工具
工具是让模型真正去做事、而不是描述做事的东西。在 Zuno 中,一个工具携带三项互相独立的声明:它有什么副作用、是否可以被重复执行、以及是否可以与其他调用重叠。把这三者分开,才能在实现安全并行的同时不让重试变得危险。
默认工具面
长任务可使用 task_context 持久保存目标、已授权动作的理解、禁止事项、用户来源 ID/摘要、 决策归属及交付/安全验收项。get 读取;update 按 revision 局部更新;replace 明确开始新任务。 新建需要 objective、task_kind: delivery|inspection 和 source_message_ids。 通用工具参数 intent 只是 UI 标签,不是任务类型。更新时省略字段保留原值,约束与验收项合并; “不发公告”不会抹掉原先的修复目标。只有负向安全检查通过,不能把交付任务标成完成。 普通更新不能改写验收定义;显式 revise_checks: true 必须附用户来源,并把被修订的检查 重置为 pending、清空旧证据。来源摘要覆盖全部持久用户输入部分(含附件引用); 后台报告和压缩标记不能充当授权来源。
该工具只维护有界的应用上下文(ManagedContext),不授予权限、不恢复 Goal,也不触发自动续跑。 显式权限规则仍生效;子 Agent 把决策交给父 Agent,不改写 root 上下文。来源变化会标为陈旧/历史, 当前用户指令优先。简单问题和单步工作不必创建任务上下文。
默认对模型可见的工具面刻意保持精简:
| 工具 | 用途 | 副作用 |
|---|---|---|
read | 读取一个文件或目录 | 只读 |
glob | 按模式查找文件 | 只读 |
grep | 搜索文件内容 | 只读 |
write | 创建文件,或有意整体替换一个文件 | 有副作用 |
report_write | 在 .zuno/reports/ 发布不可覆盖的报告,支持只读调查 | 有副作用 |
apply_patch | 局部的、经上下文校验的源码编辑 | 有副作用 |
shell | 在当前沙箱下运行命令 | 有副作用 |
bg | 检查或取消后台命令,并分页读回未进入对话记录的输出 | 只读检查;cancel 有副作用 |
task | 把一个有界目标委派给另一个 Agent | 委派型 |
session_message | 向另一个 root 或本 root 的 child 发送带来源的持久 peer context | 有副作用;仅 root 可见 |
job | 检查后台 job 状态 | 只读 |
webfetch | 获取一个 URL | 只读 |
web_search | 批量网络搜索 | 只读 |
skill | 发现并加载可复用指令 | 只读 |
question | 结构化澄清;Plan 等待首次响应,Work/Goal 立即返回 | 用户中介型 |
question_async | 发布非阻塞问题,继续独立工作 | 用户中介型 |
plan_exit | 请求批准精确的 Plan 与已保存的 Work 执行身份 | 用户中介型;仅 Plan |
持久工作状态会额外加入 plan_get、plan_update、todo_get、todo_update 以及 goal_get/goal_update。 启用记忆时提供受限 memory_read 和 memory_update;普通更新默认自动应用,显式配置复核策略时除外。 当前 Agent 能够触达时会出现 council_run。
Task、workflow 与 Council 的结果
task 必须在顶层提供 agent、objective、deliverable、instructions 和 success_evidence,使用 task_id 续跑时也一样。intent 只作为 UI 标签,不能 替代 objective。请使用工具说明中的 JSON 示例,不要嵌套到 contract 中。 漏参反馈会依据当前 schema 给出修正说明,但不会自动补值或创建子会话。
当唯一有用的下一步是等待时,命令或子任务默认保留在前台。bg 句柄或 backgroundPurpose: "remoteObserver" 不代表已分离后台:应续等同一句柄并保持 steer/中断响应。显式后台只用于明确的并行拆分,派发前确定主线要做的独立工作并实际 推进;不能把唯一任务后台化后就结束主回合。
task 用于一次有界委派,workflow 执行配置化依赖图,council_run 收集独立报告后 合成。失败时先查看持久 job 与子会话证据:超时前可以已有成功模型请求和已完成工具。 Provider 请求及重试时间与任务总耗时是两项度量;失败任务不会自动重放。 有对应证据时,<execution_progress> 在 <task_result> 之外保留原生进展; 进展本身不证明任务已完成。
内置 balanced-review 从 600 秒总预算中预留 60 秒合成,剩余 540 秒由全部席位、 排队和重试共享。每次尝试的持久提示包含剩余硬时间与 UTC 截止时间。0/2 统计有效终态 报告与 quorum,不统计 provider 成功次数。取消、失败、超时或不确定状态保留已记录进展, 但不能将进展当成有效报告;Council 成功仍要求满足 quorum 且合成成功。 只有原生执行完成但结构化报告 invalid 时,才可消耗 preset 的有界重试; 原生失败或 host Err 不会重放席位。 范围、配置与恢复边界见 Agent 编排。
持久问题与等待
问题具有稳定的请求 ID、问题项 ID 和 revision。TUI、HTTP、ACP 共用 QuestionPort, 发布、查询、回答与等待是不同操作。Plan 中的 question 等待首次响应;选择“稍后” 会解除工具等待,但不会把问题标记为已回答。普通 Work 与活跃 Goal 使用同名的 question 发布可选澄清后立即返回回执,不登记会话等待,也不暂停 Goal。question_async 在 Plan 中也立即返回。可选问题未回答时,Agent 仍可依据已有证据和明确说明的假设,在现有授权 范围内继续独立工作或给出总结。部分回答会保存已确认内容,其余问题仍为 pending。
真实回答只通过持久 inbox 进入模型一次,不会同时出现在工具结果和后续 callback 中。 响应携带 commandId 与 expectedRevision:相同命令幂等重试,旧 revision 或同 ID 不同 内容被拒绝。question_async 由 question 权限键管理。未输入、光标高亮、保存草稿、 超时、取消、跳过和“稍后”均不是批准。跳过问题不要求 Agent 再次进入澄清循环。
普通 deferred 澄清有原生 120 秒自动延后期限:前 60 秒静默,后 60 秒显示倒计时。 到期不会提交答案、选择高亮选项、关闭问题或创建模型输入;问题保留稳定 ID,用户之后 仍可补充回答,也可以不回答。交互会发送 Snooze 暂停计时并保留草稿。重启沿用原期限, 重复计时器及回答/超时竞态由原有事务和 revision 校验处理。Blocking、必需输入、 Plan 批准及 Goal 恢复不自动处理;子模型的普通澄清交给父 Agent,原生权限控制不变。
必要输入由显式控制处理,与可选澄清分开。Goal 的 goal_request_input 绑定已观察到的 Goal revision,在一个事务中记录 Goal 暂停和会话等待。只有对该精确请求的真实回答 才能满足等待条件,跳过、稍后处理或无关 callback 都不能满足。被委派的 child 向 parent 报告阻碍,不直接向用户提问。
plan_exit 发布非阻塞的封闭批准请求。只有明确的 approve 操作才能授权所展示的 Plan revision、Agent/模型身份及 review 状态。提前批准要等来源 Plan 回合正常交接后才生效; Plan 或身份变化、回合中断都会使旧批准失效。Draft review 的风险接受必须由用户明确给出。
自动续跑必须有可执行事项;当前周期已接纳且获授权的 in_progress Plan 步骤无需 重复 Todo 即可表示工作,但不绕过关联 Todo 的归属/依赖、未结算 Job 或已记录门禁。 未归属的 Plan 或 blocked Todo 本身不是依据。暂停后可用 /resume 明确恢复原有 Work,但它不能跳过仍在等待的人工/外部条件,也不能代替 Plan 批准。 若 Goal 自身已暂停,应先使用 /goal resume。
终态 bg output、bg wait 和 callback 共用完成消费回执。内联读取会原子撤销尚未被消费的 排队 callback;已 promoted/consumed 的 callback 不会被取消。完成事实保留原工作周期, 不能通过新建周期重置无进展计数。
即使父 Attempt 包含它的 schema,session_message 也会从每个 delegated child 的工具快照中 移除。执行时还会再次校验:来源必须是 root,目标必须未归档且属于同一项目,child 目标必须是 该来源 root 的后代。持久目标输入会明确记录来源 session、Agent 与标题,并说明 peer context 不是用户授权。每次发送仍受权限规则控制。
当已连接的 MCP 工具通过能力与权限过滤后,Zuno 仍保留其可执行实现,但默认不把所有 完整 JSON schema 注入模型请求。此时会条件性出现 tool_search:它搜索紧凑元数据, 匹配项从下一次模型 step 开始进入可见工具集。这样无需在每次请求中支付所有外部服务的 提示词成本。参见 MCP server。
可选的连续性工具只有在 continuity 开启,并且通过最终工具与权限过滤后才会出现:
| 工具 | action | 作用域 |
|---|---|---|
history | list_windows、list_items、read_item、search_contents | 当前会话的规范化证据 |
notes | list_files_by_prefix、read_file、search_contents、append_to_file、write_file | 当前会话与 Agent 的逻辑文档 |
History 只把成功压缩作为窗口边界。返回内容排除 reasoning、加密值、合成的内部提示 正文和二进制附件字节,并且只能作为数据而不是指令处理。
Notes 从不暴露宿主路径。每个作用域最多 100 个文档,单文档最多 256 KiB,总计最多 1 MiB。两个写 action 都必须携带精确的 expected_revision;只有新建文档时使用 0。可信 call_id、请求摘要和 revision 让重复投递保持幂等,同时拒绝过期的并发写入。
显式 Plan 协作模式要求持久战略 Plan。普通 Work 模式由模型依据完整会话决定 Plan 是否 增加价值;宿主不解析提示词关键词,也不会生成用户可见的通用步骤。每次调用 plan_update 前,模型都要立即调用 plan_get,并把返回的当前 revision 原样放进 expected_revision。只有 plan_get 返回 null 后的首次 action=create 可以省略; 替换已有根 Plan(包括真正的新目标)仍必须携带当前 revision。模型用 plan_update action=create 创建首个 Plan 或替换新目标;patch 只修改指定 id, append 追加由宿主生成 id 的步骤,push 打开聚焦子 Plan,pop 不重传整份 Plan 而只恢复精确父 Plan。所有已有 Plan 修改都必须带当前 expected_revision; completed 和 superseded 都是终态。plan_update、notes、history 的 action 是必填枚举:线上 schema 列出每个操作以及它需要的字段,没有指明操作的调用在到达工具之前 就会被拒绝。
plan_get 与 todo_get 会在结果中附带语义进展指纹;intent 等跨工具参数不进入指纹。 连续三次只有一个读取工具且状态不变时,以 stagnant_tool_loop 停止,而不是无限消耗 provider 请求。真实 mutation 会改变指纹并重置计数。
plan_update 与 todo_update 还会请求动态上下文失效。若下一次 provider 请求仍会运行, 宿主必须先从已提交数据库重新生成 Goal/Plan/Todo/Job 投影。在显式 Plan 模式中, Plan mutation 会把一次性的 Required 指令切换成 Maintain。刷新器缺失或失败时回合会 暂停,而不是明知上下文过期仍发送 developer 优先级快照。
模型 final 后,durable reconciliation driver 保留确切等待和受保护门禁。 普通 final 结束当前周期,即使 Plan/Todo 尚未完成,也不会将其伪造为完成, 或仅为对账再调用一次模型。只有当前周期拥有的活跃 Goal 保留自主续跑与持久进度指纹; 连续三次相同观察仍会触发 typed no_progress。工具结果跟进和显式前台等待仍在逻辑回合内继续。
内置只读 plan Agent 有一条例外的带类型交接语义:规划回答完成后,当前 Plan 与 Todo 会保留各自已有状态,作为后续 Start Work 回合的执行工作,不消耗对账 续轮;保留的 Job 可继续检查,但它本身不是 Work 授权。
串行依赖结果时默认前台等待;仅当父会话有独立并行工作时显式后台化。 后台完成通过 callback 唤醒仍符合条件的原周期,不能复活已停止周期。 bg wait 的单次观察最长 60 秒,超时不是远端失败,也不自动分离后台。 同步读取与 callback 共用 durable completion owner,结果只消费一次。
完整的开启/关闭、profile 覆盖、权限、revision 与重启说明见 History 与 Notes 连续性配置。
edit、execute 和 lsp 作为已注册的槽位存在,但不属于默认工具面。edit 仍可供显式构造的 profile 使用;默认的编辑路径是 apply_patch 加 write。
只读任务的报告文件
report_write 接收 name(如 audit.md 的纯文件名)与 content(完整 UTF-8 正文,最多 1 MiB)。宿主选择工作树 .zuno/reports/ 下的路径,在文件名中加入会话与 调用身份的摘要,并返回确切路径、字节数与内容 SHA-256。每次调用创建新产物,不能覆盖 已有报告或选择任意目标。目录路径、符号链接祖先和在支持平台上不安全的文件名会被拒绝。
报告能力独立于 edit,不会授予 Shell 写权限。父子工具交集和 report_write 权限规则 仍然生效;strict 模式把发布视为副作用,重放策略保持 Never。完整调用及其 reportArtifact receipt 会持久化,原生子任务报告的 artifacts 也携带这些 receipt, 因此后台交付与历史重放保留同一文件引用。引用工具返回的路径,由有编辑权限的父 Agent 按需整合到其他位置。报告被 Git 忽略,作为交付产物保留;会话清理不会删除它们,需显式移除。
apply_patch 冲突恢复
apply_patch 使用稳定的 SHA-256 读取凭据与读取代次,而不是信任模型记忆中的文本。 已有文件作为 update、delete 或 move 的源时必须先读取;已经存在的 move 目标也必须 先读取。工具在同一把 mutation lock 内完成全部文件的预检,全部通过后才写入,因此任一 预检冲突都会保证零写入。
变更冲突是类型化、可由模型修正、但绝不会自动重放的结果:
| 冲突 | 含义 | 必须执行的恢复动作 |
|---|---|---|
ReadRequired | 当前文件没有有效读取凭据 | 先读取错误中指定的资源,再构造变更 |
StaleRead | 文件在读取后发生了变化 | 重新读取当前文件,并基于新内容重建操作 |
ContextMismatch | hunk 与当前逻辑行不再匹配 | 读取指定 hunk 附近,使用新鲜且唯一的上下文生成更小补丁 |
IdenticalReplay | 相同操作摘要再次作用于相同文件内容 | 不得原样重发;修改操作,或等待文件真实变化后重新读取 |
冲突会携带资源、操作摘要、当前内容摘要、适用时的 hunk 编号/标题,以及明确的 requiredAction。匹配以逻辑行为单位,同时保留原文件的 BOM、LF/CRLF 风格和末尾换行 状态。补丁语法错误仍属于 InvalidArgs。如果写入后才发生 I/O 或格式化失败,结果属于 Uncertain,列出已经观察到变更的路径,并要求先检查实际状态,禁止机械重放。
webfetch 只接受无凭据 HTTP(S) 目标。Zuno 会解析并校验全部地址,整体拒绝公私混合 DNS,固定已经校验的地址,并在最多五次重定向的每一跳重复校验。它遵循进程级 HTTP_PROXY、HTTPS_PROXY、ALL_PROXY 与 NO_PROXY,通过 HTTP、HTTPS、 SOCKS4 或 SOCKS5 代理连接已经校验的目标 IP,同时保留原始 Host 与 TLS SNI。 代理失败不会静默改为直连。默认超时 30 秒,单次最大 120 秒;超时错误会报告 route、 phase 与 elapsed。
web_search 不会把 provider 凭据或完整 wire URL 放进错误与日志。诊断只标识 provider、scheme、host、path、状态与错误类别,不包含 API key、认证头或完整 query。
glob 与 grep 驱动官方的 rg 可执行文件,Zuno 只贡献带类型的参数、取消、有界解码、稳定排序与路径校验。这两个工具需要 ripgrep 14 或更新版本。发现是惰性的,且只作用于它们:缺少 rg 时 glob 与 grep 会报出带类型的工具错误,而不是静默回退到更慢的遍历器,但它不会妨碍 Zuno 启动、读取配置、访问 provider 或打开数据库。
两个工具都会报出「调用被拒绝」,而不是返回空结果。无法解析的 pattern 或 include glob 是「参数无效」,其中标明的是 rg 自己引用的那个 pattern,因此由 include 造成的拒绝绝不会算到正则头上;超过 ripgrep 自身编译尺寸上限的正则以同样方式报出。输出超过 64 MiB 的运行会被拒绝,并建议收窄 pattern、路径或 include,因为无法从并行遍历的一部分里确定稳定顺序。限定在某一路径上、而 rg 始终无法读取该路径的 grep,是一次被拒绝的调用,不是「没有匹配」。
路径是标识符,不是展示文本,因此两个工具只返回它们能命名的路径:每个结果都使用文件实际拥有的名字。名字不是合法 UTF-8 的文件——来自旧归档的历史编码,或 NTFS 名字里的孤立代理项——会被排除,而不是用替换字符报出一个并不指向任何文件的名字;grep 命中所在的文件同理。
在 Linux 与 macOS 上,文件名中的反斜杠会原样返回,因为反斜杠在那里是普通的文件名字节;在 Windows 上,ripgrep 的反斜杠分隔符会以 / 报告,该平台同样接受这种写法。名字中包含换行的文件,会作为它本来的那一个路径返回。
匹配行上限为 2000 个 UTF-16 码元,单条超过 1 MiB 的 rg 记录会被跳过。只要有匹配被丢弃——无论是因为达到结果上限,还是因为它的路径无法命名——渲染出的 glob/grep 输出都会标记为已截断。每条路径只返回一次,作为一个真实文件的标识符。
遍历无法进入的目录不贡献任何结果,并报为「No files found」而不是权限失败。ripgrep 的逐路径诊断会被抑制,避免根目录下别处的某个不可读目录把「pattern 没匹配到」变成工具失败。对于子树,Zuno 不区分「不存在」与「读不到」。
若 Shell 命令只观察远端工作,请设置 backgroundPurpose: "remoteObserver"。该值随后台 执行持久化,并通过 bg 与持久 completion input 投影。它不会改变权限,也不会把远端系统 状态等同于本地进程退出;恢复后的 Agent 仍必须重新查询 CI、部署或 release 的权威状态。 当观察器仍在运行且 Plan、Todo 或 Job 尚未结束时,driver 记录 waiting_background: 当前回合可以结束,不轮询,也不创建通用人工问题,不会立即自动续跑活跃 Goal。 观察器的持久终态报告会唤醒会话,权威刷新后再恢复普通对账。
按窗口读取输出
没有进入对话记录的输出通过 bg 读回,每次一个有界窗口。它有两种来源,共用一套游标约定:
| 动作 | 读取内容 | 指明来源的参数 |
|---|---|---|
bg output、bg wait | 某个后台执行的输出 | taskID |
bg artifact | 因超过输出上限而被扣留的输出 | outputPath |
cursor 是绝对字节偏移,limit 是窗口大小。两种读取都会返回下一个窗口的起始游标以及 hasMore,因此向后分页的方式是把该游标交回来,而不是自己猜偏移;同时还会返回 windowFrom 与 hasEarlier,即同样两个事实的反方向,让窗口直接说明自己从哪里开始,而不必用游标减去长度。 limit 默认 16384 字节,并被夹取到 51200。这个上限是固定的 —— bg 的一个窗口与 read 的 一次文件读取受同一种约束 —— 并且有意不随 tool_output.max_bytes 变化:调低内联阈值不会缩小读回 窗口,因为读回正是把该阈值扣留下来的输出取回来的途径。窗口在读取任何字节之前就已确定上界,请求 超过上限时会被夹取,而不是被拒绝。
正在运行的命令在内存中保留最近 2 MiB,同时完整输出留在磁盘上。早于仍被保留范围的 cursor 会从该文件提供,因此长时间运行命令输出的开头仍可通过 bg 读到,而不需要用 shell 命令去手工 切文件。fromDisk 说明这次由哪一份副本作答。
不带 cursor 的 bg output 与 bg wait 返回最新的那个窗口:即终端渲染的尾部,结束于 totalWritten,因为命令是在那里说明发生了什么。以这种方式轮询正在运行的命令会持续看到新字节, 而 windowFrom 就是向前回溯的起点。bg artifact 恰好相反 —— 被扣留的产物不会再增长,而扣留 它的提示已经给出了总字节数 —— 所以不带游标的读取从第 0 字节开始向后分页。
读回既不会重跑产生该输出的命令,也不会再走一遍输出上限:取回的窗口本身已经有界,再过一遍上限 只会让它被第二次扣留。在 execute 内部也是如此:那里所有子调用共享一份 50000 字节的预算,超过 本次组合所占份额的窗口既不会内联,也不会被再次落盘。该段落会指明仍然保存着全部字节的产物,以及 能放得下的 limit,于是下一次读取是缩小窗口,而不是转向一个新文件。
Shell 退出状态能证明什么
管道的退出码就是最后一段的退出码。cargo test | tail -5 在测试失败时仍然退出 0,因为决定它的只有 tail。因此 Zuno 默认为每次 shell 调用启用 set -o pipefail:管道中任何一段失败,都是整条命令的失败。
exitPolicy 用来显式选择这一行为:
| 取值 | 效果 | 退出状态的权威性 |
|---|---|---|
pipefail(默认) | 管道中任何一段失败即命令失败 | 在 bash、ksh、zsh 上是权威的 |
all | 另外在序列中第一条失败的命令处停止 | 权威 |
last | 只由最后一段决定,即 POSIX 默认 | 推导得出 |
只有当命令本身允许某一段失败时,才应刻意选择 last。
每个结果都带一份验证凭据,说明运行了什么、判定结果、退出码,以及该退出码的覆盖 范围。这一区分正是重点:derived 状态会被记录,但不构成证据,因为该退出码来自 一个根本没看到失败的阶段。解释器同样重要。set -o pipefail 不属于 POSIX,而 dash 在被要求启用它时会直接退出、根本不运行命令;因此不在已知可用集合内的解释 器只报告 derived,并在凭据中写明自身。sh 被刻意视为未知,因为这个名字并不说 明背后是什么。PowerShell 没有对应机制,所以在那里只有 all 是权威的。
配置只是起点。Zuno 还会读命令自身的文本,因为跑在一个权威 shell 里的文本同样可以 把这份保证拆掉:cargo test || true 无论测试结果如何都退出 0,set +e 会把前导 语句设好的选项关掉,bash -c '…' 在它自己的选项下运行,写成 if 或 while 条 件的检查在失败时退出 0,循环只报告最后一次迭代,而在 all 下,出现在最后一条语 句之前的 && 链不受 set -e 约束。在 PowerShell 中,一个原生命令管道给另一个原 生命令时,$LASTEXITCODE 里只剩后者的退出码。
这些都不改变实际执行的内容。命令仍按原样运行,|| 兜底本来就该生效;改变的是那 份声明:它降为 derived,并附上一条写明构造与补救办法的限制说明。这里读的是语法 树而不是字符匹配,所以 echo 'run tests || true' 什么都没掩盖,配置的判定继续成 立。
已存储的凭据由一个标识符寻址,它以 [verification rcp_…] 的形式出现在工具结果 中。能够满足"需要证据"的 Goal 完成判据的,是这个标识符,而不是对会话记录的回忆。
guard 保留的退出码
每条 shell 命令都运行在一个子进程 guard 之后,它把命令的进程树绑定到 Zuno 自身的 生命周期上。guard 通过与命令相同的退出状态汇报,因此有三个退出码可能属于 guard 而不 属于命令,沿用 timeout、env 与 nohup 的约定:125 表示 guard 自身失败,126 表示程序存在但无法执行,127 表示找不到程序。
三者的解读不同,因为含义不同。125 对命令是否运行过、改了什么一概不说,所以结果是一 个不确定的结局,而不是一份 Failed exit 125 的收据:它绝不会被自动重放,并要求先查看 命令本会改动的真实状态再决定下一步。126 与 127 说明程序根本没启动,因此退出码会被 记录,但对命令没有任何权威性;没有任何东西运行过,也就没有什么可判定的。
一条自己选择以 125 退出的命令保留它的普通收据。只有当 guard 自身的诊断出现在捕获的 输出中时,保留码才会被读作 guard 的判定,而这一行只有 guard 会写,所以 make 报出的 Error 125 仍是 make 的判定。被信号杀死的命令没有退出码,会被报告为被信号杀死,同样 对工作本身不作任何判定。
取消一条正在运行的命令
中断一次回合会取消它正在等待的那条 shell 命令,返回的是一份已结算的结果,而不是一条 干巴巴的错误。命令此前写出的每个字节都会保留,只要存在退出信息就会一并报告,并且结果 会说明发生的是两种取消中的哪一种——因为这两种情况允许的下一步并不相同。
| 中断时命令处于什么状态 | 退出状态 | 验证凭据 |
|---|---|---|
| 命令已经退出并报告了自己的状态 | 照常报告,并保有其原有权威性 | 与正常完成的运行相同的凭据 |
| 其他任何情况 | 记录里有就照实报告,但它什么都判定不了 | unknown,且没有退出码 |
第一种情况并不是不确定的结局。杀进程的动作落在一个已经消失的进程上,所以命令自己的判定 依然成立,结果就是它本来该得到的那一份;它只会被标记为已取消,别的什么都不变。
第二种情况是不确定的,并且在第一行就说明了这一点:命令什么都没判定,它此前已经改动了什么 也无法从这份结果里读出来。它要求先查看命令本会改动的权威状态,然后再决定是否重试。shell 从不自行重放一次调用——一条命令完全可能在被杀之前只提交了一半的写入——所以这种结局是以 查看真实状态收尾,而不是以再试一次收尾。
退出状态里有一个数字,并不足以把一次取消归入第一种情况;结果会指明这个数字证明不了什么, 而不是假装根本没有这个数字:
- 属于 guard 自身失败的
exit 125对命令是否运行过一概不说,理由与 上文的保留退出码相同; - 撞上硬上限的命令是在那里被杀掉的,而不是自己判定了什么;
- 运行确实报告过、但 Zuno 无法结算的状态——例如进程退出之后输出捕获出错——不是任何东西 可以引用的判定。
被保留下来的输出走的是普通的输出上限。一条产出超过内联阈值的被取消命令, 每个字节都会被保存,并拿到那条指明按窗口读回方式的提示,而取消说明仍在它前面。任何内容 都不会被截断;什么都没写出的被取消命令会显示 (no output),而不是返回一片空白。
一条 Shell 命令继承什么
shell 调用使用 Zuno 进程自身的宿主环境,但会先剔除 Zuno 自有的那一部分。ZUNO_* 是 Zuno 私有的配置命名空间,对模型组装出来的命令,整个命名空间都会被扣留:
| 变量 | 处理方式 |
|---|---|
ZUNO_* | 整个扣留。命名空间里没有任何一个名字会被继承,包括那些看起来只是裸身份的 |
ZUNO、AGENT | 继承。这两个「本进程由 Zuno 启动」的裸标记不在该命名空间内 |
| 其余变量 | 继承 |
这是一条命名空间规则,而不是一份「已知含密钥的变量」清单。清单必须对 Zuno 今后读取的 每一个变量都保持正确,而它已经错过一次:ZUNO_AUTH_CONTENT 被扣留了, ZUNO_CONFIG_CONTENT 却没有 —— 后者是同一种注入文档形态,在 provider.<id>.options.apiKey 下承载着同样的 provider 密钥,只不过它是以配置、而不是以 凭据的身份到来的。扣留整个命名空间只需要正确一次,Zuno 下一个读取的变量,在任何人注意到 它装着什么之前就已经被扣留了。
没有允许列表。早先的版本继承过三个名字 —— ZUNO_PID、ZUNO_CLIENT、 ZUNO_WORKSPACE_ID —— 理由是一个 pid、一个客户端界面、一个工作区标识都既不是文档也不是 凭据。这对每一个值本身都成立,却答错了问题:pid 正是上面被扣留的每一份文档的地址。在 未受限的执行路径上,Linux 上的 tr '\0' '\n' < /proc/$ZUNO_PID/environ、macOS 上的 ps eww $ZUNO_PID 就能把 Zuno 进程的环境原样读回来,所以把 pid 交给模型组装的命令,等于 把一个探测步骤变成一行命令。另外两个一起被去掉,是因为 Zuno 之外没有任何东西读它们: ZUNO_CLIENT 由 Zuno 自己在进程内读取,ZUNO_WORKSPACE_ID 除了 CLI 自己的标志快照之外 无人读取。只需要知道「我是否跑在 Zuno 下」的脚本读 ZUNO 或 AGENT,这两个在命名空间之外, 不受影响。
变量名按大小写不敏感比较,因为 Windows 的环境变量名本身就不区分大小写,在那里 %zuno_server_password% 指的是同一份密钥。这个大小写折叠只允许让被扣留的集合变大:它决定 的是移除什么,而且没有任何允许列表供它把一个名字折叠上去。方向在这里很关键 —— 如果存在 允许列表,Linux 上的 ZUNO_Pid 是与 ZUNO_PID 不同的变量、可以装任何东西,而大小写不敏感 的比较会因为它「看起来像一个被允许的名字」而把它继承下来。
移除发生在任何宿主提供的环境 hook 之前,因此「模型组装的命令能读到什么」这个决定始终由 宿主唯一持有。确实需要某个值出现在工具环境里的部署,可以通过那个 hook 明确地把它放回去; 随发布的配置不会替你放回任何一个。
命名空间之外的变量都是有意继承的。对 *_API_KEY 与 *_TOKEN 做通配过滤的方案经过评估后 被否决:它会静默破坏 gh、aws、az、gcloud,也会破坏每一个因为命令需要而自行 导出 token 的用户。一个悄悄拿走命令所需凭据的工具,比一个保留凭据的工具失败得更糟, 因为这种移除会在别处以一条无法解释的认证错误浮现出来。
有一个后果值得直说:从 shell 调用内部启动的嵌套 zuno 不会继承这个命名空间里的任何 东西 —— ZUNO_CONFIG_CONTENT、ZUNO_CONFIG、ZUNO_CONFIG_DIR、ZUNO_DB、 ZUNO_MODELS_* 与 ZUNO_SANDBOX_* 都不会 —— 因此它会按普通方式解析配置、凭据和自己的 数据库,并且需要自备这些。交互式终端不受影响,因为那里的 shell 由你驱动,而不是由模型 组装。
扣留是纵深防御,不是一条封闭边界。在未受限的执行路径上,Zuno 进程的环境从宿主上仍然是 可读的,所以某个变量不出现在命令自己的环境里,只是提高了读到它的代价,并不保证它读不到。 真正收窄这一点的是沙箱;这次移除的作用,是让一次 env 或一次 printenv 不再足够。
预检仓库读取的时间预算
git commit 与 git add 在运行之前会先被检查,以便这次调用能够拒绝交付生成物。这项检查最多进行四次 git 读取,而它们共享整个阶段的同一份三十秒预算 —— 不是每次三十秒。这个数字是总量:读取本身竞速的截止时间是预算减去一小段预留,而这段预留正是用来停止一次始终没有回答的读取,因此配置的三十秒不可能放行一个六十秒的阶段。
一次没能及时回答的读取是拒绝,而不是通过:
`git status --porcelain` did not answer within the 30s this command's pre-flight repository reads share, so the repository state it depends on is unknown; it was not run命令不会被运行。未知的仓库不等于空仓库,把一次卡住的读取当成“没什么可报告的”,会让一个停滞的 .git 变成提交的许可。取消这次调用同样以拒绝收场。这两种情况下,被放弃的 git 以及它启动的一切都会作为一个进程组先被停止,然后才返回拒绝。
一次 Shell 调用会报告自己写了什么
write、edit、apply_patch 都会说出自己改动的文件。现在 shell 也会,用的是同一个 writtenPaths 元数据键,因此一条改动工作区的命令,对所有建立在这个键之上的东西都可见 —— 目标存储从“问题”升级为“改动”、让过期验证凭据失效的新鲜度标记、被打断的调用需要结算的变更文件清单,以及每个客户端的工具调用视图。在此之前,shell {"command": "sed -i 's/foo/bar/' src/lib.rs"} 可以在完全没有证据的情况下完成一个目标,因为没有任何东西告诉过存储发生了改动。
报告来自观察,从不来自推断。命令中每一个能静态确定的路径都会在运行前后各检查一次,只有两次观察不一致时才会被报告 —— 因为文件系统说这个文件变了,而不是因为某个命令名看起来像在写。所以它是一个有意为之的下界。不会被报告的有:交给 shell 展开的目标($OUT、*.rs、$(ls)、here-document、重定向),它们被跳过而不是被猜测;工作区之外、且不在这次调用获授权目录之内的路径;删除,因为这个键的含义是“这个文件现在在这里,可以再读一遍”;既没改变大小也没改变修改时间的重写;以及被提升到后台的命令,它在调用返回时还在写。漏掉一个路径的代价是少了一份证据;凭空造出一个路径,则会让一份凭据因为一个根本没被碰过的文件而失效 —— 所以这个下界宁可保持沉默。
副作用分类
每次调用都归为四种副作用之一,而默认是最严格的那一种:
| 副作用 | 含义 | strict 模式下的批准 |
|---|---|---|
ReadOnly | 观察状态而不改变它 | 不需要 |
UserMediated | 按设计需要人类输入 | 不是批准面 |
Delegating | 运行携带自身副作用的子级工作 | 每次子级调用重新评估 |
SideEffecting | 默认。改变状态或触达外部 | 需要一次新的批准 |
由于 SideEffecting 是默认值,一个未知的 harness 或 MCP 工具会失败即拒绝。混合型工具可以基于校验后的参数来分类:bg list、bg output、bg wait 和 bg artifact 是只读的,而 bg cancel 有副作用。
原生读取、glob、grep、skill 与会话与 job 检查、tool_search、只读 LSP、MCP 资源读取、webfetch 和 web_search 不会收到额外的 strict 询问。Shell、文件写入、 持久状态变更、委派、产品 Agent、扩展生命周期变更以及未知的 MCP 工具会收到。
混合型工具按通过校验后的 action 决定策略。所有 history action 和三个 Notes 读取 action 都是 ReadOnly;Notes 追加与替换是 SideEffecting。缺少或未知的 Notes action 会按 SideEffecting 失败即拒绝。
重放策略
工具执行默认是至多一次。除非某个实现显式声明 Safe,否则继承 Never,而当前的 安全集合都是只读或幂等的检查类操作:文件读取、glob、grep、skill 查找、当前会话 History、Notes 读取、job 状态、LSP 检查、goal 状态、外部工具元数据搜索,以及网络 搜索或获取。Notes 写入保持 Never。
| 策略 | 失败后的行为 |
|---|---|
Never | 把结果落盘、下一步交给模型,并要求在任何新的变更之前先检查权威状态 |
Safe | 可以在退避之后再次尝试 |
循环绝不会机械重放一次调用。副作用附近的超时或响应丢失是一个不确定的结果,包括外部效果可能在响应丢失前就已完成的情况。后续的恢复回合会收到一条指明重试尝试的通知,它来自 SQL,而不是对话记录里的一段散文。
这就是为什么一条超时的 shell 命令不会被简单地再跑一次。
并发
重叠是与重放安全性相互独立的一项声明。
| 策略 | 行为 |
|---|---|
Exclusive | 默认。一道双向屏障:更早的重叠调用先结算,并且在它结算之前不启动任何更晚的调用 |
ParallelSafe | 可以在配置的上限内与其他非独占调用重叠 |
IsolatedBackground | 可以在前台路径之外运行时重叠 |
调度器按模型给出的顺序解析工具、校验参数、运行 hook 并请求权限。随后它在上限内执行连续的非独占调用,并按原始调用顺序落盘结果,与物理完成顺序无关。Shell、写入、未知的扩展工具,以及没有显式声明的 MCP 工具,仍然保持独占。
{
"concurrency": {
"tool_calls": 8,
"delegations": 8,
"mcp_connections": 8,
"lsp_requests": 4
}
}2
3
4
5
6
7
8
每个字段都接受 1..=64。把某一项设为 1 会让该层恢复串行行为。
启用与禁用工具
顶层 tools 映射是按工具名索引的逐工具开关:
{
"tools": {
"webfetch": false
}
}2
3
4
5
这是可用性,不是授权。授权是 permission.rules,并且一个 Agent 还可以声明一份确切的 tools 允许列表,它替换而不是扩展默认工具面。参见权限与沙箱和自定义 Agent。
拒绝是一种结果,不是失败
参数格式错误、工具不可用和权限拒绝,都会在追加对模型可见的错误结果之前,先发出一个带 invalid_arguments、unavailable 或 denied 的 dispatch-blocked 事件。持久状态保留 outcome: "blocked" 及阻塞类别,因此客户端可以说明请求的效果从未执行,而不是暗示它执行到一半失败了。
进程、传输和实现层面的失败仍然是 error 结果。读对话记录时值得记住这个区别:blocked 意味着什么都没发生。
输出上限
{
"tool_output": {
"max_bytes": 51200,
"max_lines": 2000
}
}2
3
4
5
6
以上是默认值。超过任一上限的输出会被扣留,而不是被允许吃掉模型窗口。它既不会被截断也不会丢失,调用本身仍然成功:每个字节都会先落盘 —— 对 shell 来说落盘的正是命令写出的原始字节,而不是解码后的副本 —— 模型收到的则是一条通知,写明实测大小、越过的那个上限、产物及其大小,以及把它读回来的方式,即带 action: "artifact" 的 bg。
扣留是一种结果,不是失败。命令的退出码、验证收据与沙箱事实仍然留在结果上,产物地址在持久 tool part 上记录了两处:outputPaths,以及一个带类型的 withheldOutput 对象,因此客户端界面或后续轮次无需解析通知文本就能提供读回入口。仍然可以用 accept_large_output: true 让这类输出直接内联返回,但那会重跑该调用并花掉这条上限本来要保护的上下文,所以它是一个明确的后门,而不是通知里首先建议的做法。