Agent-policy parity
一个 agent 的策略——它可以调用哪些工具、何时必须请求批准、其运行时预算、其内存范围以及其技能——无论通过哪条代码路径组装和运行这一轮,都必须以完全相同的方式执行。ZeroClaw 通过几条不同的构建路径来构建一轮,而历史上每条路径都各自应用了该策略本身。当同一策略在多个地方被重新推导时,在一条路径上被遵守的设置可能会在另一条路径上被静默跳过。
#8120(来自一个 agent 的 MCP tools 出现在另一个 agent 的会话中)就是这样一种分歧:channel path 所应用的按 agent 工具作用域在另一条构造路径上缺失。agent-policy parity harness 的存在是为了在这类 bug 发布前将其暴露出来,而它所依赖的 trunk(#8156)则旨在通过构造将其变得不可能。
构建路径
一个轮次的引擎输入(工具注册表、审批管理器、已解析的运行时旋钮)是在几个不同位置组装的:
| 路径 | 它构建引擎输入的位置 |
|---|---|
| 通道 | 通道协调器 |
| RPC | Agent 结构体 (from_config / turn) |
| 网关 | 网关服务器 |
loop_::run | 非交互式运行:cron 作业、守护进程心跳、子代理生成 |
| 委托 | 子代理委派 |
| SOP 实时嵌套步骤 | drive_live_sop_actions:委托给其他智能体的步骤会在执行过程中重新组装该智能体的引擎输入 |
每条路径都必须针对同一代理配置向引擎传入相同的策略。一致性测试框架正是对此进行断言:在一条路径上强制执行的设置,必须在每条路径上都得到强制执行。SOP 实时嵌套步骤路径是在一个已经运行的轮次中的子轮次:当某个步骤指定了不同的代理时,其完整执行契约会通过同一组装入口重新构建,而不是从父轮次继承——包括受门控的工具、安全策略、MCP 作用域、提供商绑定和温度、已解析的运行时控制项,以及一个在父级界面的交互模式下携带步骤代理风险配置的审批管理器。该步骤在显式的子级会话记录上运行(使用其自身的系统提示和步骤上下文;父级对话永远不会到达步骤代理的提供商),其记录会将步骤代理标记为执行身份,并将委派代理作为父级关联。无法重新构建的路径会使跨代理步骤安全失败。
奇偶校验矩阵
对于每个策略设置和每个构建路径,该设置要么被强制执行,要么部分强制执行,要么未应用。(setting x path) 的矩阵是一份经过审计的偏差记录:每个设置在哪些地方应用、哪些地方未应用,并与源进行核对验证。一个“gap”单元格表示某个路径省略了某个设置。
在项目的治理原则下,缺口是缺陷,不是默认值:省略不是授权。 一个未能应用限制的构造路径,实际上是意外扩大了代理的权限,而这正是 #8120 所体现的失败。
收敛目标:一个分辨率接缝
结构性修复是停止按路径重新派生策略。#8156 引入了 ResolvedAgentExecution 承载体——将引擎按代理的输入无行为差异地重新分组为一个捆绑包(agent/turn/execution.rs)。此更改添加了它的 ResolvedAgentExecution::resolve 构造函数,并让每条生产 turn 路径都通过它(将输入分组为 ResolvedIo + ResolvedRuntimeKnobs 层),因此该捆绑包在一个接缝处生成,而不是在每个站点内联组装。如今 resolve() 只是展开已解析的输入(无行为差异);后续的 surface PR 会把按字段的解析(通过作用域注册表的 tools、审批、运行时 knobs)移入其中并封存输入。完成该解析和封存后:
- 设置只在一个地方应用,因此没有任何分歧可言;
- 一个带有私有字段的 newtype(例如一个只有 resolver 才能创建的 scoped tool registry)会让把未解析的 policy 交给 engine 变成编译错误。
最终状态是,这种分叉是无法编译的,而不只是经过测试。当前/未来的边界是:ResolvedAgentExecution、它的 resolve() 构造函数,以及 ResolvedIo / ResolvedRuntimeKnobs 输入层都已存在于 master 上,并且所有生产路径都通过它们构造;TOOL 表面现在也有了它的受限构造函数(下文的 ScopedToolRegistry::assemble,由 gateway 作为其第一个消费者);将其余表面的逐字段解析吸收到 resolve() 中,并将该 bundle 的字段对外封装起来,是后续这些 surface PR 要做的工作。
工具装配接缝(Epic A,第一个表面)
每个代理的工具注册表是第一个带有单一受限构造函数的入口:ScopedToolRegistry::assemble(crates/zeroclaw-runtime/src/tools/scoped.rs)。历史上,该注册表一直是在六个构造点手工组装的——这就是为什么内置过滤器和 MCP 作用域必须按站点打补丁(#7064、#6960、#8120)。assemble 按以下顺序应用:代理的 config.peripherals(在已连接时——见下方的开关)、内置的 allowed_tools/ excluded_tools 过滤器、ACP 内存剥离、按 mcp_bundles 进行的 MCP 服务器作用域及按工具门控(提前或延迟;省略不等于授予)以及 MCP 能力工具和 pinned-resources 提示部分,最后是在相同的 SecurityPolicy 下进行技能注册(没有技能的站点传入空切片——网关会这样做,直到 Epic F 加载器统一为止)。
按站点的差异以数据体现,绝不表现为跳过安全步骤。ScopedAssembly 的各个开关只能收窄或保留——没有任何一个能扩大策略所授予的权限:
caller_allowed- 每次运行的允许列表(run()路径);与策略过滤器和 MCP 工具访问策略相交,但绝不覆盖它们。connect_mcp- ACP 快速启动路径上的false:MCP 服务器既不会被解析也不会被连接,因此不会授予任何权限。connect_peripherals-false在仅列出内容的界面上:加载外设会实际连接硬件(独占串口占用),而一个不应运行任何回环的注册表绝不能这样做。exclude_memory- ACP 内存工具条带。
切换完成状态(strangle,每个 PR 一个站点):今天 gateway (#8640)、loop_::run (#8700) 和 process_message (#8701) 都通过 assemble 构建。gateway 的切换完成——它的两个 registry builders、dashboard-agent seed 以及按 agent 划分的 /api/tools 列表——通过构建关闭了 gateway 的过滤差距:它的列表之前会显示 agent 的 policy 拒绝的未过滤 built-ins(实际的 gateway 聊天通过 process_message 解析,而后者已经过滤),此外即使 policy 拒绝了所有延迟加载的 MCP tool,也会显示一个 tool_search stub。一个范围说明让这个列表说法保持准确:peripherals 按设计被排除在列表之外(connect_peripherals: false —— 在不连接硬件的情况下枚举它们是未来的改进)。process_message 的切换完成关闭了第二个、独立的分歧:它之前通过 filter_channel_builtin_tools 过滤 built-ins,这个变体在非 Full autonomy 下会让 canonical read-only defaults 绕过 allowed_tools,而其他所有路径都应用普通的 apply_policy_tool_filter。#8701 废弃了该变体,因此现在所有路径都应用同样的普通过滤器(ledger A4,由一个文件内的正向一致性测试支持,而不是分歧描述)。
其余手工编写的站点——channels 编排器(start_channels)、Agent::from_config,以及委托的独立目标构建器(independent_agentic_tools_for_target,在该程序运行期间由 #8239 添加——该封印旨在终结的重复出现)——会在后续 PR 中迁移。等所有站点都通过 assemble 生成后,engine 的 tools 字段将封装为 ScopedToolRegistry(一个仅由 assemble 构造的私有字段新类型),而向 engine 传入未作用域限定的 registry——或者像某次 cross-merge 已经对 channels 路径做过的那样,悄悄重新内联一个构建站点——就会变成编译错误,而不是靠 review 发现。在那道封印出现之前,尚未迁移的站点之间的跨站点一致性仍依赖约定;而这个 seam 今天所保证的是,所有经由它路由的路径共享同一个实现。
该测试框架
对等性测试框架位于 crates/zeroclaw-runtime/src/agent/parity.rs,是 #7415 safety_net.rs 回合引擎 oracle 的一个 #[cfg(test)] 同级文件,并复用了它的 fixtures。它包含一个对等性行的 INDEX——每一行都命名其所属的 epic、一个公开的跟踪引用,以及支撑它的测试(或已跟踪的差异记录)——再加上两层测试。该索引刻意不编码任何按路径划分的判定网格:一张静态的手写单元格网格本身也会成为数据,当另一条 PR 更改了某条路径时它会过期,而不会有任何测试察觉——这正是这个程序存在并要终结的失败。因此,可执行的断言只存在于测试中;一个元测试只负责强制索引的簿记(owner、tracking 和 evidence 已存在),仅此而已。人类可读的(setting x path)网格位于本页。两层测试:
- L1 引擎锁定:当某个设置到达
run_tool_call_loop时,引擎会遵循它(例如,excluded_tools条目永远不会执行,即使模型调用了它)。 - L2 路径一致性是指断言某个设置在每条构建路径上解析结果相同。对于已经通过某个 seam 解析的 surface,其 L2 测试就是一个正向一致性断言。对于尚未存在单一 seam 的已确认分歧,它会以一个始终运行的 characterization test 形式交付,用来固定当前存在的分歧(断言这两条路径目前不同)——因此,当负责的 epic 统一了语义时,该断言会在同一个 PR 中失败,并且必须改写为正向一致性断言。分歧只能以显式方式变化,绝不能悄然变化。不存在
#[ignore]的 specs:一个已知失败且被忽略的测试不会在 CI 中运行,也起不到任何保护作用,因此目标是作为对当前状态的实时断言来推进。
它一次只增长一个 surface,并且只断言没有其他测试覆盖的内容:
- 一个表面(工具、审批、运行时预算、上下文和历史、内存、技能)被一次一个 PR 地扭成
resolve。 - 那个 PR 增加了该 surface 的 parity 测试:给定一个 agent 配置,每条构造路径都会向引擎传递该设置相同的解析后值。
- 原语自身的单元测试,或由
safety_net引擎 oracle 已覆盖的行为,不会重复说明。该 harness 只增加跨路径一致性断言,这一属性没有任何按原语划分的测试会提出。
在一个曲面只有单一分辨率接缝之前,没有任何可用于断言 parity 的对象,因此其行会保留在 divergence 记录中,作为一个始终运行的 divergence 特征描述,而不是一个过早通过的 green test - 绝不会作为一个 #[ignore]d spec,与上面的 no-ignored-specs 规则一致。
添加一个 surface(每个未来的 surface PR 都遵循的工作流)
有了 resolve()(见上文),每个表层 PR 按以下步骤进行:
- 将 surface 的分辨率和连线从构造位置移动到
ResolvedAgentExecution::resolve;删除各处站点的副本。 - 添加一个 parity 测试:构建一个独特的 agent 配置,驱动每条构造路径,并断言引擎收到相同的解析值。
- 将 surface 的 row 翻转为强制在每条路径上开启。
- 保持在其他地方将 strangle 行为保持中性:
safety_netoracle 和 primitives 的自身单元测试保持通过。