跳转至

多 agent 与长时工作:收件箱唤醒

pi 只给人打的字留了队列:agent 正忙时,用户可以插一句 steering,或者排一条等它干完再发的 follow-up。子 agent 和后台命令它不内置,官网的建议是用 tmux 多开几个 pi 自己盯1——子 agent 交差、后台命令跑完、一轮做不完的长任务,都没有一条路自己把 agent 叫醒。dsh 把这些和用户的话一样写进同一个持久收件箱,何时唤醒 agent 由运行时决定。下面的效果除了标明「构造的」,都来自一次真实运行:用 dsh 自己的 headless 入口跑了 fork、spawn 两组父子会话,模型是 deepseek-flash。

一个持久收件箱,三种投递

问题:通知只追加、不唤醒就会丢:模型启动一个长命令,告诉用户「已启动」,结束本轮;命令跑完时 agent 已经空闲,完成通知停在收件箱里,直到用户随手再打一句话才被读到。

每个 agent 有一个收件箱,分两个队列:next-turn 等着开新的一轮,next-step 等着当前这轮的下一步。投递有三种:

  • followup(排下一轮):进 next-turn,并唤醒 agent
  • steer(插队):进 next-step,并唤醒 agent
  • inject(静默附带):进 next-step,不唤醒,等下一次请求时顺带发出
模版 · packages/core/agent-loop/src/agent.ts:154-173
  send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
    // Waking input cannot join an aborted activity, so it starts the next turn.
    // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it.
    const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
    const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
    this.inbox.splice(resolvedTarget, Infinity, 0, [message])
    if (wakeup) this.wakeDriver(wakingAfterAbort)
  }

  followup(input: UserMessage): void {
    this.send(input, 'next-turn', true)
  }

  steer(input: UserMessage): void {
    this.send(input, 'next-step', true)
  }

  inject(input: UserMessage): void {
    this.send(input, 'next-step', false)
  }

轮次循环只认一条规矩:模型这一步说完了,只要 next-step 里还有东西,这一轮就不结束;一轮结束后收件箱还有待办,就接着开下一轮。

模版 · packages/core/agent-loop/src/agent.ts:360-366
        signal.throwIfAborted()
        if (turnEnds && this.inbox.nextStep.length === 0) {
          await this.dispatch.serial('agent/turn-stopping', { turn, signal })
          signal.throwIfAborted()
        }
        if (turnEnds && this.inbox.nextStep.length === 0) break
        target = 'next-step'
模版 · packages/core/agent-loop/src/agent.ts:392-397
    if (!this.inbox.hasPending) return false
    phase.abort = new AbortController()
    // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself.
    phase.wakeRequested = false
    phase.step = 0
    return true

收件箱是会话日志里 agent/inbox/spliced 事件的折叠:进程崩了,排着的消息从日志重放出来;同一条消息挂两次,折叠直接报错。

模版 · packages/core/agent-loop/src/inbox.ts:31-56
  apply(state: InboxState, event) {
    if (event.type !== 'agent/inbox/spliced') return state
    const splice = event.data
    try {
      const inbox = state[splice.target]
      const removedCount = splice.removedCount ?? 0
      if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
        || !Number.isSafeInteger(removedCount) || removedCount < 0
        || splice.start + removedCount > inbox.length) {
        throw new Error('invalid inbox splice')
      }
      const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted)
      const ids = new Set<string>()
      for (const message of splice.target === 'next-turn'
        ? [...next, ...state['next-step']]
        : [...state['next-turn'], ...next]) {
        if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`)
        ids.add(message.id)
      }
      return splice.target === 'next-turn'
        ? { 'next-turn': next, 'next-step': state['next-step'] }
        : { 'next-turn': state['next-turn'], 'next-step': next }
    } catch (error: unknown) {
      throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error })
    }
  },

把 fork 那组父会话的真实日志喂给它,再构造一次重复挂起:

效果 · 父会话的收件箱怎么变(最后一行是构造的)
seq 3 next-turn insert -> next-turn ["Read the following design document carefully and keep it in "] next-step []
seq 4 turn/start {"turn":1}
seq 5 next-turn claim  -> next-turn [] next-step []
seq 6 step/start {"turn":1,"step":1}
seq 15 step/end {"turn":1,"step":1}
seq 16 turn/end {"turn":1,"reason":{"kind":"completed"}}
seq 18 next-turn insert -> next-turn ["Use the subagent_fork tool exactly once to delegate this que"] next-step []
seq 19 turn/start {"turn":2}
seq 20 next-turn claim  -> next-turn [] next-step []
seq 21 step/start {"turn":2,"step":1}
seq 29 step/end {"turn":2,"step":1}
seq 30 step/start {"turn":2,"step":2}
seq 35 step/end {"turn":2,"step":2}
seq 36 step/start {"turn":2,"step":3}
seq 37 next-step insert -> next-turn [] next-step ["Agent 38f22412-4c7e-411e-9f79-6e4cf04f830c sent a message:  According to the design document, the `agent/turn-stopping` "]
seq 42 step/end {"turn":2,"step":3}
seq 43 next-step claim  -> next-turn [] next-step []
seq 44 step/start {"turn":2,"step":4}
seq 46 next-step insert -> next-turn [] next-step ["Background subagent 38f22412-4c7e-411e-9f79-6e4cf04f830c fin Its closing message: The `agent/turn-stopping` event lets a listener stop a turn,"]
seq 49 step/end {"turn":2,"step":4}
seq 50 next-step claim  -> next-turn [] next-step []
seq 51 step/start {"turn":2,"step":5}
seq 55 step/end {"turn":2,"step":5}
seq 56 turn/end {"turn":2,"reason":{"kind":"completed"}}
constructed: the same pending message spliced again -> invalid persisted inbox splice at session seq 1 <- message "aac229e6-6e44-427b-bfa3-40cdefeac2f4" is already pending

第 2 轮第 3 步进行中(seq 37),子 agent 的消息插进 next-step,第 3 步一结束就被领走。第 4 步模型已经用文字作答,结算通知又在 seq 46 插进来,这轮于是还有第 5 步。

子 agent 在后台跑,运行时负责报结局

问题:子 agent 同步跑,慢任务就卡住父 agent;结果全靠子 agent 自己汇报,它出错、被停或忘了汇报,父 agent 就什么也收不到。

委派工具一调用就返回子 agent 的 id,父 agent 接着干自己的事。子 agent 有两条路回话:自己调 send_message,说什么由模型定;结束时运行时发的结算通知,不管子 agent 发没发过消息都发。父 agent 空闲就排下一轮,正忙就插队:

模版 · packages/subagent/subagent/src/manager.ts:1186-1206
  /** Deliver the captured execution result to the durable direct parent after cleanup. */
  private notifySettlement(activation: Activation, terminal: SubagentResult): void {
    if (!activation.announced || activation.delivery === 'caller') return
    try {
      const parent = this.ctx.agents.get(activation.parent.id)
      if (parent !== activation.parent) return
      const message = createSettlementMessage(
        activation.childId, terminal, activationAgent(activation) !== undefined,
      )
      if (this.closingTeardownFor(parent) !== undefined) {
        parent.inject(message)
        return
      }
      this.sendWaking(parent, message, parent.status === 'idle' ? 'queue' : 'steer')
    } catch (error: unknown) {
      this.ctx.logger.warn(
        `subagent "${activation.childId}" settlement notice was not delivered to its parent: `
        + errorChain(error),
      )
    }
  }

通知只说怎么结束、留了什么话,不带子 agent 的思考过程:

模版 · packages/subagent/subagent/src/continuation-messages.ts:138-168
export function createSettlementMessage(
  childId: SessionId,
  terminal: SubagentResult,
  continuable = true,
): ReturnType<typeof createUserMessage> {
  const summary = settlementSummary(childId, terminal.stopReason, continuable)
  // Parent providers receive this notice as a user message and may reject
  // nontext assistant blocks. Keep this conversion local so SDK/UI consumers
  // retain the complete child output.
  const closingText = terminal.output.flatMap(block =>
    block.type === 'text' && block.text.length > 0 ? [block] : [],
  )
  return createUserMessage({
    content: [
      { type: 'text' as const, text: summary },
      ...closingText.length === 0
        ? [{ type: 'text' as const, text: 'It left no closing message.' }]
        : [{ type: 'text' as const, text: 'Its closing message:' }, ...closingText],
      ...terminal.structured !== undefined
        ? [{ type: 'text' as const, text: `Structured result: ${JSON.stringify(terminal.structured)}` }] : [],
      ...terminal.diagnostic !== undefined
        ? [{ type: 'text' as const, text: terminal.diagnostic }] : [],
    ],
    source: {
      kind: 'subagent-settled' as const,
      form: 'notice' as const,
      summary: boundContextSummary(summary),
      senderSessionId: childId,
    },
  })
}

用同一个函数给几种结局各出一条(结局是构造的):

效果 · 各种结局的结算通知
## completed
Background subagent child-1 finished and will do no further work unless you send it more.
Its closing message:
The `agent/turn-stopping` event lets a listener stop a turn; it is serial.
source: {"kind":"subagent-settled","form":"notice","summary":"Background subagent child-1 finished and will do no further work unless you send it more.","senderSessionId":"child-1"}

## completed, one-shot backend
Background subagent child-1 finished. It cannot receive follow-up messages.
Its closing message:
Done.
source: {"kind":"subagent-settled","form":"notice","summary":"Background subagent child-1 finished. It cannot receive follow-up messages.","senderSessionId":"child-1"}

## aborted, no closing text
Background subagent child-1 was stopped before it finished.
It left no closing message.
source: {"kind":"subagent-settled","form":"notice","summary":"Background subagent child-1 was stopped before it finished.","senderSessionId":"child-1"}

## error with diagnostic
Background subagent child-1 failed before it finished.
It left no closing message.
LLM request failed: 429 rate limited
source: {"kind":"subagent-settled","form":"notice","summary":"Background subagent child-1 failed before it finished.","senderSessionId":"child-1"}

## structured output
Background subagent child-1 finished and will do no further work unless you send it more.
Its closing message:
Found 2 call sites.
Structured result: {"callSites":["a.ts:12","b.ts:40"]}
source: {"kind":"subagent-settled","form":"notice","summary":"Background subagent child-1 finished and will do no further work unless you send it more.","senderSessionId":"child-1"}

真实运行里,父 agent 先后收到两条,都是插队进来的:

效果 · fork 那组父会话收到的子 agent 消息和结算通知
seq 45, opens turn 2 step 4, source agent-message:
Agent 38f22412-4c7e-411e-9f79-6e4cf04f830c sent a message: 
According to the design document, the `agent/turn-stopping` event lets a listener stop a turn, and it is a serial agent event with no `next()` (unlike the waterfalls — `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events — which require listeners to call `next()`).

seq 52, opens turn 2 step 5, source subagent-settled:
Background subagent 38f22412-4c7e-411e-9f79-6e4cf04f830c finished and will do no further work unless you send it more.
Its closing message:
The `agent/turn-stopping` event lets a listener stop a turn, and it is a serial agent event with no `next()` (unlike the waterfall events, whose listeners must call `next()` to delegate).

两条内容重复,项目认下这个代价:前者是子 agent 选择说的话,后者是运行时对结局的记录,子 agent 不配合时只有后者兜底。

fork 还是 spawn:fork 为的是父会话的前缀缓存

问题:子任务要接着父会话的上下文做(复查、续写、追问)。spawn 一个全新的子 agent,父 agent 得把上下文重抄进任务:抄少了答不对,抄多了全价重算。fork 把父会话复制过去,可只要子 agent 的请求开头和父会话差一个字节——多一段专属的 system、多一个专属工具——前缀缓存就失效,复制来的上下文照样全价重算。

fork 的种子是父会话截到最后一个 turn/end 的前缀,进行中的那一轮有没结果的工具调用,不能回放。种子只在创建时截一次,冷恢复回放的也是它:

模版 · packages/subagent/subagent-fork-in-process/src/index.ts:46-53
function completedTurnPrefix(parent: Agent): SessionEvent[] {
  // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  const events = parent.session.snapshotEvents()
  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)
}
模版 · packages/subagent/subagent-fork-in-process/src/index.ts:67-78
  // Context contract: a forked child IS seeded with the parent's completed-turn prefix.
  readonly inheritsParentContext = true

  constructor(readonly name: string) {}

  prepareContinuable(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec> {
    // The fork prefix is captured ONCE, at creation: it becomes part of the
    // child's own durable transcript, so a later cold resume replays that
    // prefix instead of re-forking the parent's newer history.
    const seed = completedTurnPrefix(request.parent)
    return Promise.resolve(seed.length > 0 ? { seed } : {})
  }

子 agent 专属的东西一律不放进请求开头:

  • 回话指引追加在任务末尾;send_message 全局注册,父子工具表一样
  • 「你是被委派的子 agent」这句话走运行时上下文,不进 system prompt
  • fork 工具不让模型给子 agent 换模型
模版 · packages/subagent/subagent/src/continuation-messages.ts:80-96
export function withContinuableReturnGuidance(
  parentId: SessionId,
  prompt: ContentBlock[],
): ContentBlock[] {
  const encodedParentId = JSON.stringify(parentId)
  return [
    ...prompt,
    {
      type: 'text',
      text: `Your parent agent id is ${encodedParentId}. Before you finish, send your result to that agent with `
        + `send_message({ agent_id: ${encodedParentId}, message: "<self-contained result>" }). The parent shares `
        + 'your workspace but does not automatically receive your transcript, tool output, or reasoning. Send '
        + 'earlier messages as well when a finding changes what the parent should do next; sending a message '
        + 'does not end your turn.',
    },
  ]
}
模版 · packages/subagent/subagent/src/child-agent.ts:166-175
/**
 * Model-facing delegation-scope statement for every in-process child. A
 * runtime-context contribution rather than a system-prompt section, so the
 * deployment's system prompt stays uniform across parents and children.
 */
export const SUBAGENT_DELEGATION_CONTEXT
  = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be '
    + 'widened from inside this session — operations that require approval are rejected automatically. '
    + 'When the task needs access beyond that scope, do not retry the denied operation; state the '
    + 'limitation in your reply so the delegating agent can handle it.'
模版 · packages/bundle/base/cordis.patch.yml:386-395
    # Fork omits model selection so provider/model stay equal to the parent and
    # the inherited history remains eligible for KV Cache reuse. Parent and child
    # share the messaging tools; return guidance follows the inherited history.
    # See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md
    # and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md.
    - id: tool-subagent-fork
      name: '@deepseek-ai/dsh-tool-subagent'
      config:
        provider: fork
        toolName: subagent_fork

两种工具的描述按「子 agent 看不看得到本对话」各说各的:

模版 · packages/subagent/tool-subagent/src/index.ts:113-137
function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
  if (inheritsConversation) {
    return {
      description:
        'Delegate a task to a subagent that inherits this conversation: a child agent seeded with all '
        + 'completed turns so far (it does not see the current in-flight turn). Use this when the subtask '
        + 'builds on this conversation\'s context — a follow-up analysis, '
        + 'a review, a continuation — without consuming this conversation\'s context for the work itself. '
        + 'You receive its result, not its intermediate steps.',
      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 {
    description:
      'Delegate a self-contained task to a subagent (a separate agent that works in its own context) '
      + 'to offload focused, independent work — research, a scoped '
      + 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent '
      + 'returns its result, not its intermediate steps.',
    promptDescription:
      'The complete, self-contained task for the subagent. It does not share this '
      + 'conversation\'s context, so include everything it needs.',
  }
}
效果 · 实跑时模型看到的两个委派工具的描述
## subagent
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. The runtime notifies you when it finishes. The child reports results with `send_message`; use `send_message` to steer it while running or continue its conversation after it finishes.

prompt: The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs.

## subagent_fork
Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. The runtime notifies you when it finishes. The child reports results with `send_message`; use `send_message` to steer it while running or continue its conversation after it finishes.

prompt: The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new.

实跑时,两组会话第 1 轮都发同一份设计文档(dsh 自己的 docs/architecture.md),第 2 轮分别用 subagent_fork 和 subagent 委派同一个问题。四个请求的用量,input 是命中加未命中:

效果 · 父子请求的缓存命中
fork:  parent, turn 2 step 1 (calls the tool) #2   messages  3  input  10015  cache hit   9856  uncached   159
fork:  child, first request                  #4   messages  3  input  10367  cache hit   9856  uncached   511
spawn: parent, turn 2 step 1 (calls the tool) #10  messages  3  input  10036  cache hit   9856  uncached   180
spawn: child, first request                  #12  messages  1  input   6051  cache hit   5376  uncached   675

fork child  vs its parent: tools equal True, system equal True, first 2 messages equal True
spawn child vs its parent: tools equal True, system equal True, first message equal False
model deepseek-flash, thinking {'type': 'enabled'}, output_config {'effort': 'high'}, max_tokens 256000

fork 子会话日志的开头是父会话第 1 轮的原样复制,session/end-seed 标出种子到此为止:

效果 · fork 子会话日志的前 29 条事件
{"type": "session", "version": 4, "id": "38f22412-4c7e-411e-9f79-6e4cf04f830c", "createdAt": 1792499179231, "cwd": "/tmp/dsh-lab/agents/scratch/ws", "parentSession": "session-b41abf03-91fe-43db-86a0-ff44a6782dde", "isSeeded": true, "origin": "subagent", "delegationDepth": 1}
seq  0 permission/preset                      {"preset": "workspace-write"}
seq  1 sandbox/mode                           {"mode": "workspace-write"}
seq  2 approval/policy                        {"policy": "ask"}
seq  3 agent/inbox/spliced                    {"target": "next-turn", "start": 0, "inserted": [{"content": [{"type": "text", "text": "Re …(design document)…
seq  4 turn/start                             {"turn": 1}
seq  5 agent/inbox/spliced                    {"target": "next-turn", "start": 0, "removedCount": 1, "inserted": []}
seq  6 step/start                             {"turn": 1, "step": 1}
seq  7 system/message                         {"turn": 1, "step": 1, "message": {"role": "system", "content": [{"type": "text", "text": "You are an AI agent powered by DeepSeek Harness.\n\nYou are
seq  8 user/message                           {"content": [{"type": "text", "text": "Read the following design document carefully and ke …(design document)…
seq  9 user/message                           {"content": [{"type": "text", "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directo
seq 10 request/header                         {"header": {"config": {"provider": "deepseek-official", "model": "deepseek-flash", "maxTokens": 256000, "reasoningEffort": "high"}, "adapterDefaults":
seq 11 request/context                        {"provider": "deepseek-official", "model": "deepseek-flash", "contextWindow": 1000000, "systemPromptUpdate": "in-history"}
seq 12 session/title                          {"title": "Read the following design document", "messageSeqs": [8], "source": {"kind": "fallback"}}
seq 13 session-log-deepseek/delivery-accepted {"sessionId": "session-b41abf03-91fe-43db-86a0-ff44a6782dde", "sessionFormatVersion": 4, "throughSeq": 12}
seq 14 assistant/message                      {"turn": 1, "step": 1, "message": {"role": "assistant", "content": [{"type": "reasoning", "text": ""}, {"type": "text", "text": "OK"}], "source": {"ki
seq 15 step/end                               {"turn": 1, "step": 1}
seq 16 turn/end                               {"turn": 1, "reason": {"kind": "completed"}}
seq 17 session/end-seed                       {"inherited": true}
seq 18 subagent/descriptor                    {"version": 3, "mode": "continuable", "provider": "fork", "label": "Answer event question", "agentProvider": "deepseek-official", "agentModel": "deeps
seq 19 sandbox/mode                           {"mode": "workspace-write", "source": "delegation"}
seq 20 approval/policy                        {"policy": "never", "source": "delegation"}
seq 21 agent/inbox/spliced                    {"target": "next-turn", "start": 0, "inserted": [{"content": [{"type": "text", "text": "According to the design document, which event lets a listener 
seq 22 turn/start                             {"turn": 2}
seq 23 agent/inbox/spliced                    {"target": "next-turn", "start": 0, "removedCount": 1, "inserted": []}
seq 24 step/start                             {"turn": 2, "step": 1}
seq 25 user/message                           {"content": [{"type": "text", "text": "According to the design document, which event lets a listener stop a turn, and what kind of event is it? Answer
seq 26 user/message                           {"content": [{"type": "text", "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directo
seq 27 request/header                         {"header": {"config": {"provider": "deepseek-official", "model": "deepseek-flash", "reasoningEffort": "high", "maxTokens": 256000}, "adapterDefaults":

fork 子 agent 首个请求的最后一条 user 消息,由任务、回话指引、运行时上下文三块组成:

效果 · fork 子 agent 首个请求的最后一条 user 消息
According to the design document, which event lets a listener stop a turn, and what kind of event is it? Answer in one sentence.

---

Your parent agent id is "session-b41abf03-91fe-43db-86a0-ff44a6782dde". Before you finish, send your result to that agent with send_message({ agent_id: "session-b41abf03-91fe-43db-86a0-ff44a6782dde", message: "<self-contained result>" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn.

---

Current runtime context. This snapshot supersedes earlier runtime-context snapshots.

Current working directory: "/tmp/dsh-lab/agents/scratch/ws".

Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "/tmp/dsh-lab/agents/scratch/ws". Some platform temporary areas may also be writable.

Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).

Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.

Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.

You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it.

spawn 子 agent 拿到的任务是父 agent 自己写的,它从文档里抄了一句原文进去:

效果 · spawn 子 agent 拿到的任务(父 agent 写的)
According to the DeepSeek Harness architecture design document, which event lets a listener stop a turn, and what kind of event is it? Answer in one sentence.

Context from the design document (quote): "`agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`."

fork 子 agent 背着整份文档,10367 token 里 9856 命中缓存,没命中的 511 是任务、回话指引和新的运行时上下文。spawn 子 agent 只有 6051 token:命中的 5376 是 system prompt 加工具表,同一部署所有会话的开头都是这段;没命中的 675 里有父 agent 抄进来的那句原文。这次抄对了,spawn 也答对了;抄漏了,子 agent 只能瞎猜。

委派深度和权限在委派那一刻定死

问题:子 agent 是持久会话,空闲后释放内存,再收到消息时从磁盘冷恢复,拿到一套新的运行参数。深度要是从参数里读,就从 0 算起,能像顶层 agent 一样一层层委派下去。

深度写进子会话的头部(delegationDepth),读的时候取头部和运行参数里较大的那个:

模版 · packages/subagent/subagent/src/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')
  }
  // The header value was validated at the session boundary (creation and
  // persistence load both construct through the store).
  return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)
}
效果 · 几种情况下的委派深度(构造的 agent)
fresh top-level agent: delegationDepthOf = 0
live child (header 1, runtime 1): delegationDepthOf = 1
child resumed cold (header 1, fresh options): delegationDepthOf = 1
runtime option claims 0 on a depth-1 session: delegationDepthOf = 1

权限也在委派时截取:审批策略固定为 never,沙箱范围取委派那一刻父会话的,记进子会话日志(上面 seq 19–20,来源 delegation)。超出范围直接被拒,运行时上下文让它把限制写进回复交给父 agent,就是上面那条 user 消息的最后一段。深度用完时工具表不变,运行时上下文里多一行「别再调 subagent」;真调了,resolveChildDepth(child-agent.ts:49-58)照样拒。

模版 · packages/subagent/subagent/src/child-agent.ts:220-235
/** Policy seeded onto a child session's log at the delegation boundary. */
export interface DelegatedPolicyOverrides {
  /**
   * The parent's current preset identity when it runs in Auto or Full access;
   * the child keeps it under the pinned `never` approval policy.
   */
  readonly permissionPreset: 'auto' | 'danger-full-access' | undefined
  /** The parent session's explicit sandbox-mode override, or `undefined` without one. */
  readonly sandboxMode: SandboxMode | undefined
  /**
   * `'never'` whenever the approval capability is composed, `undefined`
   * otherwise: a delegated child acts only within the sandbox scope fixed at
   * delegation, so its asks are rejected deterministically.
   */
  readonly approvalPolicy: 'never' | undefined
}

同会话目标:轮数有上限,人类消息不计数,续跑开关不落盘

问题:agent 做到一半就宣布完成,得有人一遍遍说「继续」。做成内存里的循环,进程一重启目标就没了;每一轮都算进度,用户插的闲话也会花掉续跑的轮数。

目标是挂在会话上的状态,存成日志事件 goal/change,带轮数上限(默认 256)。计数只认来源是 goal、轮号正好是上一轮加 1 的消息:

模版 · packages/goal/goal/src/fold.ts:321-331
  if (event.type === 'user/message') {
    const source = goalSource(event.data.source)
    if (source === undefined) return
    const current = state.goal
    if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
      || source.revision !== current.revision || source.round !== state.roundsStarted + 1
      || source.round > current.maxGoalRounds) {
      throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
    }
    state.roundsStarted = source.round
  }
效果 · 目标折叠(构造的日志:上限 3 轮,中间插了两条人类消息)
after create, round 1, two human messages, round 2: {"goal":{"id":"g1","revision":1,"objective":"Make every test in packages/foo pass","phase":"active","maxGoalRounds":3},"roundsStarted":2,"createdAt":1,"updatedAt":1,"lastRef":{"id":"g1","revision":1}}
constructed: round 4 right after round 2 -> goal round at session event 5 is not the next admitted round of the active goal

续跑驱动只在 agent 空闲、没有人类消息排队、目标激活时出手:先等子 agent 全部静默;到上限就把目标标成 blocked,否则 followup 一条 <goal_round> 消息开新一轮:

模版 · packages/goal/goal-round-driver/src/index.ts:172-208
    const goal = currentGoal(state)
    if (!drivable(goal)) return
    // A round started while descendants progress would only wait for them.
    // Every goal mutation requests a checkpoint, so passing the recheck
    // proves this goal view is still current.
    const subagents = ctx.get('subagents')
    if (subagents !== undefined) {
      await Promise.race([subagents.waitForChildren(agent), unloading.promise])
      if (!readyAfterCheckpoint(state)) return
    }
    if (goal.roundsStarted >= goal.maxGoalRounds) {
      ctx.goals.block(agent, goalRef(goal), {
        code: 'round-limit',
        message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`,
      })
      return
    }

    const round = goal.roundsStarted + 1
    const content = renderGoalRoundPrompt(goal, round)
    const message = createUserMessage({
      content,
      source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
    })
    const reservation: RoundAttempt = {
      goalId: goal.id,
      revision: goal.revision,
      round,
      messageId: message.id,
      content,
      phase: 'queued',
      cancelled: false,
      stale: false,
    }
    state.attempt = reservation
    try {
      agent.followup(message)
效果 · 第 3 轮的续跑消息(构造的目标)
<goal_round>
Objective: "Make every test in packages/foo pass"
Round: 3/3

Continue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.
</goal_round>

「能不能自动续跑」只存在进程内存里,项目叫它 armed / disarmed。agent 一创建就是 disarmed,打开旧会话、fork 都算;不是本进程写下的 goal/change 也会让它变回 disarmed。打开旧会话不该自己烧 token;开关要是进了日志折叠,就会写进检查点,武装它的进程没了它还在。

模版 · packages/goal/goal/src/index.ts:255-267
    ctx.on('agent/created', ({ agent }) => {
      this.setActivation(agent.session, 'disarmed')
    })
    ctx.sessionProjections.register(goalProjectionDefinition)
    ctx.on('session/event', (session, event) => {
      if (event.type !== 'goal/change') return
      const runtime = this.runtimeState(session)
      const activation = runtime.pendingActivation !== undefined
        && SessionSeq(runtime.pendingActivation.offset) === event.seq
        ? runtime.pendingActivation.activation
        : 'disarmed'
      this.setActivation(session, activation)
    })

自动开出的一轮能做什么,看本轮消息的来源,不看 prompt:建、改、暂停、恢复目标都要本轮有人类消息;完成和 blocked 还接受本轮正是当前目标轮。

模版 · packages/goal/tool-goal/src/authority.ts:105-118
/**
 * Resolve completion authority from either direct human input or the exact goal round.
 * @param ctx - Context carrying live agents and goal state.
 * @param execution - Authenticated current tool execution.
 * @returns The direct-human or exact-goal-round authority grant.
 */
export function completionAuthority(ctx: Context, execution: GoalToolExecution): GoalToolAuthority {
  if (hasDirectHumanInput(ctx, execution)) return { kind: 'direct-human' }
  const goal = ctx.goals.get(execution.agent)
  if (goal !== undefined && isMatchingGoalRound(execution, goal)) {
    return { kind: 'goal-round', goal }
  }
  return reject('complete and blocked require a direct human turn or the current goal round')
}

后台任务和定时任务也走这个收件箱

问题:后台任务完成会唤醒空闲的 agent,被唤醒的那一轮又可能启动新任务,自己一直叫醒自己。给唤醒设上限 3,一个会话连跑 4 个后台任务、中间没人插话,第 4 个通知就静默附带进一个不会再醒的 agent,界面上看不到,像卡死了(issue #4944)。

后台任务完成时,agent 正忙就静默附带,同时完成的几个只多花一步;空闲就唤醒。唤醒上限默认不设、部署可配,理由是看得见的花费胜过看不见的卡死。

模版 · packages/jobs/tool-jobs/src/index.ts:263-267
  // A busy owner is injected: the notice waits in its next-step inbox, which
  // the turn cannot close over, so jobs settling together cost one step. An
  // idle owner is woken instead, because an undelivered notice is a completion
  // the model never learns about. Either way, disposal before delivery
  // discards it with the owner, and a teardown settlement has no reader left.
模版 · packages/jobs/tool-jobs/src/index.ts:296-308
    if (delivery === 'wakeup' && owner.status === 'idle') {
      if (wakeBudget === undefined) {
        owner.followup(message)
        return
      }
      const spent = spentWakes.get(owner) ?? 0
      if (spent < wakeBudget) {
        spentWakes.set(owner, spent + 1)
        owner.followup(message)
        return
      }
    }
    owner.inject(message)

定时任务到点时,先把冷会话恢复成 agent,再 followup 一条提醒,落盘成功才记为已送达:

模版 · packages/schedule/schedule/src/runtime.ts:119-125
        const message = createUserMessage({
          content: [{ type: 'text', text }], source: { kind: 'schedule' },
        })
        // followup synchronously appends the inbox splice before flush observes the Session.
        resolved.agent.followup(message)
        const flushed = await this.ctx.sessions.flush(resolved.agent.session)
        if (!flushed) throw new Error('Session persistence did not acknowledge the reminder')
效果 · 一条到点的提醒(构造的记录)
[SCHEDULE REMINDER]
This is a scheduled message from the user
schedule_id_json: "sch-1"
occurrence_at: 2026-10-21T09:00:00.000Z
reminder_prompt_json: "Check whether the nightly benchmark finished and summarize regressions."

大批扇出用 Workflow:模型写一段 JS,用 agent() 起子 agent,中间结果不回父会话,总数默认以 1000 封顶。Ralph 循环、Agent Teams 默认关闭,不展开。

谁管什么

  • 程序:三种投递、一轮何时结束、何时唤醒、结算通知、fork 种子截到哪、请求开头父子一致、委派深度和权限、目标计数和上限、续跑开关
  • 模型:何时委派、选 fork 还是 spawn、spawn 时往任务里抄什么、要不要中途 send_message、目标轮里何时 complete 或 block
  • 用户:设目标和上限、暂停和恢复目标、批准权限;子 agent 的权限不会超过委派时父会话已有的

实跑原件

15 次模型调用的用量:fork 组 1–8,spawn 组 9–15,含父 agent 等待时自己调的 list_agents 等步骤
{"n": 1, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 1, "usage": {"input_tokens": 9942, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 2, "service_tier": "standard"}}
{"n": 2, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 3, "usage": {"input_tokens": 159, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 9856, "output_tokens": 102, "service_tier": "standard"}}
{"n": 3, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 5, "usage": {"input_tokens": 175, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 9984, "output_tokens": 71, "service_tier": "standard"}}
{"n": 4, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 3, "usage": {"input_tokens": 511, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 9856, "output_tokens": 236, "service_tier": "standard"}}
{"n": 5, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 7, "usage": {"input_tokens": 162, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10112, "output_tokens": 23, "service_tier": "standard"}}
{"n": 6, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 5, "usage": {"input_tokens": 147, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10496, "output_tokens": 46, "service_tier": "standard"}}
{"n": 7, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 9, "usage": {"input_tokens": 207, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10240, "output_tokens": 36, "service_tier": "standard"}}
{"n": 8, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 11, "usage": {"input_tokens": 210, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10368, "output_tokens": 36, "service_tier": "standard"}}
{"n": 9, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 1, "usage": {"input_tokens": 214, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 9728, "output_tokens": 26, "service_tier": "standard"}}
{"n": 10, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 3, "usage": {"input_tokens": 180, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 9856, "output_tokens": 207, "service_tier": "standard"}}
{"n": 11, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 5, "usage": {"input_tokens": 170, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10112, "output_tokens": 172, "service_tier": "standard"}}
{"n": 12, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 1, "usage": {"input_tokens": 675, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 5376, "output_tokens": 239, "service_tier": "standard"}}
{"n": 13, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 3, "usage": {"input_tokens": 186, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 6144, "output_tokens": 32, "service_tier": "standard"}}
{"n": 14, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 7, "usage": {"input_tokens": 255, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10240, "output_tokens": 251, "service_tier": "standard"}}
{"n": 15, "path": "/v1/messages", "status": 200, "model": "deepseek-flash", "messages": 9, "usage": {"input_tokens": 347, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 10624, "output_tokens": 36, "service_tier": "standard"}}

  1. pi 官网「What we didn't build」一节的 No sub-agents、No background bash,和 RPC 文档里的 steer、follow_up 两条命令(pi.dev、RPC commands),经 Context7 读取,2026-10-20 读。 ↩