主题
Subagent Task Contract
本课交付结果
你将交付 SubagentRunner.run(task, parent):显式挑选 contextKeys,验证子权限是父权限子集,子预算必须大于 0 且严格小于父预算,并在执行前传播已取消状态。
岗位问题
子 Agent 不是“复制整个父会话再跑一次”。全量复制会泄漏秘密、放大 token 成本,并让子角色拥有父级全部权限。委派必须通过窄合同表达它能看到什么、能做什么、最多花多少。
前置检查
前置知识快照
先掌握第 11 课权限模型和第 10 课预算:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 11
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 10本 Lab 注入 execute,不创建真实远程 Agent。
第 10 课让单个 Agent 受 token、步骤、工具和时间预算约束,第 11 课把能力变成明确权限;Subagent 必须继承这两条安全单调性:子权限不能超父权限,子预算不能等于或超过父剩余预算。委派不是复制会话,而是父任务签发一个更窄、可撤销、可计量的执行合同。
父 context 可能同时含用户需求、仓库规则、已读代码、秘密、其他任务消息和内部推理。task.contextKeys 是 allowlist,只把当前子任务必需的字段复制到新对象;未列出的 secret 即使父任务能看,也必须在 child 序列化输入中完全不存在。所谓“提示子 Agent 不要看 secret”不构成隔离。
教学 Lab 用一个 budget 数字突出严格正子集,总项目使用 input/output token、toolCalls、duration 等多维预算,并额外限制 allowedPaths、allowedTools、role 和 contextSnippets。原则相同:每个维度都不超过父剩余量,委派深度增加时资源单调收敛。
原理拆解
mermaid
flowchart TD
A[父任务与委派请求] --> B{父 signal 已取消?}
B -->|是| C[零执行返回 cancelled]
B -->|否| D[校验 prompt 与任务身份]
D --> E[权限/路径/工具为父级子集]
E --> F[每项预算严格缩小]
F --> G[按 contextKeys 复制新对象]
G --> H[构造 IsolatedSubagentInput]
H --> I[执行并传播取消/超时]
I --> J[验证 usage 与结果 schema]先检查父 signal;再验证 prompt、权限子集和严格预算;最后只复制 task.contextKeys 指向的字段。不存在的 key 明确报错,不能用 undefined 悄悄继续。通过验证后才调用 execute。
代码实验
bash
pnpm --dir bootcamps/coding-agent/labs/34-subagent-contract/starter test
pnpm --dir bootcamps/coding-agent/labs/34-subagent-contract/solution testStarter 应声明 实现 Subagent 上下文隔离;Solution 应通过 4 项测试。
失败实现
ts
async function delegateUnsafe(task: SubagentTask, parent: ParentContext) {
return execute({ ...parent, ...task, permissions: [
...parent.permissions, ...task.permissions,
] });
}对象展开把完整 parent context、secret 与内部字段都交给 child;权限取并集让子任务可以扩权;预算同名字段可能被任意覆盖;父取消也没有阻止 execute。一次“方便复制”同时破坏数据最小化、最小权限和结构化并发。
基础安全实现先验证再构造全新对象:
ts
const parentPermissions = new Set(parent.permissions);
for (const permission of task.permissions) {
if (!parentPermissions.has(permission)) throw new Error(`权限越界:${permission}`);
}
if (task.budget <= 0 || task.budget >= parent.budget) {
throw new Error("预算必须为父预算的严格正子集");
}
const context: Record<string, string> = {};
for (const key of task.contextKeys) {
if (!(key in parent.context)) throw new Error(`上下文不存在:${key}`);
context[key] = parent.context[key]!;
}
return execute({ prompt: task.prompt, context, permissions: [...task.permissions],
budget: task.budget, signal: parent.signal });关键实现讲解
父 context 同时含 spec 和 secret,任务只选 spec,execute 收到的对象必须完全没有 secret。父有 read/write 不代表子必须继承二者;请求 network 会在执行前拒绝。父预算 100 时子预算必须位于 (0, 100)。
验证顺序从零副作用条件开始。父 signal 已 aborted 时立即返回 cancelled 或抛稳定错误,execute 调用次数必须为零;随后校验 task id、非空 prompt、role、contextKeys、权限和预算。任何错误都在创建远程会话、Provider 请求或工具资源之前发生,不能执行后再把结果标 blocked。
prompt 描述一个可验收的窄任务,例如“阅读这两个文件并列出三个风险”,而不是“继续完成父任务”。它应包含输出 schema、禁止修改范围和完成条件,不依赖父会话隐含历史。prompt 有字节预算并经过数据分类,避免把用户秘密通过自由文本绕过 contextKeys allowlist。
contextKeys 只选择预先命名的安全片段。父 context 使用 Record 便于 Lab 展示,生产中每项包含 kind、source、bytes、sensitivity 与 hash;task 只能引用允许委派的 key。不存在 key 明确失败,不能写入 undefined 后让子模型猜测。重复 key 去重并保持稳定顺序,避免重复 token 成本。
复制值而不是传父对象引用。字符串天然不可变,嵌套对象需结构化克隆、深冻结或重新序列化;否则 child executor 在同进程内可修改父 context。更强边界是只传 JSON 序列化后的新对象到 worker/远程 API,类型 schema 拒绝函数、句柄与循环引用。
最小上下文同时改善安全和能力。全量历史包含无关分支、旧假设和其他角色输出,容易让子 Agent偏航;精确 spec、相关文件片段和当前约束让它更专注。Context Builder 对片段排序、去重和预算,Subagent Contract 只接收最终选择,不自行扫描整个仓库。
秘密默认不可委派。若子任务确实要调用有凭证服务,不把 secret 文本放 context,而是授予一个范围更窄、短时、可撤销的 capability handle;工具代理在调用时使用。子 Agent 只知道工具和批准结果,无法读取 token。任务结束或父取消立即撤销。
权限子集对每项精确比较,不使用字符串包含或角色默认。父有 read/write,子只请求 read 合法;请求 network 立即 blocked。父权限本身可能带路径/主机约束,子约束还要是其子集:父 write src/**,子不能通过只写 write 字符串获得 tests/**。
allowedPaths 的子集需要规范路径语义。所有路径相对 workspace、无点点段,按 glob 包含关系或预展开安全集合检查;简单字符串前缀会误判。allowedTools 同理按稳定 Tool name,子不能使用父未授予的 MCP 或 Plugin 工具。execute context 只注册交集后的工具,不能仅在 prompt 中提醒。
预算严格小于父预算防止无收敛递归。若父一百、子也一百,子再委派一百,理论成本无界;严格减少加最大深度保证最终停止。多维预算每项不超过父剩余,可要求至少一个关键维度严格减少;课程采用所有任务单一 budget 严格减少,规则更直观。
预算应从父的剩余量而非初始上限分配。父已用六十/一百,只能在四十内委派;多个兄弟任务预留总和不能超过剩余。Orchestrator 负责 admission control,SubagentRunner 仍验证单任务合同,形成纵深防御。估算与实际 usage 都记录,实际超限返回 budget_exceeded。
duration budget 由 child AbortController 落实。父 signal abort 时 controller abort,计时器到期也 abort;executor 必须把 signal 传到 Provider、工具与子进程。Promise.race 可及时返回,但 loser 仍需响应取消,任务完成后 finally 清 timer 和 listener。父子取消是一条树,不是事后状态字段。
ts
it("does not pass unselected parent data", async () => {
let received: ChildContext | undefined;
const runner = new SubagentRunner({ execute: async (child) => {
received = child; return "done";
}});
await runner.run({ prompt: "review", contextKeys: ["spec"],
permissions: ["read"], budget: 50 }, parent);
expect(received?.context).toEqual({ spec: "公开" });
expect(JSON.stringify(received)).not.toContain("隐藏");
});安全测试要搜索唯一 secret 种子在序列化 child input、Trace 与错误中都不存在,不只断言 context 等于某对象。execute spy 为零证明 permission、budget、missing key 与 pre-abort 都是执行前拒绝。测试中用假秘密,失败消息不打印 received 全体。
角色指令由宿主可信表提供,例如 planner 只分析计划、coder 实现限定路径、tester 运行和补测试、reviewer 检查证据。task 只能选择允许 role,不能自带系统级 roleInstructions 覆盖安全规则。总项目把 ROLE_INSTRUCTIONS 注入 IsolatedSubagentInput,与自由 prompt 分离。
结果也需要窄 contract。返回 status、output 摘要、changedPaths、usage 与 evidence references,不返回完整对话或隐藏 chain-of-thought。changedPaths 规范化并验证在 allowedPaths 内;usage 每维不超过预算;未知字段、超大 output 和秘密先拒绝/脱敏。父任务只消费可验证结果。
子 Agent 声称 completed 不代表工作完成。父或 Orchestrator 根据任务 completion criteria、Diff、测试与 grader 校验;结果 ID 必须与 task id 匹配,防止并发响应串线。失败、blocked、cancelled 与 budget_exceeded 分开,父决定重试、澄清或停止。
重试使用新 attemptId,但保持 taskId 和幂等边界。已产生写副作用后不能自动从头重跑,先检查 changedPaths/Trace 和 workspace 状态;只读分析可安全重试。每次 attempt 独立预算,总计划预算累计真实 usage,不能失败后把消耗清零。
父子 Trace 通过 parentRunId、childRunId、taskId 和 attemptId 关联。父 Trace 记录委派 metadata、上下文 key/hash、权限和预算,不记录秘密值;子 Trace 使用独立 sequence。Viewer 能从父步骤跳到子详情,再聚合总成本。两个 Trace 不混写同一 sequence 文件。
最大委派深度是严格预算之外的第二道停止条件。即使浮点预算每次只减极小值,也可能形成很深链;depth 到上限拒绝,调用路径进入错误。检测 task ancestry 避免 A 委派 B、B 又委派语义同一 A 的循环,task fingerprint 可辅助识别重复分解。
并行兄弟 Subagent 要隔离写路径或使用独立 worktree。上下文隔离不自动解决文件冲突;两个 child 都获 write src/a.ts 会覆盖。第 35 课 Orchestrator 在执行前检查声明 writePaths,执行后再根据实际 changedPaths 报冲突。SubagentRunner 只执行已经准入的单合同。
模型 Provider 选择也属于合同。子任务可用更便宜模型完成分类或检索,复杂 coder 使用强模型;但任务不能自行升级到更贵/更高权限 Provider。model id、温度、seed 与预算记录在 Trace 和评测,便于分析委派是否真降低成本。
远程 Subagent 传输需要 TLS、身份与租户隔离,服务端重新验证合同,不能信客户端已检查。短时 delegation token 绑定 parentRunId、权限、路径、工具、预算与过期;服务端每次工具调用验证。网络断开时状态可能不确定,父任务通过 child run 查询或幂等 token 恢复,不盲目重发。
错误分类包括 INVALID_TASK、CONTEXT_NOT_FOUND、PERMISSION_ESCALATION、PATH_ESCALATION、TOOL_ESCALATION、INVALID_BUDGET、CANCELLED、TIMEOUT、USAGE_EXCEEDED、INVALID_RESULT。客户端依赖 code,message 只含安全 task id 和字段。任何验证错误都不创建 execute side effect。
可观测指标按 role 聚合委派数、完成率、上下文 bytes、预算估算/实际、取消、超限、深度和冲突;不把 prompt、context 或 child id 放高基标签。若子任务 token 总和大于直接单 Agent,说明分解粒度或上下文重复需要调整,而不是继续增加角色。
完整验收父 context 同时含 spec 与唯一 secret,子只选 spec/read/半预算,execute 收到精确对象并返回 done;请求 network、等于父预算、零预算、missing key 与预取消分别拒绝且 execute 零调用。总项目再验证 allowedPaths/tools、多维实际 usage 与 duration timeout。
task identity 应由父 Orchestrator 生成并在同一计划内唯一。子 Agent 不能改写 id、parentRunId 或 role;返回结果逐项对照。重复 id 在启动任何 child 前拒绝,否则结果 Map 覆盖,Trace 关系也会串线。id 是关联键,不包含用户 prompt 或敏感路径。
context 片段应带不可变 hash。父在委派时记录 key、source 与 hash,子收到内容后可复核;若远程传输或缓存返回不同字节,立即停止。恢复任务时只有 task contract 与所有片段 hash 相同才可复用结果,不能因为 id 一样就跳过新的需求。
规则来源也要进入 context,而不是只给代码片段。子获准修改某路径时,Instruction Loader 先计算该路径适用的 AGENTS.md,作为受保护片段传入;子不能自行从父 root 外搜索规则。系统安全策略与 roleInstructions 由宿主另行注入,优先级高于自由 prompt 和 Skill。
任务输出 schema 越具体,父整合越可靠。reviewer 返回 findings 数组含路径、严重度与证据;planner 返回有序 steps 与风险;coder 返回 summary、changedPaths、tests;tester 返回 commands 与结果。纯文本 output 可保留给人读,但机器字段经过 schema 验证后才能驱动下一角色。
child changedPaths 不是可信声明的终点。执行前后对隔离 workspace 做 Git diff 或树快照,实际路径与 result 对比;遗漏、越界或额外修改标 policy failure。只读角色若产生任何 Diff 立即失败。工具层权限阻止大多数越界,事后 diff 提供独立证据。
父任务如何消费失败需要明确。blocked 表示缺权限或上下文,可向用户请求;budget_exceeded 触发重规划;cancelled 不自动重试;failed 按幂等与 attempt 限制决定;invalid_result 是 executor/模型 contract 问题。把所有状态压成空字符串会让 Orchestrator继续使用不完整结论。
部分结果也要有政策。tester 超时前可能跑完一半测试,报告可保存 evidence 供诊断,但 status 仍不是 completed,后续发布门禁不能当全绿。父可以基于已完成步骤重新安排剩余任务,新的 task 明确引用旧 evidence hash,不悄悄拼接文本。
Checkpoint 恢复记录 child task、attempt、status、usage、result hash 与副作用 fingerprint。completed 且 contract identity 相同的只读子任务可以复用;进行中在崩溃后视为未知,先检查工作区与外部副作用。远程 child 可能仍在运行,先按 runId 查询或取消,不盲目启动第二份。
审批 token 不随 parent context 复制。子需要 write/execute 时,父生成绑定 task 和参数范围的 delegation approval,子工具调用消费;合同只列已允许 capability,不包含可用于新动作的通用 token。任务重试参数改变后重新审批,旧 token 不能扩展使用。
多层委派时,每层都重新构造 context 与 capability,不把祖先完整合同透传。孙 Agent 只知道直接父提供的最小信息;审计系统通过 parentRunId 链在后台重建 ancestry。这样模型上下文不会因深度累积所有祖先秘密,安全策略仍能检查全局最大深度。
成本分摊按 child usage 回写父级 ledger,包含模型 token、工具成本、MCP 资源和运行时间。父返回前等待所有已启动 child 结算或取消;不能只累计 completed,失败 attempt 同样花费。实际成本超过预留时立即阻止新的兄弟任务,超出全局硬上限则级联取消。
并发限制不只防成本,还防 Provider 限流与宿主资源耗尽。父计划声明最大 active children,队列中的任务尚未获得执行资源,可在取消时直接移除。公平策略按稳定计划顺序,避免长任务永远饥饿;真正开始时再次校验剩余预算和权限状态。
Subagent 的系统提示要避免角色拟人化造成责任模糊。它知道自己是受限执行角色、没有父级全部上下文、必须按 output schema 返回,不声称最终任务完成。父 Agent 或 Orchestrator 承担整合与用户沟通,子不能直接发布、部署或扩大外部影响。
模型对 prompt injection 的防护仍依赖数据/指令分离。代码、Issue 和网页片段作为明确 data block,不能覆盖 roleInstructions;工具结果标来源与信任等级。子上下文越窄,攻击面越小,但恶意片段仍可能存在,read-only reviewer 与最小工具集合限制后果。
评测委派收益要与单 Agent 基线比较:成功率、总 token、延迟、冲突和错误归因。多角色可能提升复杂任务,却在简单任务增加成本与沟通损耗。路由器只在任务确实可分解、上下文可隔离且验收可独立时使用 Subagent,不把多 Agent 当默认装饰。
委派评测包含秘密不泄露、权限越界、预算等于父、预取消、duration timeout、invalid result、实际 usage 超限和三级收敛。再加入真实 bugfix:planner 只读 spec,coder 获限定 write,tester 只执行测试;最终 Diff 与直接基线比较。通过测试数量不能替代唯一秘密扫描。
运维 runbook 在 child 卡住时先看父 signal、deadline、Provider 请求、最后工具和 ledger,不直接 kill 主 Agent。取消 child、等待资源回收、保存安全 Trace;若工作区可能有部分写入,标记待审查。重复卡住同 role/model 进入熔断,父使用单 Agent fallback 或向用户说明。
团队治理应规定哪些数据可委派到外部 Provider、哪些只能本地模型,合同 metadata 带 dataClassification 与 region。远程 executor 在服务端强制政策,客户端 UI 显示目的、数据类别和权限。上下文 allowlist 是技术基础,合规决定哪些 key 根本不允许出边界。
验收文档给每个角色列输入字段、权限、预算、输出 schema、取消行为和失败处理。没有这张合同表,代码里的几个数组很快会漂移成隐式约定。Subagent 的价值来自可组合窄接口,接口必须比父会话更容易理解和验证。
最终判断是“子任务即使不可信,也只能在合同盒子里失败”。它看不到未选秘密,拿不到父外能力,花不完父全部预算,父取消后不能继续,返回内容也不能未经验证驱动副作用。满足这些约束后,委派才真正增加能力而不是放大风险。
一次代码评审可以沿固定清单检查:输入是否重新构造、敏感字段是否默认拒绝、权限/路径/工具是否逐项求子集、预算是否按父剩余递减、预取消是否零调用、运行中取消是否回收底层资源、结果是否校验身份/路径/usage、Trace 是否只记录安全 metadata。任一项依赖 prompt 中的“请遵守”,都说明边界尚未落到代码与运行时。
课程完成后应能用一个反例解释每条约束:全量 context 会泄密,并集权限会扩权,等额预算会递归发散,遗漏 signal 会留下后台任务,未验证 result 会让不可信 child 驱动父副作用。能从反例推回不变量,才是真正掌握 Subagent Contract。
委派越深,能力、上下文与预算越窄;责任始终回到拥有完整任务的父级。
这条单调收缩原则不可被任何角色绕过。
运行与验证
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 34
pnpm --filter @coding-agent/extensions test预取消测试还要断言 execute 调用次数为 0,证明取消不是事后状态标签。
真实运行输出
text
✓ tests/lab.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)项目 extensions 套件还验证 contextSnippets 不含父私密消息、工具/路径收缩和 duration budget。运行证据应同时展示完成路径与所有执行前拒绝路径的 zero-call spy。
常见失败与排查
故障案例 1
症状:子 Trace 或 Provider 请求出现父会话秘密、无关消息,token 成本与父任务几乎相同。
根因:展开整个 parent,contextKeys 只是提示而非真正 allowlist。
定位:注入唯一秘密种子,扫描序列化 child input、Trace 和 executor spy;统计上下文字节。
修复:只从声明 key 构造全新对象,秘密改用窄 capability;未选字段在传输层完全不存在。
故障案例 2
症状:父只有 read/write,子仍启动 network 或写未允许路径;审批发生在执行之后。
根因:权限取并集、只比较粗粒度字符串,或把约束写进 prompt 而不限制工具注册。
定位:请求父级外 permission/path/tool,并监视 execute 与真实工具调用次数。
修复:执行前精确子集验证,child 只注册交集能力;验证失败稳定 blocked 且零副作用。
故障案例 3
症状:递归委派成本无界,父取消后 child 仍调用工具,duration 超限只改变最终标签。
根因:子预算允许等于父、没有剩余量/深度,AbortSignal 未传播到 executor。
定位:构造三级委派和永不完成 executor,记录每层预算、活动请求与取消后 workspace。
修复:预算严格递减、兄弟总和准入、最大深度;父取消和 timer 级联到所有底层资源并等待回收。
课后作业
加入最大委派深度、结果 schema 和父子 Trace link;证明三级委派的权限与预算单调递减,并限制子 Agent 返回的上下文字节数。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 上下文 | 只复制显式 key | 全量父会话 |
| 权限 | 子集关系 | 合并或默认继承全部 |
| 预算 | 严格正子集 | 等于或超过父预算 |
| 取消 | 执行前阻断 | 事后标记 |
总项目增量
总项目包:@coding-agent/extensions
总项目路径:packages/extensions/src/subagent-runner.ts
总项目验证命令:pnpm --filter @coding-agent/extensions test
Subagent 定义单次受限委派;下一课协调多个角色的顺序、写路径和总成本。
延伸阅读
- Principle of least authority。
- Context isolation 与 delegation token。
- Structured concurrency 的父子取消。
方案对比与工程取舍
复制完整会话实现最省工程,子模型也“什么都知道”,但泄密、成本与干扰最大;手工摘要便宜却容易遗漏来源;结构化 contextKeys 加 Builder 证据略复杂,却能验证每个片段为何进入。高风险 Agent 应选择可追溯 allowlist,而不是依赖模型自律。
同进程 executor 测试简单、延迟低,却只能靠对象复制约束内存访问;worker/远程进程形成更强序列化和资源边界,但增加协议、取消与恢复。合同接口保持纯数据和 AbortSignal,底层可以从 mock 平滑替换成隔离执行器。
严格预算可能让一些子任务过早停止,但它保证委派可收敛。上层可重新规划、拆小任务或请求用户增加总预算,不能让 child 自行借用未来资源。治理优先于一次看似顺利完成,因为无界多 Agent 成本会在规模上迅速失控。
下一课衔接
SubagentRunner 保证单次委派不越过父边界,却不决定多个任务如何排序、是否写同一文件或总预算能否承担。下一课 Orchestrator 会在任何执行前检查重复 ID、成本与写路径冲突,按稳定角色顺序运行,并以实际 usage 聚合整个计划。