一切皆插件:DeepSeek Harness 源码精读 11 / 14

第 11 章

委托与并行:subagent、workflow、goal

源码核对基于 deepseek-ai/deepseek-harness commit `b150a551b8`(2026-08-21),tag `course-anchor-20260823`对比语料 anthropics/claude-code commit `45bdfa96`(2026-08-23),tag `v2.1.241`本章字数:约 9210 字(不含代码与图)

场景还原

你在 IDE 里对一个 agent 说:调研三个开源库,各写一份评估。它没有逐条汇报,而是转身开出了三个新 agent 卡片,各领一份。你盯着卡片上的「subagent」字样,冒出一串问题:这三个小家伙和主 agent 是什么关系?它们继承了我的对话、我的工具、我的权限吗?如果我中途改了主 agent 的配置,它们会跟着变吗?

再看另一个场景。你想让 agent 每天跟进一个长期任务,于是挂了一个目标;第二天它居然自己接着干,但你记得自己并没有点过「继续」。这是怎么回事,谁允许它继续的?

这一章回答一个核心问题:一个 agent 如何派生另一个 agent,状态如何继承与隔离。答案分三种形态。subagent 是模型按需开出的子 agent,一问一答跑完即走;workflow 是脚本驱动的编排,一个脚本可以并行派出一批子 agent;goal 根本不派活,它给现有 session 挂一个持久目标,让同一会话自己在空闲时续跑。三种形态共用同一套状态哲学:派生出来的东西,带走的只有明确列出的数据,别的都不继承。

逐行精读

这一章走三条路,先看整体分工。下面这张图回答:subagent、workflow、goal 在「派活」上的位置各是什么。

flowchart TD A[主 agent] --> B[subagent 委托] A --> C[workflow 编排] A --> D[goal 挂目标] B --> B1[每次请求一个孩子] C --> C1[脚本一次声明一批孩子] D --> D1[同一 session 自己续跑]

subagent 与 workflow 都派生新 agent,区别在决策点位置;goal 不派生,只给现有 session 挂状态。下面逐个精读。

委托的入口:先验能力,再派活

先看一次委托在服务层的入口。SubagentRuntime.start() 是模型侧工具调用的落点,它在调用 provider 之前做完所有校验:

packages/subagent/subagent/src/index.ts430:442
430  async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {431    const provider = this.expectProvider(name)432    this.assertCapabilities(provider, request)433    assertSubagentMaxDepth(request.maxDepth)434    if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)435    const descriptor = snapshotSubagentDescriptor({436      mode: 'one-shot',437      provider: name,      ...request.label !== undefined ? { label: request.label } : {},439    })440    const resolved: ResolvedSubagentStartRequest = { ...request, descriptor }441    return observeRun(this.emitLifecycle, name, request.parent, await provider.start(resolved))442  }

顺序是刻意的:provider 不存在就先抛错(expectProvider),然后校验「你要的能力 provider 有没有」。能力清单是 provider 自己声明的:

packages/subagent/subagent/src/types.ts86:91
86export interface SubagentCapabilities {87  readonly outputSchema: boolean88  readonly depthLimit: boolean89  readonly toolFilter: boolean90  readonly persona: boolean91}

assertCapabilitiespackages/subagent/subagent/src/index.ts:497-512)遍历请求里出现的字段,发现 provider 不支持就抛 UNSUPPORTED_CAPABILITY。这个「先声明再使用」的规则,一手材料里叫「Capability gating keeps providers honest」(.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md:60):外部 provider 只声明自己能强制的东西,请求永远不会被静默降级。之后才是 observeRun,它在 provider 返回已发布 run 时发出 subagent/start 事件,配对的 subagent/end 在 run 结束时发出;两个事件都按委托方 parent 做 scope 过滤派发(packages/subagent/subagent/src/index.ts:157-166),所以「谁家的子 agent」从事件层就是明确的。

为什么能力校验值得摆到派活之前?docs/architecture.md:102 的转述给出了尺度:seam 的意义在于一次 provider 交换移动整个产品,而 subagent provider 在同一个接口后面变化的幅度最宽,从同进程新开的一个孩子,到另一个产品里的一段委托回合。实现差异这么大,请求里要什么、provider 能干什么,就必须在委托前核对清楚,否则「选了不支持的选项」会被拖到孩子跑起来才暴露。(推断:原文没说这两个端点具体指谁,按 dsh 的实现,前者是 in-process-driver 开的新孩子,后者是 ACP 侧把委托回合交给另一个 agent 产品的 provider。)

深度预算:持久化头部说了算

请求里可以带 maxDepth,它是「绝对深度上限」。子 agent 的深度怎么算?看 child-agent.ts

packages/subagent/subagent/src/child-agent.ts48:57
48export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): number {49  const childDepth = delegationDepthOf(parent) + 150  if (!Number.isSafeInteger(childDepth)) {51    throw new RangeError('subagent child depth exceeds the safe-integer range')52  }53  if (maxDepth !== undefined && childDepth > maxDepth) {54    throw new SubagentDepthError(childDepth, maxDepth)55  }56  return childDepth57}

父深度加一。父深度从哪来?delegationDepthOf

packages/subagent/subagent/src/depth.ts28:36
28export function delegationDepthOf(agent: Agent): number {29  const runtime = agent.options.subagentDepth30  if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) {31    throw new TypeError('agent subagentDepth must be a non-negative safe integer')32  }33  // The header value was validated at the session boundary (creation and34  // persistence load both construct through the store).35  return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)36}

函数上方的 JSDoc 注释(同文件 18 至 27 行)说清了单调性:持久化的 delegationDepth 是权威下限,运行时字段只能加深计数、永不降低。原因是 resume 时子 agent 带着全新的 options 从持久化里醒过来,如果从零计数,一个曾经深居第 3 层的子 agent 复活后会把自己当顶层,递归预算就失效了。30 行还把运行时字段的非负 safe-integer 校验也放在这里,坏值在读数时一次性炸掉。这正是「状态如何继承」的第一个答案:深度写进 durable 头部,靠持久化数据传递,不靠对象结构。

子会话元数据:血缘数据

子 agent 的 session 要持久化哪些元数据?childSessionMeta 一次说清:

packages/subagent/subagent/src/child-agent.ts102:120
102export function childSessionMeta(103  parent: Agent,104  childDepth: number,105  lineageSeedLength: number,106): NonNullable<CreateAgentOptions['meta']> {107  const parentHeader = parent.session.header108  const agentPreset = parent.ctx.get('agentPresets')?.composedPreset(parent.ctx)109  return {    ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},    ...agentPreset === undefined ? {} : { agentPreset },·    parentSession: parentHeader.id,·    // Navigation classification only; the descriptor remains the authority·    // for mode and continuation capability.·    origin: 'subagent',·    // Durable: the recursion budget must survive persistence and resume.·    delegationDepth: childDepth,    ...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {},119  }120}

父 session 的 id 记进 parentSession,深度记进 delegationDepth,种子边界记进 seedLength。这三样就是「血缘数据」的载体,docs/glossary.md:21 把它们命名为 lineage,并明确写了一句:never affects visibility。血缘只回答「谁生的我、我在第几层、哪些事件来自父方」,不回答「我能看到什么工具」。可见性由另一套机制管,马上看。

组合与策略种子:scope 不继承

子 agent 创建时发生什么?applyChildCompositionpackages/subagent/subagent/src/child-agent.ts:163-175)做三件事:从父 context 组合 preset(composeFrom)、注入固定委托声明、应用可选的 persona 与 toolFilter。全部注册在子自己的 scope 里,父和兄弟不可见。这里引出一条系统提示贡献:

packages/subagent/subagent/src/child-agent.ts135:139
135export const SUBAGENT_DELEGATION_CONTEXT136  = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be '137    + 'widened from inside this session — operations that require approval are rejected automatically. '138    + 'When the task needs access beyond that scope, do not retry the denied operation; state the '139    + 'limitation in your reply so the delegating agent can handle it.'

这是给模型看的提示词声明:权限在启动时固定,不能再宽。它不是安全机制,是行为约定(注释 130 至 134 行也说明它是运行时上下文贡献而非系统提示词 section,为的是父子提示词保持统一)。

策略层面还有一重种子。委托发生时,父的沙箱覆盖和审批策略被捕获下来:

packages/subagent/subagent/src/child-agent.ts199:204
199export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {200  return {201    sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),202    approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never',203  }204}

关键在 202 行:只要部署组合了审批能力,子 agent 的审批策略就被钉成 'never'。子 agent 不会弹审批框,需要批准的操作用力也白搭。185 至 187 行的注释说清了理由:委托出去的 agent 只应在其启动时固定的范围内行动,所以它的 ask 被确定性拒绝。这一行为本身是授权语义吗?不是,见设计决策分析。

创建落地

以上各件套进 in-process driver 的创建事务:

packages/subagent/subagent-in-process-driver/src/index.ts106:139
106  assertSubagentMaxDepth(request.maxDepth)107  if (request.signal.aborted) throw prePublicationAbort()108  const parent = request.parent109  const childDepth = resolveChildDepth(parent, request.maxDepth)110111  const childId = SessionId(randomUUID())112  const seed = options.seed113  const activationBoundary = seed?.length ?? 0114115  // Capture before the first await: a later parent switch belongs to the116  // parent's future.117  const inherited = captureDelegatedPolicyOverrides(parent)118119  let structured: StructuredAttachment | undefined120  const setup = (childCtx: Context): void => {121    appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)122    applyChildComposition(childCtx, parent, {123      persona: request.persona,124      toolFilter: request.toolFilter,125    })126    if (request.outputSchema !== undefined) {127      structured = attachStructuredRuntime(childCtx, request.outputSchema)128    }129    attachDescriptorAppend(childCtx, request.descriptor)130  }131132  const handle = await parent.ctx.agents.create({133    sessionId: childId,134    meta: childSessionMeta(parent, childDepth, activationBoundary),    ...seed !== undefined ? { seed } : {},136    agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),137    signal: request.signal,138    setup,139  })

114 至 116 行的注释「Capture before the first await」值得注意:委托策略在第一次 await 之前同步捕获,之后父 agent 的任何切换都属于父的未来,不属于这个子 agent。setup 回调在 agent 发布之前执行(创建事务内部),保证没人能看到一个「先用父配置跑了半程,再用子配置」的中间态。一手材料 66 至 70 行把这条叫「Unpublished setup makes the first request correct」。

把上面整个过程画成时序,下面这张图回答:一次委托从发起到回收,谁在什么时候做什么。

sequenceDiagram participant Parent as 主 agent participant Subagents as subagent 服务 participant Child as 子 agent Parent->>Subagents: start name request Subagents->>Subagents: 能力校验与深度计算 Subagents->>Child: 创建并发布 Subagents-->>Parent: subagent/start 事件 Subagents->>Child: 投递 prompt Child-->>Subagents: 结果 Subagents-->>Parent: result 与 subagent/end 事件 Parent->>Child: dispose 回收

注意事件对 subagent/startsubagent/end 都是 observe-only:观察者能看到孩子出生和终结,拿不到运行控制权。

workflow:脚本编排的派活

subagent 是模型每回合按需派活;workflow 是模型写一段脚本,一次性声明「跑几个 agent、并行还是流水线」。脚本在 worker 线程里跑,每次 agent() 调用都桥回宿主开一个真子 agent。请求契约里 parent 是必填字段:

packages/workflow/workflow/src/runtime-types.ts19:34
19export interface WorkflowStartRequest {20  /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */21  script: string22  /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */23  meta: WorkflowMeta24  /** Optional input exposed verbatim to the script as the `args` global. */25  args?: unknown26  /** Optional engine-wide child-provider override for this run. */27  subagentProvider?: string28  /** Optional per-run total-child ceiling. */29  maxTotalAgents?: number30  /** The agent on whose behalf the run executes (parent of every child). */31  parent: Agent32  /** Cancels the run when aborted. */33  signal?: AbortSignal34}

注释点明:每次 agent() 派出的孩子都归属于这个 live parent。脚本看到的世界是几个冻结的函数:

packages/workflow/workflow-worker-thread/src/runtime.ts100:108
100    const globals: Record<string, unknown> = {101      agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)),102      parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),103      pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),104      phase: (title: unknown) => { this.phase(title) },105      log: (message: unknown) => { this.log(message) },106      // workerData already performed the real cross-thread structured clone.107      args,108    }

脚本在 vm context 里只有这五个入口,没有别的手段。跑飞了怎么办?引擎有总孩子数上限做兜底:

packages/workflow/workflow-worker-thread/src/runtime.ts256:261
256    if (this.started >= this.limits.maxTotalAgents) {257      throw new WorkflowError(258        `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`,259        'AGENT_CAP',260      )261    }

这是「不这样做会出事」的正面设计:脚本是模型生成的,一个死循环 for 里反复 agent() 会把宿主拖垮,所以每次调用都先查总上限(默认 1000,packages/workflow/workflow-worker-thread/src/index.ts:118 的 Config 定义)。为什么跑在 worker 线程里,模块头说得直白:

packages/workflow/workflow-worker-thread/src/index.ts1:7
1/**2 * Worker-thread workflow engine. Each run executes its model-written script in3 * an escapable vm context on a fresh worker and bridges `agent()` calls to host4 * subagents. The thread prevents synchronous script work from blocking the host5 * and permits forced termination, but it is containment rather than a security boundary.6 * @module @deepseek-ai/dsh-workflow-worker-thread7 */

三个判断:脚本同步计算不阻塞宿主、可以强制终止、但这是 containment,不是安全边界。安全边界的话不替它吹。

goal:不派活,挂状态

goal 与前面两种形态都不同。subagent 和 workflow 都派生新 agent;goal 挂在现有 session 上,不产生新 session。它的四相是 durable 状态:

packages/goal/goal/src/types.ts44:48
44export type GoalPhase =45  | 'active'46  | 'paused'47  | 'blocked'48  | 'complete'

每个 goal 还带轮次上限 maxGoalRounds。谁在推进它?goal-round-driver。它只在三种条件同时满足时开下一轮:进程 fiber 活跃、agent 空闲、没有竞争 prompt(packages/goal/goal-round-driver/src/index.ts:103-109)。轮次耗尽时它不做别的,直接把 goal 标成 blocked:

packages/goal/goal-round-driver/src/index.ts164:172
164    const goal = currentGoal(state)165    if (goal === undefined || goal.phase !== 'active' || goal.activation !== 'armed') return166    if (goal.roundsStarted >= goal.maxGoalRounds) {167      ctx.goals.block(agent, goalRef(goal), {168        code: 'round-limit',169        message: `Goal reached its configured limit of ${goal.maxGoalRounds} rounds.`,170      })171      return172    }

166 行的 block 带机器可路由的 code 和给人看的 message,对应 glossary 25 行「blocked retains a policy code and explanation」。

「自动续跑」还有个关键闸门:activation。它不进持久化。goal 服务从 session log 重建状态时,激活一律初始为 disarmed:

packages/goal/goal/src/index.ts420:447
420  /** Return the per-session cache, folding a seed once with activation disarmed. */421  private cache(session: Session): GoalCache {422    let cache = this.caches.get(session)423    if (cache !== undefined) return cache424    const state = emptyGoalFoldState()425    for (const event of session.events) applyGoalEvent(state, event)426    cache = {427      state,428      activation: 'disarmed',429      observedSeq: session.seq,430      pendingActivation: undefined,431    }432    this.caches.set(session, cache)433    return cache434  }435436  /** Incrementally observe durable events and reconcile local activation intent. */437  private sync(session: Session, cache: GoalCache): void {438    for (const event of session.events.slice(cache.observedSeq)) {439      applyGoalEvent(cache.state, event)440      if (event.type === 'goal/change') {441        cache.activation = cache.pendingActivation?.seq === event.seq442          ? cache.pendingActivation.activation443          : 'disarmed'444      }445      cache.observedSeq += 1446    }447  }

428 行:冷读 cache 时 activation 是 disarmed。441 至 443 行:每次 goal/change 事件落库后,激活意图只有在 pendingActivation.seq 恰好等于该事件 seq 时才保留,否则回到 disarmed。也就是说,回放只恢复 phase,不恢复「要不要自动跑」;想重新武装必须来一次显式的 resume 变更(/goal 命令或模型工具),这就是 glossary 27 行的「deliberately absent from durable replay」在代码里的样子。场景还原里「第二天它自己接着干」的疑问,答案在:这发生在同一进程内、activation 还武装着的窗口里;进程重启或 fork 之后,它必须先经人授权一次。

goal 的四相迁移画成状态图,下面这张图回答:phase 在什么操作下迁移,activation 在哪一步被武装或解除。

stateDiagram-v2 [*] --> active: create 并 armed active --> paused: pause 并 disarm active --> blocked: block 或轮次耗尽 paused --> active: resume 并 armed blocked --> active: resume 需先提升上限 active --> complete: complete 并 disarm complete --> [*]

paused 与 complete 也可以经 clear 进入墓碑态(packages/goal/goal/src/domain.ts:34-41 的 clear tombstone),图上省略了这条边。

设计决策分析

决策一:scope 不继承,lineage 数据代替 scope 结构

glossary 13 行给 scope 下的定义是两层扁平:贡献要么全局、要么归属唯一 scope key,scoped 注册不向下继承到 subagent;子树行为用 lineage 数据表达,绝不用 scope 结构。subagent.md 472 行把话说到位:每个孩子得到新的扁平 scope,父的注册一概不继承。

为什么不把 scope 做成树?一手材料 84 行给了一条直接理由:registration scope is flat by design,lifetime ownership does not imply visibility inheritance。设想反过来的设计:scope 按血缘树继承,子 agent 自动看到父的全部工具。后果有三层。第一,子树行为变得无法表达,你只能在「继承全部」和「什么都没有」之间选,任何定制都要造新的 scope 层级机制。第二,可见性与授权语义纠缠:一旦「能看到」来自结构继承,「看不到」就会被误读成「没权限」,这是「Visibility is not authority」note 开篇就否定的方向:

.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md72:76
72## Visibility is not authority7374These controls compose trusted same-process behavior; they do not authorize it. `toolFilter` changes the child view resolved by the tool registry, but it does not create a parent-to-child grant lattice, require a child to be a subset of its parent, sandbox plugins, or prevent code with another Cordis context from calling services directly.7576In particular, a child-local tool is added after the global filter and may be absent from the parent's view. A deny-only child also sees later global tools not named by the deny-list. Those are deliberate live-composition semantics, not non-escalation guarantees.

76 行那句话最直白:这些是刻意的实时组合语义,不是非升级保证。如果章节把 toolFilter 写成「越权保护」,就是在替它吹它没承诺的东西;真正的安全设计需要独立的授权表示、传播规则和执行时强制点(78 行)。第三,过滤与提示词会脱节:note 88 行说只藏 schema 不改执行,会让模型调用一个「提示词里不存在」的工具,出现 presentation/execution drift;所以 dsh 用同一个 resolver 同时管呈现与执行。

决策二:goal 是状态,不是调度器;activation 不进 replay

glossary 25 行明说:A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth。goal 的一切可持久事实都写在 session log 的 goal/change 事件里(packages/goal/goal/src/domain.ts:24-32 的全量快照变更)。activation 是进程内权限,刻意不进 replay。

不这样做会出什么事?如果 activation 也持久化,那么 resume 或 fork 一个 session 之后,进程恢复「武装」状态,goal-round-driver 在无人授权的情况下自动续跑。这个闸门守的边界是:自动工作必须能追溯到一次人类授权的变更。27 行原文是「resume and fork require a later human-authorized resume mutation through /goal or the model tool before automatic work」。配合 goal 工具的运行时授权(packages/goal/tool-goal/src/authority.ts:90-93requireDirectHuman),「谁批准了继续」从事件层可审计。

决策三:workflow 是 worker-thread provider

workflow 的 Service Definition 只有一个抽象方法 start()packages/workflow/workflow/src/index.ts:157-168),provider 由部署选择,默认实现是 worker-thread 引擎(packages/workflow/workflow-worker-thread/src/index.ts:112-122,inject 声明依赖 subagents)。引擎把模型写的脚本编译进 vm context,agent() 调用通过 host 桥回 subagent 服务。

不这样做会出事。脚本直接在宿主线程跑,一段 while(true) 同步代码就卡死整个 harness;没有独立线程,也无法在宽限期内强制终止一个不配合的脚本。反过来,线程隔离的代价也在模块头写明了:escapable vm context 加 worker 只是 containment,脚本仍是模型写的不可信文本,不能当沙箱用。engine 还把 workflow/* 事件做成 observe-only(packages/workflow/workflow/src/index.ts:36-89),观察者永远拿不到运行控制权,只能看进度。

边界条件剖析

追问一:一个 resume 回来的子 agent,深度会从零重新计数吗?

不会。resume 时子 agent 带着全新 options 醒来,运行时 subagentDepth 是 undefined;但持久化头部的 delegationDepth 还在,delegationDepthOfMath.max(persisted ?? 0, runtime ?? 0) 取到持久值(packages/subagent/subagent/src/depth.ts:29-31)。冷恢复时连运行时字段都省了:descriptor 刻意省略 subagentDepth,因为恢复信任头部里的持久深度作为单调下限(docs/subsystems/subagent.md:283)。注意 Math.max 的另一半:一个运行中 agent 显式传入更深的 subagentDepth,计数会加深,这覆盖「沙箱里嵌套再加一层」的宿主场景,但永不降低。

追问二:子 agent 想发起一个需要批准的操做,会怎样?

结果不是弹窗,是确定性拒绝。捕获策略时,只要宿主组合了审批服务,approvalPolicy 就被钉成 'never'packages/subagent/subagent/src/child-agent.ts:201-203),作为 approval/policy 事件追加进子的 session log(appendDelegatedPolicyOverrides,同文件 215-225 行)。同时系统提示里的委托声明告诉模型别重试被拒操作,改在回复里说明限制(135 至 139 行)。「拒绝」从决策点就固定下来,不是运行时临时判断。

追问三:goal 的轮次用完了,会一直空转吗?

不会。driver 的 drive()roundsStarted >= maxGoalRounds 时调 ctx.goals.block,goal 进入 blocked 相位并带 round-limit 码(packages/goal/goal-round-driver/src/index.ts:166-172)。blocked 后 driver 的启动条件(phase 必须 active)不再满足,165 行直接返回。模型侧后续的 goal 工具操作需要授权或先 resume,而这个 resume 在轮次耗尽时会被拒:packages/goal/goal/src/index.ts:321-327 检查 roundsStarted >= maxGoalRounds 后抛错,要求先提升上限。

追问四:人类 prompt 和 goal 自动续跑撞车,谁赢?

人类 prompt 赢。driver 的 readyToDrive 要求没有竞争排队(packages/goal/goal-round-driver/src/index.ts:103-109!state.competingQueued),任何非 goal 来源的入队都会设竞争标记(284-291 行)。更硬的一层在 agent/pre-step 钩子:driver 对已排队的 goal round 做 validReservation 校验,任何一环不对(fiber 不活跃、attempt 不匹配、goal 换过 revision、round 号错位)就拒绝进入 step(334-347 行)。同一轮被拒后,如果 goal 还 active 且 armed,直接 block 成 prompt-rejected(388-397 行)。这套「fail closed」保证自动续跑永远打不过真实的人类输入。

横向对比

同一问题的两种答案:subagent 委托与 workflow 派活

「怎么把活派出去」在 dsh 内部就有两种答案,两侧都是真实代码。

subagent 侧是模型驱动的逐次委托:模型调工具,工具层组装 SubagentStartRequest,服务层校验能力后由 provider 建一个子 agent(packages/subagent/subagent/src/index.ts:430-442);孩子把结果以 SubagentResult 带回(packages/subagent/subagent/src/types.ts:219-245)。决策点分散在模型每个回合,模型每一步都可以决定派、不派、派几个。代价是模型要自己负责编排,稍不留神就嵌套过深,所以才有 maxDepth 硬闸(packages/subagent/subagent/src/child-agent.ts:53-54)。

workflow 侧是脚本驱动的确定性编排:模型写一段脚本,agent()parallel()pipeline() 是仅有的出口(packages/workflow/workflow-worker-thread/src/runtime.ts:100-108);编排结构写死在脚本里,一次启动后由引擎执行,parallel() 内部用 FIFO 槽位限制并发(227-247 行)。代价是脚本本身要一次写对:语法错在 start() 同步阶段就抛 SCRIPT_PARSEpackages/workflow/workflow-worker-thread/src/index.ts:64-74),运行期跑飞由 AGENT_CAP 之类致命错误兜底,combinator 遇致命错误必须上抛而不是把条目置 null(packages/workflow/workflow/src/index.ts:130-139fatal 标志与 146-148 行的 isFatalWorkflowError)。

两种答案的代价不同:模型驱动的答案灵活但把责任压在模型身上,脚本驱动的答案确定但要求一次写对。dsh 两个都保留,因为消费场景不同:交互式对话里模型自己判断派不派,长任务里脚本把并行结构显式化。

对比语料侧:claude-code 的 Task tool / AgentTool

对比语料(anthropics/claude-code,commit 45bdfa96)的公开仓库只有 plugins、examples、CHANGELOG 和 scripts,主产品二进制不在仓库内,Task tool 与 AgentTool 的实现源码未找到对应实现,检索关键词为:Task tool、AgentTool、subagent_type、CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH、CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS。行为记录在 CHANGELOG 里是公开的,挑三条与本章主题对得上。

其一,权限继承的默认值不同。claude-code/CHANGELOG.md:751

claude-code/CHANGELOG.md751:751
751- Deprecated the Task tool's `mode` parameter (now ignored); subagents inherit the parent session's permission mode by default

claude-code 侧子 agent 默认继承父会话的权限模式;dsh 侧相反,委托策略种子把审批钉成 'never'packages/subagent/subagent/src/child-agent.ts:202),子 agent 只能在启动时固定的范围里行动。两个默认值各自成立,因为两侧的信任模型不同;这条差异属于「dsh 主动改的还是上游后来改的」拿不准的部分,按推断处理。

其二,深度跟踪的修正记录。claude-code/CHANGELOG.md:1231 记录「resumed subagents now restore their original spawn depth, and forked subagents now count toward the depth cap」,与 dsh 的持久化单调深度(packages/subagent/subagent/src/depth.ts:29-36)是同一个问题的两种解法:上游在运行时跟踪修复,dsh 把深度写进 durable header 从源头避免。还有 547 行(嵌套默认深度 3,曾是 1)与 608 行(一度默认不许嵌套)的来回,对比 dsh 的 maxDepth 是每次请求显式携带、部署默认 3(note 56 行的 tool 配置默认值),深度策略不藏在环境变量默认里。

其三,公开仓库里能直接读到的 agent 定义格式。claude-code/plugins/pr-review-toolkit/agents/code-reviewer.md:1-6 展示了一个 agent 就是带 name、description、model、color frontmatter 的 markdown,description 里明确教模型何时用 Task tool 去启动它。dsh 侧没有对应物:子 agent 是运行时构造的 session,配置走 persona 与工具过滤而非 md 文件,模型侧感知入口是 dsh-tool-subagent 的委托工具。两种「agent 形态」设计各答各的问题,md 文件便宜直观,运行时构造能按需组合。

互动演示设计

形态:模拟器,主题「subagent 树与 scope 图」。

一句话结论:子 agent 从父那里带走的只有三样数据(血缘、深度、策略种子),scope 的树是假的,血缘的线才是真的。

舞台元素:左侧一棵倒置的树,根节点是主 agent,下面三个子 agent 卡片,卡片旁标「depth 1」;右侧两根平行的柱子,一根标「全局层」贯穿所有 agent,另一根由三段组成,每段标一个 agent 名,代表各自的 scoped 层。树与柱之间用虚线连「血缘线」,线旁标注 parentSession 与 delegationDepth 两个字段名。生活化比喻:subagent 是外包的小组,公司制度(全局工具)人人适用,小组内部规定(scoped 注册)只在本组生效,组长的委派单(lineage 数据)记着谁派的、第几层外包。

分步与字幕文案:

  1. 主 agent 向树外伸出一根线,字幕:「模型调用 subagent 工具,先验能力,再派活」。轨迹面板亮 start() 行。
  2. 线末端长出新节点,深度标签从 0 变 1,字幕:「深度 = 父深度 + 1,写进持久化头部」。轨迹面板亮 resolveChildDepth 行。
  3. 新节点的柱子出现第三段 scoped 层,字幕:「孩子拿到新的扁平 scope,父的注册一个不继承」。轨迹面板亮 applyChildComposition 行。
  4. 委派单从父飞向子,上面列出 parentSession、delegationDepth、seedLength,字幕:「血缘数据三件套,状态继承就靠这三样」。轨迹面板亮 childSessionMeta 行。
  5. 树的第二层又伸出两根线,字幕:「孩子还能再派孩子,深度继续加一,直到撞上 maxDepth 上限」。轨迹面板亮 resolveChildDepth 的抛错分支。
  6. 三张卡片依次变灰回收,字幕:「run 结束,subagent/end 事件按父派发,树收回」。轨迹面板亮 observeRun 行。

逻辑轨迹面板伪代码,一行一句白话,右侧标真实行号,随步进高亮:

text
start(name, request)                        // 派活入口:先查 provider 再校验能力
  assertCapabilities(provider, request)     // 你要的能力 provider 声明过吗,没有就拒
  childDepth = delegationDepthOf(parent)+1  // 父深度加一,头部持久值打底
  if childDepth > maxDepth then throw       // 撞上限直接抛 SubagentDepthError
  meta = { parentSession, delegationDepth } // 血缘三件套写进子会话元数据
  setup(childCtx)                           // 发布前组合:策略种子 + 新扁平 scope
  ctx.agents.create({ meta, setup })        // 创建事务,发布即生效

对应真实行号:packages/subagent/subagent/src/index.ts:430(start)、packages/subagent/subagent/src/index.ts:497(assertCapabilities)、packages/subagent/subagent/src/child-agent.ts:49(childDepth)、packages/subagent/subagent/src/child-agent.ts:53(抛错分支)、packages/subagent/subagent/src/child-agent.ts:112(parentSession)、packages/subagent/subagent-in-process-driver/src/index.ts:121(setup)、packages/subagent/subagent-in-process-driver/src/index.ts:132(agents.create)。

读者操作:拖动根节点旁一个「maxDepth」滑块,观察第二层子节点在撞线时变红并弹出一条错误气泡;点开任意子节点,展开它的 scoped 层清单,逐一对比与父的 scoped 层清单的差异,看到「一个都不继承」。

可迁移结论

第一,派活接口把「父是谁」做成必填字段。WorkflowStartRequest.parent 注释写「the agent on whose behalf the run executes」(packages/workflow/workflow/src/runtime-types.ts:31),subagent 请求里 parent 也是必填(packages/subagent/subagent/src/types.ts:106-109)。你自己做多智能体系统时,任何派生接口都该显式携带父身份,调用栈猜不出血缘。最小形态:一个 spawn(parentId, spec) 函数签名。

第二,这条不依赖 TypeScript:上下文继承用「数据快照加单调计数器」,不用对象树结构继承。dsh 的 lineage 三件套(parentSession、delegationDepth、seedLength)就是三个字段;你给多进程任务系统设计子任务上下文时,同样可以把「父任务 id、层级计数、继承的配置快照」作为任务记录里的三列,从数据库读出来就能重建血缘,不依赖任何运行时对象图。计数器的单调性(只能加深不能降低)在别的语言里同样成立,持久化在数据库里天然满足。

第三,自动续跑必须过一道显式授权门。activation 不进 replay 的设计(packages/goal/goal/src/index.ts:441-443)可以搬到你自己的定时任务系统里:把「该不该继续」与「能不能继续」分开存,前者持久化、后者每次进程启动时重置,续跑前要一次人类确认。最小形态:任务表里加一列 resume_armed,进程重启时一律清零。

思考题

  1. 读代码题:resolveChildDepth 会抛两种异常(SubagentDepthErrorRangeError)。追一下 assertSubagentMaxDepthpackages/subagent/subagent/src/depth.ts:38-51)在调用链上的位置,说明为什么 start() 还要单独调它一次,而 resolveChildDepth 里也要再算一遍。

  2. 动手验证题:把 goal-round-driver 的轮次上限调小,观察自动 block。改 examples/ 下某个 demo 的 cordis.yml,给 goal 配置里设一个很小的 maxGoalRounds,跑一轮看 session log 里新增的 goal/change 事件,确认 operation: 'block' 且 reason.code 为 round-limit(对照 packages/goal/goal-round-driver/src/index.ts:166-172)。再试一个进程重启后再续跑的场景,确认重启后 goal 停在 blocked 或 paused,不会自己恢复 active。

  3. 设计题:如果要把 scope 改成「子 agent 默认继承父的 scoped 工具」,对照 note 84 行「registration scope is flat by design」的理由,列出至少两个会破坏的现有语义,并说明你会为它引入什么新机制来保持「visibility 与 authority 分离」。