夜雨聆风学习资料网

ARTICLE · 1177816

DeepSeek-Harness 源码深读(21):fork 子代理只复制到上一个回合,多一个事件都不要

DeepSeek-Harness 源码深读(21):fork 子代理只复制到上一个回合,多一个事件都不要

摘要:subagent 家族六七个包,provider 之间的差异被压成一条纯数据:有没有 seed。fork 的 seed 截在最后一个 turn/end,进行中的回合不平衡,不能当合法会话重放。常驻子代理的回合排序交还 Agent inbox,队列一行没写;report 与 settled 两种消息来源分家,transcript 不替子代理代言。委派深度单调递增,委派出去的审批策略钉死 'never'。

教程篇《子代理后端只要两个方法,其余全归框架》从接缝侧看过这个家族:ctx.subagents 是命名 provider 注册表,spawn、fork、acp 各占一个名字,模型侧只有一个 subagent 工具。这一篇钻进实现:两个 provider 到底薄到什么程度、常驻子代理的生命周期归谁管、子代理的结果怎么回到父会话。

这块源码包多文件多,我们还是拿一条具体委派从头走。主线案例:主代理 A 正在修一个仓库的构建,它把 lint 修不好这件事派出去:

{  「tool」: 「subagent」,  「description」: 「fix lint errors」,  「prompt」: 「Run npm run lint, fix every reported error, then re-run to verify」,  「run_in_background」: true}

(这段是模型发起的委派调用,示意。)continuable 模式下后台是默认值,模型不写 run_in_background 也一样(resolveDelegationRun 的 request.run_in_background ?? options.continuable,tool-subagent/src/index.ts:272)。A 立刻拿回一个 subagentId,继续手头的活。B 在后台跑,后面五节我们反复回到这条委派上。

这条委派要走的全部路径,一张图先铺开:

spawn/fork 的分岔与 continuable 子代理的一生

注意看图上两处橙色:剪口标在父日志最后一个 turn/end 上,fork 只复制到这条线为止,之后进行中的回合不带走;右下橙框把 report 与 settled 分成两种来源,B 自己写的话和 manager 替它说的话不走同一条通道。下面六节按这张图的顺序拆。

一、模型只看见一个工具,后端有三种

先看注册表这侧的入口。start 的验证序(subagent/src/index.ts:430-442):

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))}

这段是服务侧的验证序。注意看顺序:查 provider、对能力、验深度、验 schema、快照描述符,全部通过才碰 provider.start。请求了 provider 不支持的能力,在子代理存在之前就会被拒,错误码 UNSUPPORTED_CAPABILITY。类型注释给这条规则起的名字是 fail loud, no silent degradation(types.ts:76-84):宁可报错,不悄悄降级。

能力校验本体是个查表循环(index.ts:497-512),四项能力 outputSchema、depthLimit、toolFilter、persona 与请求字段一一对应,第一项不合就抛。provider 契约长这样(types.ts:292-331,摘核心):

export interface SubagentProvider {  readonly name: string  readonly capabilities: SubagentCapabilities  readonly inheritsParentContext: boolean  start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>  prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>}

这段是 provider 的全部契约。注意看 prepareContinuable 带问号:方法存在即能力。types.ts:315-329 的注释把话说满了,这是 provider 对可续式子代理的唯一参与,它只交出创建数据,此后的 Agent、handle、turn、teardown 一概看不见。ContinuableCreateSpec(types.ts:185-192)整个接口只有一个可选字段 seed,provider 之间的全部差异压在这一点上:fork 给 seed,spawn 给空。要是改用布尔标志位声明能力,新增能力得同时改标志和实现两处;方法声明本身当能力用,能力的定义和实现绑在同一个声明上,不存在不同步。

工具挂载跟随 provider 生命周期(tool-subagent/src/index.ts:449-456):监听 subagent/provider-added 与 provider-removed,provider 出现挂工具、消失卸工具,兄弟插件的加载顺序和热替换都追得上。挂载时刻还有两道校验:配了数值 maxDepth 但 provider 没有 depthLimit 能力,挂载直接失败(:294-299);continuable 模式但 provider 没有 prepareContinuable,同样失败(:301-305)。错配死在最早知晓点,不留给第一次委派去撞。

spawn 和 fork 的差异,到底差在哪几行?

二、fork 的 seed 截在最后一个 turn/end

两个 provider 都薄得不像话。spawn 的全部执行逻辑(spawn-in-process/src/index.ts:48-59):

start(request: ResolvedSubagentStartRequest) {  return startInProcessRun(request, {})}prepareContinuable(): Promise<ContinuableCreateSpec> {  return Promise.resolve({})}

这段是 spawn 的两个方法。全新子代理:自己的会话、自己的系统提示、零父上下文,两头都交空对象。

fork 只多一件事,算 seed(fork-in-process/src/index.ts:48-54):

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)}

这段是 fork 的核心,五行。注意看 slice 的上界:到最后一个 turn/end 为止,含它自己。模块头注释(:5-6)给的道理:进行中的回合没有 end 事件,工具调用还没配上结果,这段日志不平衡;不平衡的前缀不能当合法子会话重放。会话层的 seed 契约要求从 seq 0 连续、事件配对平衡,塞进去就是非法会话。所以 fork 宁可少复制:一个回合都没完成时,start 里 seed.length > 0 才传(:68-75),fork 退化成 spawn。

seq 就是数组下标,这是会话包的 append 契约,slice 出来的前缀天然从 0 连续,不需要任何重排。

两个 provider 的 capabilities 完全相同,四个能力全 true,代码里真正不同的只有 inheritsParentContext 一个布尔加五行 seed 计算。这个布尔不是能力声明,是事实描述(types.ts:297-302 注释),它驱动工具文案(tool-subagent/src/index.ts:220-245,摘分岔):

if (inheritsConversation) {  return {    // …    promptDescription:      'The task for the subagent. It already sees this conversation\'s completed turns, so build on them '      + 'freely and state only what is new.',  }}return {  // …  promptDescription:    'The complete, self-contained task for the subagent. It does not share this '    + 'conversation\'s context, so include everything it needs.',}

这段是同一个工具的两种自我介绍。注意看 promptDescription 的分岔:fork 告诉模型「只说新增的部分」,spawn 告诉模型「什么都得带上」。文案从 provider 的事实字段生成,提示词永远不会对 fork 说出「子代理看不到你们的对话」这种假话。

fork 还有一条显式禁令:fork 与 continuable 的组合在已发布的组装里被禁(fork index.ts:77-82 的 TODO 引 issue #2124)。原因写在注释里:可续式子代理的 report 工具和提示段排在继承历史之前,而 fork 的意义是复用父历史,要求子代理的系统提示和工具 schema 跟父逐字节相同,两件事顶在一起。0.1.1-rc.1 的取舍是禁掉组合,fork 只做 one-shot。

子代理建出来那一刻,拿到手的是什么?

三、出生包裹:深度、血统、钉死的审批

两个 provider 都把活转包给共享的 startInProcessRun(subagent-in-process-driver/src/index.ts:102-148,摘核心):

// Capture before the first await: a later parent switch belongs to the// parent's future.const inherited = captureDelegatedPolicyOverrides(parent)const setup = (childCtx: Context): void => {  appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)  applyChildComposition(childCtx, parent, {    persona: request.persona,    toolFilter: request.toolFilter,  })  // …}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,})

这段是子代理的出生流水线。注意看 captureDelegatedPolicyOverrides 的调用位置:首个 await 之前,同步。父代理此后切换沙箱模式,属于父的未来,不属于这个孩子。capture 的内容(child-agent.ts:199-204):

export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {  return {    sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),    approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never',  }}

这段是委派边界的策略快照。注意看第二行:审批策略无条件钉死 'never'。DelegatedPolicyOverrides 的注释(child-agent.ts:180-186)说直白:委派出去的子代理只在委派那一刻定死的沙箱范围内行动,它的审批请求会被确定性地拒绝。B 修 lint 想装个新依赖,没人会批准它,B 只能在结果里向 A 说明需要什么。捕获的只有父会话的显式沙箱覆盖,部署默认和一次性授权不带。

这些策略以 source: 'delegation' 事件写进子代理自己的日志(appendDelegatedPolicyOverrides,child-agent.ts:215-225),且落在 fork seed 之后,新策略压过旧 seed 里的状态。子代理的有效策略只靠自己的日志就能重建,冷恢复不用回头问父会话。

深度也在出生包裹里。delegationDepthOf(depth.ts:28-36):

export function delegationDepthOf(agent: Agent): number {  const runtime = agent.options.subagentDepth  if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) {    throw new TypeError('agent subagentDepth must be a non-negative safe integer')  }  return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)}

这段是深度读数。注意看最后一行取 max:持久化 header 是只增不减的下限。注释(depth.ts:19-23)讲透了为什么:恢复的子代理带着新 options 回来,深度要是从零重计,它就能像顶层代理一样再委派一层。A 是 0,B 出生是 1,B 再派 C 是 2;工具层 maxDepth 默认 3,配 0 是全面禁止委派(tool-subagent Config 注释 :69-78)。

子代理的 meta 还记 seedLength(child-agent.ts:118):继承的历史到哪条为止、孩子自己的工作从哪条开始,冷恢复时靠它划界。preset 从父的活作用域链读而不是从 header 读(:90-96 注释):父切换 preset 后 header 里还留着旧名字,照 header 记录,子代理冷恢复会用错误的工具集重建回合。

一次性的 spawn 到这里就走完了。我们的 B 是 continuable,A 还要跟它继续对话。常驻子代理,谁来管?

四、常驻子代理:manager 管驻留,inbox 管排队

continuation.ts 的模块头(:7-13)给了核心概念:一个可续式子代理有一个持久 Session,至多一个进程本地的 Activation。Activation 是一次驻留时期:进程内重建出来的 Agent 实例,里面可以跑多个 FIFO 回合,它创建的后代还在跑时它保持驻留。分工原文一句:manager owns residency while the Agent loop owns all turn ordering and execution。manager 只管驻留,回合的排序与执行全部由 Agent 循环自己负责。要是 manager 也管排序,就得再造一套队列;inbox 是 Agent 已有的原语,直接复用,自定义队列这一整类排序 bug 从根上不存在。

我们跟着主线案例走。A 发起委派,startContinuable(continuation.ts:409-476,摘骨架):

async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart> {  const request = spec.request  const parent = request.parent  this.assertAdmitting(parent)  const persistence = this.requirePersistence()  assertSubagentMaxDepth(request.maxDepth)  const childId = spec.childId ?? SessionId(randomUUID())  const childDepth = resolveChildDepth(parent, request.maxDepth)  // Snapshot before any await: invalid descriptor JSON rejects the call  // before a child exists, and the detached value is what reaches the log.  const descriptor = snapshotSubagentDescriptor({ mode: 'continuable', /* … */ })  // Capture before the first await: a later parent switch belongs to the  // parent's future, not to this child.  const delegatedPolicies = captureDelegatedPolicyOverrides(parent)  const prepared = await this.host.prepareContinuable(spec.provider, { sessionId: childId, parent, signal: spec.signal })  // …  const messageId = await this.locks.run(childId, async () => {    const activation = await this.materialize({ childId, provider: spec.provider, parent, /* … */ })    return this.submitMaterialized(activation, request.prompt, { kind: 'user' }, parent, spec.signal)  })  return { childId, messageId }}

这段是可续式子代理的创建全流程。注意看返回时机:inbox 一接受初始 prompt 就返回 childId 和 messageId,不等回合开始,不等消息落盘。A 拿到 id 那一刻,B 可能一个字都还没跑。这之前的任何失败都会让两边都拿不到 id 地 reject,并完整回滚:描述符快照在任何 await 之前做,非法 JSON 在子代理存在前就拒绝调用。

B 跑到一半,A 想追加一句「tsconfig 的 paths 也顺手修一下」。send_message 工具转手调 followup(continuation.ts:502-531):

async followup(  parent: Agent,  childId: SessionId,  content: ContentBlock[],  options: SubagentFollowupOptions,): Promise<MessageId> {  this.assertAdmitting(parent)  while (true) {    const live = await this.locks.run(childId, async () => {      const activation = this.activations.get(childId)      if (activation === undefined) return this.coldResume(parent, childId, content, options)      if (activation.disposal !== undefined) {        return activation.disposal.then(() => undefined, () => undefined)      }      return this.submitAdmitted(activation, content, options.source, parent, options.signal)    })    if (live !== undefined) return live    this.assertAdmitting(parent)    options.signal.throwIfAborted()  }}

这段是 followup 的驻留路由。注意看锁内三分支:驻留中就投递 submitAdmitted;不在内存就 coldResume,从持久 Session 重建一个新 Activation;正赶上处置事务就等释放,返回 undefined 让外层回圈重试冷恢复。调用者看不见 B 驻不驻留,进程重启后 send_message 照样工作。

每个子代理的操作串行靠 ChildLock(continuation.ts:335-346):

run<T>(childId: SessionId, operation: () => Promise<T>): Promise<T> {  const previous = this.tails.get(childId) ?? Promise.resolve()  const result = previous.then(operation, operation)  // Absorb rejections in the chaining tail so one failed critical section  // cannot reject an unrelated later caller.  const tail = result.then(() => undefined, () => undefined)  this.tails.set(childId, tail)  void tail.then(() => {    if (this.tails.get(childId) === tail) this.tails.delete(childId)  })  return result}

这段是每个孩子的尾部承诺锁。注意看第二行:previous.then(operation, operation),前一段无论成败,本段都跑。链尾把 rejection 吸掉,一次失败的临界区不会把后面无关的 caller 一起拖垮。followup、物化、处置全走这把锁,同一个 B 的操作严格排队,不同 B 之间互不阻塞。

A 发现 B 跑偏了,调用 interrupt_agent。工具一行权威都不自带(control/src/index.ts:114-115 注释明说 the tool adds no authority of its own),鉴权全在 service 侧(continuation.ts:554-594,摘分支):

if (this.ctx.agents.get(caller.id) !== caller) {  throw new SubagentError(`interrupting 「${targetSessionId}」 requires the exact live ancestor agent`, 'UNAUTHORIZED')}if (caller.id === targetSessionId) {  throw new SubagentError(`agent 「${caller.id}」 cannot interrupt itself`, 'UNAUTHORIZED')}// …activation.handle.agent.cancel(  authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' },  { keepInbox: true },)

这段是打断的鉴权与执行。注意看第一行比较的是对象身份不是 id:registry 里同 id 的 Agent 已不是这个对象,说明 caller 被替换过,拒绝,防的是同 id 重放的探针。用户侧权限对照 parentSession,祖先侧对照活 lineage 集合。最后 cancel 带上 keepInbox: true:只停当前回合,没领走的 inbox 消息原地保留,等下一次唤醒接着跑。

目标不在内存呢?直接接受、什么都不做(:543-546 注释)。这个 no-op 一律盖住自然完成竞态、重复请求、one-shot id、未知 id 四种情况,不用查持久目录。

B 没被杀掉,它有中间发现要告诉 A。这条路怎么走?

五、report 与 settled:两种消息,分得清清楚楚

可续式子代理手里有一个专属的 report 工具,由 tool-subagent-report 通过 registerContinuableSetup 注入(report/src/index.ts:140-141)。工具只进可续式子代理:根代理、one-shot 子代、远程 provider、无 agent 执行永不见它(模块头 :2-4)。工具描述教它三件事(:68-73):report 不会结束你的回合;完成工作本身不等于产出结果;失败的调用可能其实已送达,别盲目重试。

B 调 report,service 侧先验身份(continuation.ts:622-639):

private authorizeReporter(child: Agent): Activation {  const activation = this.activations.get(child.id)  if (activation === undefined || activation.handle.agent !== child) {    throw new SubagentError(      `agent 「${child.id}」 is not a live continuable subagent and cannot report`,      'UNAUTHORIZED',    )  }  // …}

这段是上报方鉴权。注意看比较:activation.handle.agent !== child,对象全等。子代理本身就是凭证,调用方指定不了收件人;父从持久 lineage 解析,不活就抛 PARENT_UNAVAILABLE(:642-653)。

投递按部署配置分两档(continuation.ts:704-719):

private sendReport(  parent: Agent,  message: ReturnType<typeof createUserMessage>,  delivery: SubagentReportDelivery,): void {  try {    if (delivery === 'next-step') parent.steer(message)    else parent.inject(message)  } catch (error: unknown) {    throw new SubagentError('direct parent is not live; report was not delivered', 'PARENT_UNAVAILABLE', { cause: error })  }}

这段是两档投递。注意看分岔:next-step 走 steer,唤醒 A,报告成为 A 的下一个回合;quiet 走 inject,只加上下文不唤醒,停驻中的 A 要等到别的什么唤醒它时才看到报告。消息本体带前缀 Background subagent ${childId} reported:(:664),来源标 subagent-report。

next-step 档还有一笔防误判账。sendWaking(:690-701)在发送前把消息 id 记进父自己的 Activation 记账,堵住一个窗口:唤醒发送与微任务准入之间,父可能被误判为静默。

B 结算了,A 还会再收一条消息,但那条不是 B 写的。模块头(:15-19)讲了为什么通知父这件事归 manager:外部的 subagent/end 监听器做不好,payload 里不含父的信息,子 handle 那时已 dispose,唤醒父侧结算观察者的 release 已经跑过了。结算消息用另一种来源(continuation.ts:82-90):

export interface SubagentSettledMessageSource {  readonly kind: 'subagent-settled'  /** A runtime account shown without expanding the row (`notice` context form). */  readonly form: 'notice'  /** One-line account of how the child ended. */  readonly summary: string  /** Session id of the child that settled. */  readonly senderSessionId: SessionId}

注释给的理由直白(:77-80):report 是子代理自己选择上报的内容,settle 是 manager 的陈述,合并的话 transcript 会把 manager 说的话记在子代理头上(a transcript that merged them would credit the child with words it never wrote)。

主线案例到此闭环。还剩控制面和收尾。

六、控制面与账单

send_message 的工具描述把语义交代得很诚实(control/src/index.ts:28-33):消息成为下一个 FIFO 回合,子代理正在干活时没法打断它手头的工作,调用只拿回执不拿回答,失败等于没投递出去。这是模型侧已知的模糊点,靠大段描述文字补救。

list_agents 给三档状态(list-agents.ts:59-63):

function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'ready' {  const agent = agents.get(id)  if (agent === undefined) return 'ready'  return agent.status === 'running' ? 'running' : 'idle'}

这段是状态三档。注意看 ready 的措辞(工具描述 :97-99):resumable, not terminal, not a result waiting to be collected。ready 刻意不叫终态,它只存在于存储里,一条 send_message 就能让它复活。one-shot 子代被过滤掉(:75-77),模型选不到它,但遍历仍穿过它去发现更深的后代。list_agents 与 send_message 分开装载,部署可以只开投递、不开发现。

进程收尾走 drain(continuation.ts:730-744):同步关准入,等已准入的物化落定,快照根(没有活 Activation 拥有的 Activation),从根开始子代优先释放。兄弟分支独立结算,一条失败不挡其余,聚合错误等全部分支结束后才抛。

账单式收个尾。这个家族省下了什么?provider 的实现被压到几十行:spawn 两个方法十来行,fork 多一个五行函数;策略种子、深度、血统、组装全部收在共享的 child-agent.ts 和 driver 里,写一遍全家共用。回合排序一行没写,inbox 是现成的。

付出去什么?continuation.ts 1545 行,冷恢复、锁、宽限、排空、结算通知集中在这一个文件里,是全家族理解成本最高的一块。fork 与 continuable 的组合被 issue #2124 挂起,seed 前缀与工具注入的顺序耦合还没解。send_message 只回执不回答,模型要用长描述自己领会。深度默认 3,深层 workflow 编排可能嫌紧,防递归失控的默认值倒是合理。

回到主线案例的最后画面:A 发出委派拿回 childId;追加需求走了 followup 的锁内三分支;打断落到 cancel(cause, { keepInbox: true }) 那个参数上;B 的 report 带着 Background subagent … reported: 前缀进 A 的日志;结算通知换了 subagent-settled 这个 kind,说话的是 manager 不是 B。这些消息在 A 的 transcript 里各归各位,靠的是 report 与 settled 两个 interface 分开声明。

单次委派到这里清楚了。可 A 手里若同时跑五个 B,谁管并发扇出、失败重试、结果聚合?workflow 引擎就是干这个的,它建在 ctx.subagents 之上,下一篇拆它。

你的 Agent 派子代理时,结果回传是自己拼消息,也有独立的归因通道吗?评论区聊聊。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料