教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
多 Agent 协作
在单 Agent 边界稳定后,增加角色分工、任务交接和结果归并。
扩展篇 · 预计 70 分钟 · 前置:主线 04 工具系统、09 权限与安全、14 Agent Loop 已经通过 Checkpoint。本页目录
主线 ToolRegistry、权限与安全、Agent Loop 与长程任务恢复已经通过各自 Checkpoint。
src/role.tssrc/handoff.tssrc/merge.tssrc/recovery-events.tssrc/agent-loop.tstests/handoff.test.ts
多 Agent 不是把单 Agent 复制几份,而是把已经被 Harness 约束的 Loop 当成一个可调度的单元:父运行保留目标和总预算,子运行只负责一个被封装的子任务,二者之间唯一的合法通道是持久化事件,不是进程内的 Promise,也不是模型在对话里互相转述。
Step 1定义角色与交接契约
角色不是人设,而是权限的子集:系统 Prompt 片段决定它如何工作,工具白名单与 effect 上限决定它能做什么,预算上限决定它能花多少,三者必须由 Harness 一起强制。
export type AgentRoleId = "researcher" | "implementer" | "verifier";export interface AgentRole { id: AgentRoleId; promptFragment: string; allowedTools: string[]; maxEffect: Exclude<ToolEffect, "external">; budgetCeiling: Pick<AgentBudget, "maxSteps" | "maxToolCalls">;}const rank: Record<ToolEffect, number> = { read: 0, write: 1, execute: 2, external: 3 };export function restrictRegistry(registry: ToolRegistry, role: AgentRole): ToolRegistry { const names = registry.definitions() .filter((tool) => role.allowedTools.includes(tool.name) && rank[tool.effect] <= rank[role.maxEffect]) .map((tool) => tool.name); return registry.subset(names);}restrictRegistry 只会收窄:子 Agent 拿不到父 Agent 没有的工具,也拿不到超过 maxEffect 的工具,而 external 不在 maxEffect 的可选值里,所以角色无法自行获得网络或外部副作用能力。角色只是把主线第 09 章的 authorize 输入变小,绝不会替换或跳过它。
交接本身是一份持久化记录,而不是一次函数调用:
export type AgentHandoffStatus = "recorded" | "running" | "completed" | "failed" | "cancelled" | "manual_reconciliation";export interface AgentHandoff { handoffId: string; parentRunId: string; childRunId: string; parentStepId: string; role: AgentRoleId; task: string; allowedTools: string[]; budget: AgentBudget; idempotencyKey: string; status: AgentHandoffStatus; recordedAt: string;}为什么必须由 Harness 记录,而不是让模型把任务“转述”给另一个 Agent:模型看不到 runId、sequence、lease 和真实预算,它无法保证消息落盘、无法幂等重试、无法在崩溃后恢复,也无法阻止自己顺手把不该给的工具一起转述过去。模型只提出候选交接,Harness 校验后追加 handoff_recorded,这条事件才是子运行存在的唯一依据。
export type DurableEvent = // 第 16 章已有成员 | { type: "handoff_recorded"; handoff: AgentHandoff } | { type: "handoff_result"; handoffId: string; status: AgentHandoffStatus; budgetUsed: AgentBudget; evidence: string[] } | { type: "handoff_conflict"; handoffId: string; conflicting: string[] };export interface StopReasonMap { manual_reconciliation: true; handoff_blocked: true; }StopReasonMap 用 interface merging 扩展,新停止原因和其它路径一样进入 AgentResult.stopReason、AgentEvent 序列与 Trace,而不是抛一个无人处理的异常。
Step 2持久化交接任务
先追加事件,再启动子运行。idempotencyKey 由父运行、父 Step 与角色确定性派生,因此崩溃后重放同一逻辑交接会命中同一条记录,而不会创建第二个子运行。
export async function recordHandoff(options: { parent: DurableAgentState; request: HandoffRequest; store: RunStore; lease: RunLease; clock: Clock;}): Promise<AgentHandoff> { const idempotencyKey = `${options.parent.runId}:${options.request.stepId}:${options.request.role.id}`; const existing = findHandoffByKey(options.parent.events, idempotencyKey); if (existing) return existing; const handoff: AgentHandoff = { handoffId: crypto.randomUUID(), parentRunId: options.parent.runId, childRunId: childRunIdFor(idempotencyKey), parentStepId: options.request.stepId, role: options.request.role.id, task: options.request.task, idempotencyKey, allowedTools: options.request.role.allowedTools, status: "recorded", budget: sliceBudget(options.parent.budget, options.request.role, options.request.budgetRequest), recordedAt: options.clock.now().toISOString(), }; await appendHandoffEvent({ state: options.parent, lease: options.lease, store: options.store, handoff }); return handoff;}预算切片在记录交接时就预留,而不是等子 Agent 用多少再扣多少:父运行的计数器先加上整个切片额度,事件 reducer 用同一个纯函数重放,这样三个子 Agent 即使同时跑满也无法突破父预算。
export function reserveBudget(parent: AgentBudget, slice: AgentBudget): AgentBudget { const next: AgentBudget = { ...parent, modelSteps: parent.modelSteps + slice.maxSteps, toolCalls: parent.toolCalls + slice.maxToolCalls }; if (next.modelSteps > next.maxSteps || next.toolCalls > next.maxToolCalls) throw new Error("budget_exhausted"); return next;}子 Agent 的 AgentBudget 是切片额度、计数从零开始的独立预算。子 Agent 失败或取消时,handoff_result 事件仍带着 budgetUsed 落盘,父运行只把未使用部分通过 settleBudget 退还,预算证据不会被吞掉,也不会因为“跑挂了一个”就凭空多出额度。
进程内的 await runSubagent(...) 崩溃后什么都不剩:既不知道子任务是否开始,也不知道它花掉了多少预算。先写 handoff_recorded,再由恢复器按 idempotencyKey 找到它并决定启动、续跑还是对账,才能让多 Agent 复用第 16 章的恢复能力。
Step 3运行子 Agent 并继承预算
接入点在 Agent Loop 的 PLAN 与 ASSEMBLE 之间:给 PlanStep 用 interface merging 增加可选的 handoff 字段,默认 undefined 时循环行为与主线完全一致;只有当前 Step 声明交接时,Harness 才走这个分支,DECIDE/ACT/OBSERVE 三步原样复用。
export interface PlanStep { handoff?: { role: AgentRoleId; task: string; budgetRequest: AgentBudget } | undefined;}// runAgentLoop 的循环体内,selectNextStep 之后:if (activeStep?.handoff) { const handoff = await recordHandoff({ parent: state, request: { stepId: activeStep.id, ...activeStep.handoff }, store, lease, clock }); const outcome = await runHandoff({ handoff, role, parentState: state, parentRuntime, store, clock }); state = applyHandoffOutcome(state, outcome); // 写入 observation 与父 TaskPlan 证据 previousResponseId = undefined; pendingOutputs = []; continue;}子运行必须拿到自己的 RunLease,绝不能复用父运行的 lease;它继承同一个 cwd 与真实工作区根目录、同一个 WriteLease 与 TraceSink,并通过 restrictRegistry 只看到角色允许的工具。
export async function runHandoff(options: HandoffRunOptions): Promise<HandoffOutcome> { const { store, handoff, role, parentState, parentRuntime } = options; const childLease = await store.acquireLease(handoff.childRunId, crypto.randomUUID(), 30_000); const childState = createInitialState(handoff.task, parentRuntime.cwd); childState.runId = handoff.childRunId; childState.budget = { ...handoff.budget, modelSteps: 0, toolCalls: 0 }; const childRuntime: AgentRuntime = { ...parentRuntime, tools: restrictRegistry(parentRuntime.tools, role), store, trace: parentRuntime.trace, }; const result: AgentResult = await runAgentLoopFromState(childState, childRuntime); await appendHandoffResult({ state: parentState, lease: options.parentLease, store, handoff, result }); await store.releaseLease(childLease); return toOutcome(handoff, result);}这里没有任何“因为是子 Agent 所以可以放宽”的例外:子运行的每个工具调用仍然经过 prepare 与 authorize,仍然受工作区边界约束,仍然使用共享 WriteLease 串行化写入,角色只会让权限更小。子 Agent 也不能自己批准审批,ask 决策会像主线一样把子运行置为 waiting,由操作者决定。父进程崩溃后,恢复器读取 handoff_recorded 与缺失的 handoff_result,按 idempotencyKey 对账:未开始就启动,已开始就续跑,副作用不明就转人工核对。
Step 4确定性归并与冲突处理
归并是从子结果回到父 TaskPlan 证据链的唯一入口,必须与完成顺序、并发度、机器时钟无关。排序键固定为 parentStepId、role、childRunId,绝不使用 completedAt:
export interface HandoffOutcome { handoffId: string; childRunId: string; parentStepId: string; role: AgentRoleId; status: AgentHandoffStatus; budgetUsed: AgentBudget; evidence: string[]; criteria: Record<string, "passed" | "failed" | "unknown">;}export function mergeHandoffOutcomes(plan: TaskPlan, outcomes: HandoffOutcome[]) { let merged = plan; const conflicts: HandoffConflict[] = []; const ordered = [...outcomes].toSorted(compareOutcome); for (const outcome of ordered) { merged = applyCriterionEvidence(merged, outcome.criteria, outcome.evidence); merged = appendEvidence(merged, outcome.parentStepId, outcome); } for (const conflict of detectCriterionConflicts(ordered)) { conflicts.push(conflict); merged = blockStep(merged, conflict.stepId, "manual_reconciliation"); } return { plan: merged, conflicts };}冲突判定只看证据是否互相排斥:两个结果对同一 AcceptanceCriterion 给出相反的 passed 与 failed,或对同一路径给出不同的文件 hash。此时保留双方证据、把对应 Step 置为 blocked、追加 handoff_conflict,并以 manual_reconciliation 停止,交由人判断。
只有通过校验的归并结果才调用主线的 completeStep 推进计划。子 Agent 自己的 TaskPlan、AgentResult 与事件序列始终留在子运行里,父运行只接收标记了来源(childRunId 与 eventId)的证据;父 budget 用 settleBudget 退还差额,TraceSink.readRun(childRunId) 仍可按需展开完整子轨迹。
Step 5测试交接与归并
创建 tests/handoff.test.ts。Fake Model 驱动父运行、固定返回的 Fake Agent 驱动子运行,断言事件顺序、预算不变量与归并确定性,不断言模型内部思考。
describe("handoff", () => { it("records handoff_recorded before the child run starts", async () => { const fixture = createHandoffFixture(); await fixture.runParent(); expect(fixture.indexOf("handoff_recorded")).toBeLessThan(fixture.indexOf("run_started")); }); it("never lets parallel children exceed the parent budget", async () => { const result = await runParallelHandoffs(parentState, [request(4), request(4), request(4)]); expect(result.state.budget.modelSteps).toBeLessThanOrEqual(parentState.budget.maxSteps); expect(result.stopReason).toBe("max_steps"); }); it("keeps budget evidence when a child fails", async () => { const outcome = await runFailingChild(); expect(outcome.budgetUsed.modelSteps).toBeGreaterThan(0); expect(findEvent(parentRunId, "handoff_result")).toMatchObject({ status: "failed" }); }); it("merges outcomes independently of completion order", () => { const forward = mergeHandoffOutcomes(plan, outcomes); const backward = mergeHandoffOutcomes(plan, [...outcomes].reverse()); expect(backward.plan).toEqual(forward.plan); expect(backward.conflicts).toEqual(forward.conflicts); }); it("keeps both sides of a conflict and asks for manual reconciliation", () => { const merged = mergeHandoffOutcomes(plan, [ outcome("verifier-a", { "tests-pass": "passed" }), outcome("verifier-b", { "tests-pass": "failed" }), ]); expect(merged.conflicts).toHaveLength(1); expect(merged.plan.steps[0]?.status).toBe("blocked"); }); it("denies a tool outside the role", async () => { const child = await runChildWithRole("researcher", "apply_patch"); expect(child.trace.readRun(child.childRunId)).toContainEqual(expect.objectContaining({ errorCode: "policy_denied" })); }); it("reconciles a handoff recorded before the child started", async () => { const fixture = createHandoffFixture("after_handoff_recorded"); await expect(fixture.startParent()).rejects.toThrow("simulated_crash"); const result = await resumeAgentRun(fixture.parentRunId, fixture.restartedRuntime()); expect(result.stopReason).toBe("final_answer"); expect(fixture.childStartCount()).toBe(1); expect(fixture.eventSequences()).toEqual(fixture.contiguousSequences()); });});最后一条就是故障注入:让父运行在交接已落盘、子运行尚未启动的窗口崩溃。恢复后 Harness 必须按 idempotencyKey 复用同一条 handoff,只启动一次子运行、只写一次 handoff_result。如果子运行自己崩溃在 after_tool_effect,则复用第 16 章的 verifier 对账,保证副作用总计只发生一次。