第 11 章
委托与并行:subagent、workflow、goal
场景还原
你在 IDE 里对一个 agent 说:调研三个开源库,各写一份评估。它没有逐条汇报,而是转身开出了三个新 agent 卡片,各领一份。你盯着卡片上的「subagent」字样,冒出一串问题:这三个小家伙和主 agent 是什么关系?它们继承了我的对话、我的工具、我的权限吗?如果我中途改了主 agent 的配置,它们会跟着变吗?
再看另一个场景。你想让 agent 每天跟进一个长期任务,于是挂了一个目标;第二天它居然自己接着干,但你记得自己并没有点过「继续」。这是怎么回事,谁允许它继续的?
这一章回答一个核心问题:一个 agent 如何派生另一个 agent,状态如何继承与隔离。答案分三种形态。subagent 是模型按需开出的子 agent,一问一答跑完即走;workflow 是脚本驱动的编排,一个脚本可以并行派出一批子 agent;goal 根本不派活,它给现有 session 挂一个持久目标,让同一会话自己在空闲时续跑。三种形态共用同一套状态哲学:派生出来的东西,带走的只有明确列出的数据,别的都不继承。
逐行精读
这一章走三条路,先看整体分工。下面这张图回答:subagent、workflow、goal 在「派活」上的位置各是什么。
subagent 与 workflow 都派生新 agent,区别在决策点位置;goal 不派生,只给现有 session 挂状态。下面逐个精读。
委托的入口:先验能力,再派活
先看一次委托在服务层的入口。SubagentRuntime.start() 是模型侧工具调用的落点,它在调用 provider 之前做完所有校验:
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 自己声明的:
86export interface SubagentCapabilities {87 readonly outputSchema: boolean88 readonly depthLimit: boolean89 readonly toolFilter: boolean90 readonly persona: boolean91}assertCapabilities(packages/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:
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:
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 一次说清:
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 创建时发生什么?applyChildComposition(packages/subagent/subagent/src/child-agent.ts:163-175)做三件事:从父 context 组合 preset(composeFrom)、注入固定委托声明、应用可选的 persona 与 toolFilter。全部注册在子自己的 scope 里,父和兄弟不可见。这里引出一条系统提示贡献:
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,为的是父子提示词保持统一)。
策略层面还有一重种子。委托发生时,父的沙箱覆盖和审批策略被捕获下来:
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 的创建事务:
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」。
把上面整个过程画成时序,下面这张图回答:一次委托从发起到回收,谁在什么时候做什么。
注意事件对 subagent/start 与 subagent/end 都是 observe-only:观察者能看到孩子出生和终结,拿不到运行控制权。
workflow:脚本编排的派活
subagent 是模型每回合按需派活;workflow 是模型写一段脚本,一次性声明「跑几个 agent、并行还是流水线」。脚本在 worker 线程里跑,每次 agent() 调用都桥回宿主开一个真子 agent。请求契约里 parent 是必填字段:
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。脚本看到的世界是几个冻结的函数:
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 里只有这五个入口,没有别的手段。跑飞了怎么办?引擎有总孩子数上限做兜底:
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 线程里,模块头说得直白:
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 状态:
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:
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:
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 在哪一步被武装或解除。
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 开篇就否定的方向:
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-93 的 requireDirectHuman),「谁批准了继续」从事件层可审计。
决策三: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 还在,delegationDepthOf 的 Math.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_PARSE(packages/workflow/workflow-worker-thread/src/index.ts:64-74),运行期跑飞由 AGENT_CAP 之类致命错误兜底,combinator 遇致命错误必须上抛而不是把条目置 null(packages/workflow/workflow/src/index.ts:130-139 的 fatal 标志与 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:
751- Deprecated the Task tool's `mode` parameter (now ignored); subagents inherit the parent session's permission mode by defaultclaude-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 数据)记着谁派的、第几层外包。
分步与字幕文案:
- 主 agent 向树外伸出一根线,字幕:「模型调用 subagent 工具,先验能力,再派活」。轨迹面板亮
start()行。 - 线末端长出新节点,深度标签从 0 变 1,字幕:「深度 = 父深度 + 1,写进持久化头部」。轨迹面板亮
resolveChildDepth行。 - 新节点的柱子出现第三段 scoped 层,字幕:「孩子拿到新的扁平 scope,父的注册一个不继承」。轨迹面板亮
applyChildComposition行。 - 委派单从父飞向子,上面列出 parentSession、delegationDepth、seedLength,字幕:「血缘数据三件套,状态继承就靠这三样」。轨迹面板亮
childSessionMeta行。 - 树的第二层又伸出两根线,字幕:「孩子还能再派孩子,深度继续加一,直到撞上 maxDepth 上限」。轨迹面板亮
resolveChildDepth的抛错分支。 - 三张卡片依次变灰回收,字幕:「run 结束,subagent/end 事件按父派发,树收回」。轨迹面板亮
observeRun行。
逻辑轨迹面板伪代码,一行一句白话,右侧标真实行号,随步进高亮:
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,进程重启时一律清零。
思考题
-
读代码题:
resolveChildDepth会抛两种异常(SubagentDepthError与RangeError)。追一下assertSubagentMaxDepth(packages/subagent/subagent/src/depth.ts:38-51)在调用链上的位置,说明为什么start()还要单独调它一次,而resolveChildDepth里也要再算一遍。 -
动手验证题:把 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。 -
设计题:如果要把 scope 改成「子 agent 默认继承父的 scoped 工具」,对照 note 84 行「registration scope is flat by design」的理由,列出至少两个会破坏的现有语义,并说明你会为它引入什么新机制来保持「visibility 与 authority 分离」。