论文:Harness as a Language: A Minimalist Agent Framework With Maximal Expressivity(arXiv:2609.26891,MIT CSAIL) 复刻 JAZ 的两条定义性质:
invoke是"实现由 LLM 在每次调用时提供"的语言原语;一切对 LLM 可见的东西(含 REPL 历史)都是代码环境里的变量。
本仓库包含两个 Harness bundle:
一个可安装实体(@local/dsh-jaz-invoke),内含两个入口 + 两个预设:
| 组成 | 位置 | 提供 |
|---|---|---|
| 核心 | index.js |
jaz 工具 + invoke 原语 + 持久 REPL + hooks(预算/深度/trace) |
| 模式 | mode.js(入口 @local/dsh-jaz-invoke/mode) |
jaz_agent(JAZ 子代理)、jaz_mode(运行时切入/退出)、部署默认 JAZ 模式、以及 preset 用的 restriction 挂载 |
| 预设 | cordis.patch.yml |
新会话可选的两个模式:JAZ(JAZ 风格 + base 工具)、JAZ (minimal)(论文设定) |
为什么是一个包:核心与模式共享同一套配置与守卫,preset 只是声明;拆成三个包只会多两次安装、多两次"改代码要重启"的重载。
mode采用同包第二入口,代码仍是分模块的。
git clone https://github.com/AIMentalModel/dsh-jaz.git ~/Code/dsh-plugins/dsh-jaz
# 1) 装包:dsh plugin 只代理 pnpm(<profile> 换成你的 profile,如 web)
dsh plugin --profile web add ~/Code/dsh-plugins/dsh-jaz
# 2) 启用 bundle:这一步由 Plugin Manager 负责(它写 profile 的 dsh.profile.bundles)
# · Web UI:设置 → 插件 → 启用对应 bundle
# · 或让 Agent 调用 plugin_manager 工具(action: install_bundle / set_bundle)注意别混淆两个东西:
dsh plugin是你在终端敲的 CLI 命令(只装包);plugin_manager是 Agent 工具(负责 bundle 选择与启用)。前者不会自动选中 bundle,所以第 2 步不能省。依赖:
ctx.ptcRuntime(@deepseek-ai/dsh-ptc-runtime-node)与ctx.subagents(dsh-subagent-spawn-in-process),DSH 默认 profile 已具备。包名保留@local/前缀是刻意的:它们作为本机 profile bundle 安装,不发布到 npm。
论文的 harness 只有一条 invoke 原语——没有工具列表、没有文件系统、没有记忆系统。本仓库提供三种入口,从"最省事"到"最细粒度":
安装本 bundle 后,新建会话时在 preset 选择器里二选一:
| preset | 内容 | 什么时候用 |
|---|---|---|
JAZ(推荐) |
JAZ 协议 persona + 随 DSH 一起发的 base 工具(read/write/edit/glob/grep/bash/jobs/web_search/web_fetch/todo/skill/present/compaction/subagent/workflow)+ jaz |
日常使用:要 JAZ 风格(写 cell + invoke 递归委托),但照常保留文件和网络工具,什么都不拿走 |
JAZ (minimal) |
只有 jaz(用 restriction 把继承层工具全滤掉) |
复现论文的实验设定(prompt-only、无文件系统、无记忆系统) |
preset 的工具行是从 shipped standard 预设原样复制的(连必填 config 一起),所以不会因为漏配置而挂载失败;想加减工具就在 preset 的 config.plugins 里改。
preset 是新会话才生效的:已存在的会话保持它启动时的插件集(DSH 的规则)。装完/改完新 bundle 后请开新会话验证;选择器里没看到就刷新一次页面。
JAZ (minimal)的诚实说明:restriction 通过@local/dsh-jaz-invoke/mode(restrictionOnly: true)挂载,能滤掉继承层工具(含 host 级jaz_mode/jaz_agent),但 DSH 注册表不过滤本层注册——像dsh-experimental-agent-team-profile那样直接往每个 agent 注入工具的 bundle 仍会残留spawn_teammate/team_task_*。实测见 docs/VERIFICATION.md §6。
jaz_agent({ task: "把这三篇材料的要点整理成对比表", session: "compare-1" })
子代理的工具表被 toolFilter 收窄到只有 jaz,系统提示装入同一套 JAZ 协议。它除了写 cell 什么也做不了。结果以文本返回,并在 DSH 会话轨迹里生成子代理记录。
jaz_mode({ action: "enter" }) # 隐藏除 jaz / jaz_mode 之外的全部工具,并装入 JAZ 协议提示
jaz_mode({ action: "enter", allow: ["bash"] }) # 想保留个别工具就加白名单
jaz_mode({ action: "exit" }) # 恢复原工具表
jaz_mode({ action: "status" }) # 查看当前状态
收窄是该 agent 作用域的(agent.ctx),不影响其他会话;下一个模型步生效,退出后恢复。实测:进入后 read/write/bash/web_search/subagent 全部消失、技能目录清空,退出后全部恢复。
- insert:
- id: jaz-mode
name: '@local/dsh-jaz-invoke/mode'
config:
mode: jaz # 部署级收窄(对齐论文的最简 harness)
allowTools: [] # 例如 ["bash","read"] 保留少量工具
provider: spawn
maxDepth: 2
model: null部署级配置改动需重启 Harness 生效。
注意:JAZ 模式下 agent 只能写 cell,因此它也无法调用 write/bash 等工具——这是论文"无外部系统"的设计,不是缺陷;需要落地文件时用 allow: [...] 放行。
JAZ 风格
invoke语言原语 + 持久代码 REPL,为 DeepSeek Harness 实现。 设计与取舍:DESIGN.md
jaz 是模型可调用的一个工具:你(LLM)写一段 JS(一个 REPL cell),在沙箱里执行;cell 里可以调用 invoke({...})——这是一个**"函数实现由 LLM 在每次调用时现场提供"**的原语(每次调用 = 启动一个子代理,把命名输入渲染成 JSON 给它,子代理的最终回答就是返回值)。
两条定义性质:
- 可写任意代码、可递归 invoke —— cell 里能写控制流,子代理自己也能再调
jaz,递归委托是默认行为; - 一切可见皆是变量 ——
__inputs__、__history__、__scope__以及你写的变量,都是代码环境里的普通 JS 变量("代码即上下文",不需要外部记忆库)。
| 参数 | 必填 | 说明 |
|---|---|---|
program |
✅ | REPL cell:纯 JavaScript(不是 TS),按 async 函数体执行,支持顶层 await/return;return 的 JSON 值即工具结果的 result |
inputs |
本次调用的命名输入,cell 内以 __inputs__ 访问(prompt 也是变量) |
|
session |
命名 REPL 会话。同名 cell 共享持久变量、__history__、__scope__ 与预算计数;省略 = 一次性无状态运行 |
|
reset |
先清空该 session(变量/历史/作用域/预算)再执行 |
cell 内可用全局量
await invoke(inputs, opts?) // opts: { schema?, provider?, model?, scope? }
__inputs__ // 本次工具调用的 inputs
__history__ // 本 session 内每次 invoke 的记录数组:{seq, ok, ms, stopReason, childId, inputs, output}
__scope__ // 可变对象;每次 invoke 前会把它合并进该次输入(动态作用域)const idea = await invoke({ task: '给这个插件起 3 个中文名,每个不超过 6 字', tone: '技术向' });
return { idea };files = ['a.ts', 'b.ts', 'c.ts'];
reports = [];
for (const f of files) {
reports.push(await invoke({ task: `审查 ${f} 的边界条件`, file: f }));
}
const merged = await invoke({ task: '把以下审查报告合并成一份清单', reports });
return { merged, count: reports.length };const score = await invoke(
{ text: '这家餐厅环境好但上菜慢,价格偏高。' },
{ schema: {
type: 'object',
additionalProperties: false,
properties: {
sentiment: { type: 'string', enum: ['positive', 'neutral', 'negative'] },
score: { type: 'integer' },
reasons: { type: 'array', items: { type: 'string' } }
},
required: ['sentiment', 'score', 'reasons']
} }
);
return score; // 直接是对象,不是字符串上下文快满时,把整个 REPL 历史按引用传给子代理,让它接着干——历史是普通变量,可无损传递:
// cell 1:记录
ledger = [];
for (let i = 1; i <= 12; i++) {
ledger.push({ round: i, fact: await invoke({ round: i, instruction: '虚构一条含人名/数字/地点的事实,一句话' }) });
}
return { count: ledger.length };
// cell 2(同 session):回忆 + 委托
const answers = await invoke({
task: '只依据 prev_history 和 ledger 回答以下问题,你没有别的记忆',
prev_history: __history__,
ledger,
questions: ['第 2 轮的事实是什么?', '第 7 轮的是什么?', '第 11 轮的是什么?']
});
return { answers };// cell A
notes = [];
notes.push(await invoke({ task: '总结刚才的讨论要点' }));
return notes.length;
// cell B(同 session,notes 仍是那个数组)
const next = await invoke({ task: '基于已有笔记,提出下一步 3 个动作', notes });
notes.push(next);
return { total: notes.length, next };facts = []; // ✅ 裸赋值 → 落 globalThis → 跨 cell 存活
globalThis.summary = '...'; // ✅ 显式全局 → 存活
let tmp = 1; // ❌ cell 局部(const/var 同理),下个 cell 取不到__history__/__scope__与预算计数随 session 自动持久;- 不可 JSON 序列化的变量(函数、Symbol、循环结构)不会持久,会出现在结果的
skippedVars里; - cell 抛错时:该 cell 的变量/作用域变更不落盘(不污染后续),但它花掉的 invoke 会补记进历史(标记
cellFailed: true); - session 存在插件内存里:插件重载 / Harness 重启即清空(有意为之——"记忆即状态",不引入外部存储)。
| 机制 | 默认 | 表现 |
|---|---|---|
| 调用预算(BudgetPool) | 每 session 32 次 invoke | 超限时 invoke 抛 JazInvokeError,可 catch 优雅收尾 |
| 递归深度(RecursionLimit) | maxDepth: 4 |
映射到 subagent 委派深度上限,超限的子代理启动被拒绝 |
| 单 cell 墙钟 | 15 分钟 | 由 PTC 运行时执行;超时 kind=timeout |
| 调用追踪(TrajectoryRecorder) | 始终开启 | 工具结果里的 ## invoke trace 表(seq / ms / stopReason / childId / 输入字符数 / 返回类型),另有全局 subagent/start·end 事件进会话轨迹 |
| 历史条目上限 | 每条 8000 字符 | 超出转成 {__jazTruncated, chars, preview},不会撑爆运行输出 |
预算内优雅降级的写法:
const partial = [];
try {
for (let i = 0; i < 100; i++) partial.push(await invoke({ i }));
} catch (e) {
if (e.name !== 'JazInvokeError') throw e; // 预算/深度类拒绝
}
return { done: partial.length, partial };| 场景 | 用什么 |
|---|---|
| 一两次独立委托、要读文件或跑命令 | subagent / subagent_fork |
| 确定性大规模扇出(审计 N 个文件、多角度调研、pipeline) | workflow(写死的编排脚本,同质工人,parallel/pipeline) |
| LLM 现场决定递归结构、需要跨上下文窗口的记忆、自我改进循环 | jaz(每次 invoke 的实现由 LLM 现场写,状态就是变量) |
一句话:workflow 是"写好的乐谱",jaz 是"会自己写谱的指挥"。
调整默认值(provider / maxDepth / maxInvokes / 超时 / 模型)——编辑插件行的 config:
- insert:
- id: dsh-jaz-invoke
name: '@local/dsh-jaz-invoke'
config:
provider: spawn # invoke 子代理使用的 provider(spawn | fork)
maxDepth: 4 # 递归深度上限
maxInvokes: 32 # 每 session 的 invoke 预算
maxInvokeInputChars: 24000 # 单次 invoke 输入渲染上限
maxHistoryEntryChars: 8000 # 历史条目快照上限
runTimeoutMs: 900000 # 单 cell 墙钟(毫秒)
model: null # 可选:强制子代理模型安装 / 卸载
# 安装(幂等)
plugin_manager install_bundle target=~/Code/dsh-plugins/dsh-jaz-invoke
# 卸载
plugin_manager remove_bundle target=@local/dsh-jaz-invoke注意:替换已安装 bundle 的代码需要重启 Harness(
application: restart-required);首次安装才通过 HMR 即时生效。
本地回归测试(不需要 Harness,mock 掉 PTC 与 subagent 两条缝):
cd ~/Code/dsh-plugins/dsh-jaz-invoke && node test-mock.mjs
# 覆盖:跨 cell 持久化 / __scope__ 动态作用域 / __history__ 累积 /
# __history__ 自引用传参 / 预算熔断 / 失败 cell 的 journal 合并- 子代理是完整 agent(有工具、独立会话),不是论文里"纯 LLM 写代码在同一 REPL 执行"——因此跨 invoke 边界只能传无损 JSON,函数不能当输入传;
- 变量持久是插件内存级,进程重启即失(有意取舍);
- 预算按调用次数而非美元计(subagent 缝不返回 token 计量);
invoke是顺序的;要并发扇出请用workflow的parallel,或在 cell 里Promise.all([...])(注意 PTC 绑定的并发限制)。
论文是 prompt-only 的:不给工具、不给文件系统、不给记忆系统,全靠把 invoke 当函数 + 把状态当变量。对应到本插件,论文里的"顶层 invoke"由 DSH 主 Agent 承担(它写 cell),"sub-invoke"就是 cell 里的 invoke()。
论文的提示词要点(Appendix E.1.1):
- 把
prev_history当"上一任 agent 的 REPL 历史",需要回忆时检索它,而不是看__history__——论文原话:prev_history与你自己的__history__不同,__history__已完整可见,检索它无意义; - 检索规则:用具体、独特的检索词;命中时显示上下文窗口而不是只给前缀;这一步只做检索,把后续工作留给下一轮;
- 上下文将满时(E.1.2):把剩余工作全部委托给子代理,并且
prev_history + __history__都传过去,保证已有工作不丢。
本插件等价写法(prev_history 由 session 持久化天然承担):
// 第一次调用:instructions / guidance 作为变量落进 REPL(论文的 instructions、guidance 变量)
// inputs: {"instructions": "...", "guidance": "..."}
instructions = __inputs__.instructions;
guidance = __inputs__.guidance;
prev_history = []; // 论文里"上一任的历史",session 持久
progress = { done: [], summary: '' };
return 'state initialized';
// 后续每次调用:一步工作;判满则按 E.1.2 模板尾递归委托
const step = await invoke({ instructions, guidance, prev_history, progress, next: 'do exactly one step' });
progress.done.push(step);
// 触发条件:历史条目数 / 字符数超阈值(论文 ContextWindowWarning 的等价物)
const heavy = __history__.length > 20 || JSON.stringify(__history__).length > 40000;
if (heavy) {
const carried = prev_history.concat(__history__); // 论文:prev_history + __history__ 都传
prev_history = carried;
return invoke({
instructions, guidance,
prev_history: carried,
prev_progress_summary: progress.summary,
next_steps: progress.done.slice(-5)
});
}
return { steps: progress.done.length, heavy };论文里这步写
return invoke(...)(尾递归、由 harness 做尾调用优化)。本插件里return await invoke(...)即是同一件事;跨 cell 时用prev_history变量接力,效果等价。
论文方法论(Appendix E.2):
- 按批次解题,一个子代理负责一个任务;
- 先用 ~5 个任务的种子批次跑起来并分析结果;
- 之后交替做「prompt/tool 优化」与「下一批验证」,直到任务队列耗尽;
- prompt 是主杠杆;编辑必须是跨任务通用的模式,不许针对单个任务的失败形状;
- 表现好就加大批次、减小改动;几乎全对就别改,直接换批验证;
- 错误信息是有价值的反馈,让工具/子代理该报错就报错;要观测工具使用率,不可靠的工具删掉。
本插件等价写法(把 prompt 当被优化的状态变量,opts.schema 让改进结果结构化):
prompt = globalThis.prompt || { text: '你是解题子代理,先规划再用工具执行。', notes: [] };
queue = globalThis.queue || ['t1', 't2', 't3', 't4', 't5'];
batch = queue.slice(0, 3);
const results = [];
for (const t of batch) {
results.push({ task: t, out: await invoke({ prompt: prompt.text, task: t }) });
}
queue = queue.slice(batch.length);
const improved = await invoke(
{ task: '从这批轨迹中提炼一条跨任务通用的 prompt 改进;不要针对单个任务的失败形状。', prompt: prompt.text, results },
{ schema: { type: 'object', additionalProperties: false,
properties: { newPrompt: { type: 'string' }, rationale: { type: 'string' } },
required: ['newPrompt', 'rationale'] } }
);
if (improved && improved.newPrompt) prompt = { text: improved.newPrompt, notes: prompt.notes.concat(improved.rationale) };
return { optimized: prompt.text, remaining: queue.length };论文:with BudgetPool(max_cost=5): 是 scoped(覆盖块内所有调用含递归子调用);invoke(ReturnType(float), ...) 是 local(只管这一次)。翻译:
| 论文 | 本插件 |
|---|---|
invoke(ReturnType(float), ...)(local 校验) |
该次调用传 opts.schema,只约束这一次 |
with BudgetPool(max_cost=5):(scoped 预算) |
用 session —— 预算在该 session 内累计(maxInvokes) |
with scope(web_search=tool):(scoped 能力可见) |
写 __scope__,自动合并进之后每一次 invoke 的输入 |
with ConfigOverride(llm=...): |
子代理模型:插件 config model,或单次 opts.model |
论文的成本策略是顶层用强模型、sub-invoke 用便宜模型:
const draft = await invoke({ task: '...' }, { model: 'deepseek-flash' }); // 便宜模型批量干
const final = await invoke({ task: '审校并定稿', draft }); // 默认模型定稿| 论文 hook | 状态 | 替代做法 |
|---|---|---|
ContextWindowWarning |
❌ 未实现 | cell 内自测 __history__ 体量并触发 §9.1 的委托分支 |
BudgetForcing(强制收尾) |
❌ | try { ... } catch (e) { if (e.name === 'JazInvokeError') return 收尾结果 } |
TrajectoryReplay(可重放) |
❌ | session + __history__ 已保存全部输入输出,足以自行重放,未提供 replay driver |
ValidateREPLCode(校验 cell) |
❌ | 无(PTC 沙箱本身是隔离边界) |
PrintLogger / FileLogger |
✅ 部分 | PTC logs + 结果里的 trace 表(不写文件,符合论文"无外部系统") |
TrajectoryRecorder |
✅ | trace 表 + subagent/start·end 会话事件 |