第四部分 · 多代理与长任务
第 11 章:子代理——作为能力的递归
一个模型工具、七个传输后端:子代理不是内置特性,是插件
子代理也是 seam
在多数 agent 产品里,子代理是核心内置:一个固定的「派生子会话」函数,一个固定的等待/取消协议。dsh 把子代理做成了第 7 章定义的 capability 三件套——SubagentRuntime(Service Definition)+ 命名 provider 注册表(传输后端)+ 三个 Consumer 工具包。于是「子代理」从一个特性变成了一个可插拔的传输接缝:
| Provider | 传输方式 | 能力 |
|---|---|---|
subagent-spawn-in-process | 进程内全新子 agent(ctx.agents.create) | 全能力:outputSchema / depthLimit / toolFilter / persona 全 true |
subagent-fork-in-process | 进程内 fork:子代理继承父的已完结 turn 前缀 | 同上 |
subagent-acp | Agent Client Protocol 独立进程 | 全 false(进程外无法替子代执行深度/过滤/结构化) |
subagent-codex / subagent-claude-code | codex app-server --stdio / Claude Code | 全 false |
subagent-dsh-sdk | JSON-RPC 连另一个 dsh | 全 false |
provider 声明四个启动期能力标志(outputSchema/depthLimit/toolFilter/persona)与 inheritsParentContext(是否继承父对话 seed,仅描述性——工具用它生成诚实的措辞)。start() 前的 assertCapabilities 是硬校验:请求要的能力 provider 不支持,抛 UNSUPPORTED_CAPABILITY 拒绝——fail loud,绝不接受后忽略。continuable 能力用「方法存在即能力」(prepareContinuable? + TS narrowing)做发现机制。
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
const provider = this.expectProvider(name)
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
const descriptor = snapshotSubagentDescriptor({
mode: 'one-shot',
provider: name,
...request.label !== undefined ? { label: request.label } : {},
})
const resolved: ResolvedSubagentStartRequest = { ...request, descriptor }
return observeRun(this.emitLifecycle, name, request.parent, await provider.start(resolved))
}
工具面:委托、控制、报告
三个 Consumer 工具包挂出四个工具:
| 工具 | 包 | 作用 |
|---|---|---|
subagent | tool-subagent | 委托:参数 {description, prompt, run_in_background?};前台 / 后台 one-shot / 后台 continuable 三路 |
send_message | tool-subagent-control | 给 continuable 子代理发消息(成为其下一轮输入) |
interrupt_agent | tool-subagent-control | 中断(fire-and-return,只停当前轮,队列保留) |
list_agents | tool-subagent-control | 列出可续接的子代理 |
subagent 工具的三路分支(tool-subagent/src/index.ts):前台 ctx.subagents.start(...) → settleForegroundRun(await run.result,永不 reject——失败编码为 SubagentStopReason 并映射 isError,部分输出仍回流);后台 one-shot 包成 ctx.jobs.start 返回 jobId;后台 continuable 调 startContinuable() 立即返回 durable childId。
const runSpec = resolveDelegationRun(args, { backgroundEnabled, continuable })
if (runSpec.runInBackground) {
if (continuable) {
const started = await ctx.subagents.startContinuable({
provider: config.provider, label: args.description, request, signal: exec.signal,
})
return { kind: 'continuable' as const, subagentId: started.childId }
}
const jobs = ctx.get('jobs')
// ...
const id = jobs.start({
kind: 'subagent', label: args.description, owner: parent,
run: () => {
const controller = new AbortController()
const start = ctx.subagents.start(config.provider, { ...request, signal: controller.signal })
return {
cancel: (reason?: string) => { controller.abort(reason ?? 'background subagent task killed') },
done: settleStart(start, controller.signal),
}
},
})
return { kind: 'background' as const, jobId: id }
}
const run: SubagentRun = await ctx.subagents.start(config.provider, { ...request, signal: exec.signal })
return settleForegroundRun(run)
生命周期:从 tool-call 到结果回流
进程内路径(subagent-in-process-driver)展示了「子代理是 core 之上的一层纯编排」——loop 本身完全不知道子代理的存在:
const childId = SessionId(randomUUID())
const seed = options.seed
const activationBoundary = seed?.length ?? 0
// Capture before the first await: a later parent switch belongs to the
// parent's future.
const inherited = captureDelegatedPolicyOverrides(parent)
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)
applyChildComposition(childCtx, parent, {
persona: request.persona,
toolFilter: request.toolFilter,
})
if (request.outputSchema !== undefined) {
structured = attachStructuredRuntime(childCtx, request.outputSchema)
}
attachDescriptorAppend(childCtx, request.descriptor)
}
const handle = await parent.ctx.agents.create({
sessionId: childId,
meta: childSessionMeta(parent, childDepth, activationBoundary),
...seed !== undefined ? { seed } : {},
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
signal: request.signal,
setup,
})
关键步骤:
- 新子 session id(fork 时多一个
seed前缀);childSessionMeta写 durable header:parentSession、origin: 'subagent'、delegationDepth: 父+1(持久化,重启后仍是单调下限)、seedLength。 - 委托策略在创建窗口钉死:
captureDelegatedPolicyOverrides快照父的 sandbox 覆盖并把子代理的 approval 钉成'never'——权限不可在子会话内扩大。 - composition 继承:
applyChildComposition加入父的 preset、注册固定委托上下文、persona 阴影段、tools.restrict(toolFilter)(第 5 章)。 - descriptor 落盘:
attachDescriptorAppend在子代理首轮把subagent/descriptor事件 append 进子日志(log-only,不进 model history)——续接身份权威在数据而非内存。
驱动:child.followup(createUserMessage(prompt)) → child.whenIdle() → readResult 按 activation boundary 切事件后缀,finalAssistantOutput 取最后非空 assistant 消息,toStopReason 映射 TurnEndReason。结果以 ContentBlock[] 形式作为 tool result 返回,进入父会话日志——父模型下一轮可见。
fork 的语境继承是一个值得细看的工程细节(subagent-fork-in-process/src/index.ts):子代理继承的不是「全部父对话」,而是平衡前缀——
function completedTurnPrefix(parent: Agent): SessionEvent[] {
const events = parent.session.events
const lastEnd = events.findLast(e => e.type === 'turn/end')
if (lastEnd === undefined) return []
// seq === array index (the append contract), so slice up to and including it.
return events.slice(0, lastEnd.seq + 1)
}
取到最后一个 turn/end 为止——正在进行的当前轮(工具调用还没配对完)不可回放,绝不能把半截上下文喂给子代理。这与第 4 章的「tool-call 配对平衡」是同一个原则的递归版。
递归深度防线有三层:SessionHeader.delegationDepth(持久化,resume 后是单调下限——重启不会把深度归零)、AgentOptions.subagentDepth(merge-extensible 运行时字段)、工具层 maxDepth(默认 3,超限抛 SubagentDepthError;'provider-managed' 把深度交给进程外后端)。continuable 树还有一层结构约束:父持有 ownedChildren: Set<SessionId>,父不能在子未 dispose 前 settle;listDescendants 按 pre-order 遍历整棵委托树。
continuable:可续接的长连接形态
one-shot 是「函数调用」,continuable 是「可随时唤醒的驻留工作进程」:durable child Session + 至多一个进程内 Activation(resident Agent)。「一 Session 一 Activation」意味着不需要第二套身份系统——子 session id 就是 live child 的标识。followup 按 Activation 状态路由(running → enqueue、waiting → wake、无 → cold resume);Agent inbox 是唯一 FIFO 队列;子代 settle 时管理器以 subagent-settled 通知父;report 工具以子代理自身为凭证把内容作为 subagent-report 消息送达父(quiet 用 Agent.inject()、wakeup 用 Agent.followup())。
async execute(args, exec) {
const parent = exec.agent
if (!parent) {
throw new Error('send_message requires a calling agent (exec.agent was undefined)')
}
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
const messageId = await ctx.subagents.followup(
parent, SessionId(args.subagent_id), message,
{ source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id }, signal: exec.signal },
)
return { messageId }
}
事件与 UI
subagent/start/subagent/end 是 scope-filtered 事件(以委托父为 carrier,携带 runId/provider/id/local 与 stopReason/lastAssistantMessage)——第 3 章的 scope 路由在这里体现:父组合能观察到每个子代理的活动。UI 链路是三层拼图:事件 → projection → RPC catalog。侧栏的「N 个子代理运行中」来自 ui-workspace 的会话列表派生;「@」触发源从 session list 快照筛 running 子代(零 RPC);RPC 面是 host/apiproxy/src/api/subagents.ts 的 list/history/prompt/interrupt 四个方法(browser-safe);listChildren/listDescendants 只读枚举不加载任何 Agent,走 session projection 三档缓存——数据即权威。还有一个镜像挂载细节:工具的注册与 provider 生命周期同步——subagent/provider-added|removed 事件驱动 Consumer 工具包的挂/卸,provider 卸载时它的工具也随之消失。
在《Prime Agent 源码解析》里,rlm(...) 是「作为函数调用的子代理」——模型在 Python 里 await 一个句柄,子代理是解释器命名空间里的值。dsh 的子代理是「作为传输的子代理」——同一个 subagent 工具可以把委托发给进程内、Codex、Claude Code、ACP 或另一个 dsh。前作赌「子代理足够像函数」,dsh 赌「子代理足够可插拔」;两者都把递归深度与结果回流做成了核心语义。
实践应用
- 子代理是传输不是特性:capability 协商保证「做不到就拒绝,绝不假装做到」——把委托做成可插拔传输,产品就能在本地、沙箱、竞品进程间自由迁移子代理执行。
- 结果永不 reject:子代理失败以结构化 stopReason 编码,部分输出仍回流父上下文——「把部分输出报成成功是谎言,吞掉部分输出同样有害」。
- 权威在数据而非内存:身份与续接组合全部落盘为 log-only 事件;枚举不加载 Agent、走 projection——子代理是 core 之上的一层纯编排,loop 不需要认识它。
- 委托策略创建时钉死:sandbox/approval 覆盖在子会话创建窗口快照并锁定,权限不可在子会话内扩大——递归委托的安全底线。
总结
这一章拆完了子代理:provider 注册表、三工具面、one-shot 与 continuable 双形态、事件回流与 UI 拼图。子代理解决「一个 agent 不够」的第一个层次;下一章进入第二个层次——workflow:让模型自己写一段编排脚本,在 worker 线程里扇出多个子代理,用代码替代提示词编排。