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

第 5 章

系统提示与工具 schema 组装

源码核对基于 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`本章字数:约 10000 字(不含代码与图)

场景还原

你在一份部署里给子 agent 定了两件事:把它自己的角色说明换上去,工具只留只读的三件。第一轮请求发出去,你在请求体里看到三件怪事。

第一件,persona 没换。提示词开头还是部署 persona,子 agent 自己的角色说明没出现。第二件,工具没过滤。schema 里躺着父 agent 的 Bash,你配的 allow 名单像没生效。第三件最隐蔽:等前两件修好,子 agent 自己注册的 structured_output 工具从它的 schema 里消失了。

三个症状指向同一个根因:一份提示词和一份 tool schema,在每一轮请求之前,是从几十个插件的注册里临时拼出来的;拼出什么,取决于这一轮是给哪个 agent 的。注册时你写的是「我贡献一段提示词」,执行时才回答它属于谁、和谁同名、被谁过滤。本章把这台组装机拆开:注册怎么挂上作用域,同名怎么覆盖,过滤怎么只删继承面。

图 1 是这台组装机的全景:左边是注册面,中间是组装,右边是两份产物,回答几十个插件的注册怎么变成一份提示词和一份 schema。

flowchart LR A[agent.ctx 注册] --> L[ScopedLayers 层表] L --> C[assemble 组装] C --> V[变量 最近者胜] C --> N[section 同名覆盖] C --> T[工具提供者收集] T --> D[structuredClone 参数] D --> O[orderTools 排序] N --> M[PromptAssembly] V --> M O --> M M --> W[waterfall 组装事件] W --> R[renderPrompt 插值] W --> S[schemaOf 白名单投影] R --> Q[模型请求] S --> Q

逐行精读

注册面:一次注册,两件事同时定下来

每个 Agent 对象构造时给自己铸一个作用域,把自身塞进上下文。

packages/core/agent-loop/src/agent.ts94:96
94    this.scope = createScope(loopCtx, this)95    this.ctx = this.scope.ctx.extend({ agent: this })96    this.runtimeContext = new RuntimeContextProjection(this.ctx, session)

createScope 在 scope 包里:给了 parent 就绑定父链;起一个专属 fiber;把 key 写进新 ctx 的一个 Symbol 标签。

packages/core/scope/src/index.ts137:147
137export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {138  if (options?.parent !== undefined) bindScopeParent(key, options.parent)139  const fiber = ctx.plugin(scope)140  const scoped: Context = fiber.ctx.extend({ [kScope]: key })141  let disposing: Promise<void> | undefined142  return {143    ctx: scoped,144    rawDispose: fiber.dispose,145    dispose: () => (disposing ??= quiesceFiber(fiber)),146  }147}

kScope 是私有 Symbol,普通代码读不到,只有 scope 包自己的函数认它。读取函数就三行:

packages/core/scope/src/index.ts154:156
154export function scopeOf(ctx: Context): ScopeKey | undefined {155  return (ctx as Context & { [kScope]?: ScopeKey })[kScope]156}

SystemPrompt.section 的实现:

packages/core/system-prompt/src/index.ts381:390
381  section(section: PromptSection): () => void {382    if (!Number.isFinite(section.order)) {383      throw new TypeError(`prompt section "${section.name}" order must be a finite number`)384    }385    return this.layers.effect(386      this.ctx,387      layer => layer.sections.insert(section.name, section),388      { label: 'systemPrompt.section()' },389    )390  }

注册代码不区分全局和某个 agent:this.ctx 交给 layers.effect,由 effect 在运行时读 scopeOf(ctx) 决定层归属。「注册时不知道给谁,执行时才回答」就落在这里。effect 的完整实现:

packages/core/scope/src/store.ts226:266
226  effect(227    ctx: Context,228    action: (layer: L) => () => void,229    options: { label: string; notify?: boolean },230  ): () => void {231    const scope = scopeOf(ctx)232    const notify = options.notify ?? true233    const dispose = ctx.effect(function* (this: ScopedLayers<L>) {234      let layer: L235      let created = false236      if (scope === undefined) {237        layer = this.global238      } else {239        const existing = this.scoped.get(scope)240        if (existing === undefined) {241          layer = this.createLayer(scope)242          this.scoped.set(scope, layer)243          created = true244        } else {245          layer = existing246        }247      }248249      let undo: () => void250      try {251        undo = action(layer)252      } catch (error) {253        if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)254        throw error255      }256257      yield () => {258        undo()259        if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)260        if (notify) this.onChange()261      }262      if (notify) this.onChange()263    }.bind(this), options.label)264    // oxlint-disable-next-line typescript/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity265    return dispose266  }

231 行 scopeOf(ctx):undefined 走 global 层(236-237 行),有 key 取对应 scoped 层,没有就建(240-243 行)。249-255 行把 action 包进 try:插入抛错时,刚建的层若空就当场删掉(253 行)。257-261 行是撤销体:先 undo,层空就回收,再通知 onChange;262 行注册成功也通知一次。SystemPrompt 构造 layers 时传的 onChange(347-350 行)是 () => { this.ctx.emit('system-prompt/change') },每次注册、撤销都广播「提示词变了」。

一次 ctx.effect 同时决定可见性(层归属)和生命周期(disposer 归属),层空了自动回收。术语表第 15 行:「registrations through it are scope-visible AND scope-lifetime (one fact drives both)」。

作用域链往哪挂?bindScopeParent 把 agent 的 key 挂到它 preset 的 standing 组合上,子 agent 继承的正是这个组合,注释写得很直白:

packages/preset/agent-presets/src/index.ts293:300
293   * This is how a child agent inherits its parent's capabilities. It is a bind,294   * not a mount: the parent's generation is already composed, so the child gets295   * that exact instance — the same plugin objects, the same tool registrations,296   * the same prompt sections. Re-resolving the parent's preset by id instead297   * would re-read the roster, and a composition file edited since the parent298   * started would hand the child a DIFFERENT generation than the one its299   * parent's history was produced under (and a preset deleted since would fail300   * the child outright while its parent keeps running).

子 agent 继承父 agent 的 preset 组合,但拿不到父 agent 自己 agent.ctx 上的注册(比如父 agent 自己的 persona)。这是场景里「persona 没换」的另一半答案:注册要么挂在父 agent 自己的 scope 上,链上看不到;要么挂在全局或 standing 层,子 agent 才看得见。

入口:每个 step 之前

ReactLoopAgent 的 preStep 先 claim 一条输入,然后组装:

packages/core/agent-loop/src/agent.ts225:243
225  private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {226    /* v8 ignore next -- private callers establish the running phase before proposing a step */227    if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)228    const signal = this.phase.abort.signal229    const claimed = this.inbox.claim(target, position.turn)230    const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))231    signal.throwIfAborted()232    const sections = renderContextSections(assembly)233    const context = this.runtimeContext.project(joinContextSections(sections), sections)234    const decision = await this.dispatch.waterfall(235      'agent/pre-step', { messages: claimed, ...position, signal },236      (): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({237        kind: 'enter',238        messages: context === undefined ? claimed : [...claimed, context],239      }),240    )241    signal.throwIfAborted()242    return decision.kind === 'reject' ? decision : { ...decision, assembly }243  }

230 行是组装入口。assemble 的上下文由 assembleContextFor 构造,把 agent 和 scope 绑在一起传进去:

packages/core/agent/src/dispatch.ts167:176
167/**168 * Build the prompt assembly context with agent and scope set together, so169 * agent-scoped prompt and tool contributions cannot be silently omitted.170 * @param agent - the agent the assembly is for.171 * @param signal - the current turn's explicit control signal, when assembly belongs to a turn.172 * @returns the context to pass to `assemble()`.173 */174export function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext {175  return { agent, scope: agent, ...signal === undefined ? {} : { signal } }176}

175 行:scope 就是 agent 自己。232 行渲染动态上下文段,233 行投影成模型可见的快照。真正执行 step 时提示词和工具才定型:agent.ts 337 行 renderPrompt(assembly),341 行 assembly.tools 交给 buildRequest。每个 step 重新组装一次,热注册由此落地。

assemble:层叠拍平成一份清单

assemble 是本章的心脏,分两段读,第一段是收集:

packages/core/system-prompt/src/index.ts467:503
467  async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {468    const scope = context.scope469    const scopeLayers = this.layers.chainLayers(scope)470    const runtimeContextSuppressed = !this.layers.global.runtimeContextSuppressors.isEmpty()471      || scopeLayers.some(layer => !layer.runtimeContextSuppressors.isEmpty())472    // Scoped variables shadow globals.473    const variables: Record<string, string | undefined> = {}474    for (const [name, provider] of this.layers.global.variables.entries()) {475      variables[name] = provider(context)476    }477    // Scope-chain variables, farthest first, so the nearest scope wins a name.478    for (const layer of scopeLayers) {479      for (const [name, provider] of layer.variables.entries()) {480        variables[name] = provider(context)481      }482    }483    // Scoped sections shadow globals before the stable order sort.484    const sectionByName = this.layers.merge(scope, layer => layer.sections)485    const contextByName = this.layers.merge(scope, layer => layer.contexts)486    // Validate order against pre-restriction names while collecting visible schemas.487    const providers = [      ...this.layers.global.toolProviders.values(),      ...scopeLayers.flatMap(layer => [...layer.toolProviders.values()]),490    ]491    const collected: ToolSchema[] = []492    const knownNames = new Set<string>()493    for (const provider of providers) {494      const result = provider(context)495      const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({496        name,497        description,498        parameters: structuredClone(parameters),499      }))500      const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)501      collected.push(...schemas)502      for (const name of acceptedKnownNames) knownNames.add(name)503    }

469 行 chainLayers 取作用域链上的层(由远及近)。473-482 行变量先填全局,再沿链从远到近覆盖。484-485 行 merge 出 section 与 context 的名字表。487-503 行把所有工具提供者按 global 在前、scoped 在后的顺序求值,每个 parameters 都 structuredClone(498 行),收集之后谁改参数对象都污染不了副本。

merge 的语义在 store.ts:

packages/core/scope/src/store.ts208:217
208  merge<V>(209    scope: ScopeKey | undefined,210    pick: (layer: L) => NamedEntries<V>,211  ): Map<string, V> {212    const merged = new Map(pick(this.global).entries())213    for (const layer of this.chainLayers(scope)) {214      for (const [name, value] of pick(layer).entries()) merged.set(name, value)215    }216    return merged217  }

212 行先装全局,213-215 行沿链由远及近 set:同名就覆盖,最近者胜。这就是 shadowing 的完整实现,Map 的语义天然是后者覆盖前者。

第二段是排序与收尾:

packages/core/system-prompt/src/index.ts504:541
504    const sectionDefinitions = [...sectionByName.values()].sort((a, b) => a.order - b.order)505    const completeSections = sectionDefinitions.filter(section => section.complete === true)506    if (completeSections.length > 1) {507      throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`)508    }509    let completeSection: AssembledSection | undefined510    const sections = sectionDefinitions511      .map((section) => {512        const assembled = {513          name: section.name,514          text: typeof section.text === 'function' ? section.text(context) : section.text,515        }516        if (section.complete === true) completeSection = { ...assembled }517        return assembled518      })519    const assembly: PromptAssembly = {520      sections,521      contexts: runtimeContextSuppressed522        ? []523        : [...contextByName.values()]524          .sort((a, b) => a.order - b.order)525          .map(entry => ({526            name: entry.name,527            text: typeof entry.text === 'function' ? entry.text(context) : entry.text,528          })),529      tools: orderTools(collected, this.toolOrder, knownNames),530      variables,531    }532    const transformed = await this.ctx.waterfall(533      scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,534      () => Promise.resolve(assembly),535    )536    if (completeSection === undefined && !runtimeContextSuppressed) return transformed537    return {      ...transformed,539      sections: completeSection === undefined ? transformed.sections : [completeSection],540      contexts: runtimeContextSuppressed ? [] : transformed.contexts,541    }

504 行按 order 排序,约定写在 PromptSection 注释里(57-59 行):-100 是 harness 身份段,0 是 persona,工具指引用 100-199。505-508 行查 complete 段,多于一个直接抛错。519-531 行拼出 PromptAssembly。532-535 行是关键一步:scopeTarget(this, scope) 包装 subject 发 waterfall,路由机制下一节展开。536-541 行收尾:complete 段在 waterfall 之后恢复为唯一段,runtimeContext 被抑制时清空 contexts。恢复放在 waterfall 之后(24-26 行注释),listeners 不能靠这轮 waterfall 改写 complete 段的最终形态。

渲染:{{变量}} 严格插值

assembly 里的 section 文本还带着 {{variable}}。渲染函数:

packages/core/system-prompt/src/index.ts212:217
212export function renderPrompt(assembly: PromptAssembly): string {213  return assembly.sections214    .map(section => interpolate(section, assembly.variables, 'section'))215    .filter(text => text.length > 0)216    .join('\n\n')217}

每段插值,空的丢掉,其余用空行连接。动态上下文快照有自己的组装:

packages/core/system-prompt/src/index.ts236:240
236export function joinContextSections(sections: readonly ContextSnapshotSection[]): string {237  const body = sections.map(section => section.text).join('\n\n')238  if (body.length === 0) return ''239  return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`240}

239 行那句固定文案是模型可见文本:每次快照替换上一份。interpolate 是严格插值:

packages/core/system-prompt/src/index.ts258:295
258function interpolate(259  input: AssembledSection | AssembledContext,260  variables: Record<string, string | undefined>,261  kind: 'section' | 'context',262): string {263  const text = input.text264  let result = ''265  let last = 0266  for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {267    const group = GROUP_AT.exec(text.slice(open))268    if (group === null) {269      // A later closing brace makes this malformed; otherwise it is literal prose.270      if (text.indexOf('}}', open + 2) >= 0) {271        throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in ${kind} "${input.name}" (references are complete simple {{name}} groups)`)272      }273      result += text.slice(last, open + 2)274      last = open + 2275      continue276    }277    // `{{}}` yields an empty name and follows the malformed-reference path.278    const name = group[0].slice(2, -2)279    if (!VARIABLE_NAME.test(name)) {280      throw new Error(`malformed prompt variable reference "{{${name}}}" in ${kind} "${input.name}" (variable names match ${String(VARIABLE_NAME)})`)281    }282    // Do not resolve unregistered names through Object.prototype.283    if (!Object.hasOwn(variables, name)) {284      const known = Object.keys(variables)285      throw new Error(`unknown prompt variable "{{${name}}}" in ${kind} "${input.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)286    }287    const value = variables[name]288    if (value === undefined) {289      throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (${kind} "${input.name}")`)290    }291    result += text.slice(last, open) + value292    last = open + group[0].length293  }294  return result + text.slice(last)295}

四个分支。269-276 行:{{ 后面没有 }} 且整个文本里也没有闭合括号,当字面文本,否则报 malformed。279-281 行名字不合规报错。283-286 行查变量表用 Object.hasOwn:不查原型链,一个未注册的名字若用普通属性读取,会撞上继承属性,把不存在的变量当成有值。287-290 行 provider 返回 undefined 也报错。全部失败都抛错,没有静默跳过。代价是一个笔误让整个 step 失败,收益是模型永远不会看到 {{}} 残骸。

工具侧:先过滤继承面,own 层豁免

工具可见性由 ToolRuntime.view 一锤定音。先看三个类型:

packages/core/tools/src/index.ts680:701
680export interface ToolRestriction {681  /** Global tool names that stay visible; everything else is removed. */682  readonly allow?: readonly string[]683  /** Global tool names removed from visibility. */684  readonly deny?: readonly string[]685}686687/** One restriction compiled at registration for repeated live-global lookup. */688interface CompiledToolRestriction {689  readonly allow?: ReadonlySet<string>690  readonly deny?: ReadonlySet<string>691}692693/** One scope's complete registry view, derived in a single layer traversal. */694interface ToolView {695  /** Visible definitions after restrictions, scoped shadowing, and transport insertion. */696  readonly visible: ReadonlyMap<string, ToolDefinition>697  /** Pre-restriction capability names used by prompt-order validation. */698  readonly knownNames: ReadonlySet<string>699  /** Current global names that a scoped restriction may name. */700  readonly restrictableNames: ReadonlySet<string>701}

ToolRestriction 是用户写的过滤条件(allow/deny 数组),CompiledToolRestriction 是注册时编译成 Set 的形态,ToolView 是一次遍历算出的完整视图。restrict 的注册面:

packages/core/tools/src/index.ts1071:1098
1071  restrict(filter: ToolRestriction): () => void {1072    const scope = scopeOf(this.ctx)1073    if (scope === undefined) {1074      throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')1075    }1076    const allow = filter.allow1077    const deny = filter.deny1078    if (allow === undefined && deny === undefined) {1079      throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)')1080    }1081    const compiled: CompiledToolRestriction = {      ...allow !== undefined ? { allow: new Set(allow) } : {},      ...deny !== undefined ? { deny: new Set(deny) } : {},1084    }1085    if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {1086      throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)1087    }1088    const known = this.view(scope).restrictableNames1089    const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))1090    if (unknown.length > 0) {1091      throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)1092    }1093    return this.layers.effect(1094      this.ctx,1095      layer => layer.restrictions.append(compiled),1096      { label: 'tools.restrict()' },1097    )1098  }

1073-1074 行:restrict 拒绝无作用域的上下文,全局过滤会遮住所有 agent。1078-1080 行:空 filter 直接抛错。1085-1087 行:run_code 是保留的展示 transport,插件不许碰。1088-1091 行:过滤里出现未注册的工具名直接抛错,拼写错误不会静默失效。

view 是解析端:

packages/core/tools/src/index.ts1152:1193
1152  private view(scope?: ScopeKey): ToolView {1153    // Scope-chain layers, farthest ancestor first, the exact scope last.1154    const layers = this.layers.chainLayers(scope)1155    // Chain-blind on purpose: this is the ONE layer whose registrations the1156    // scope owns rather than inherits, and it is absent until the scope1157    // contributes something.1158    const own = this.layers.peek(scope)1159    // Inherited surface, nearest ancestor last: a nearer scope's same-name1160    // entry shadows a farther one, and the global layer is the farthest.1161    const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())1162    for (const layer of layers) {1163      if (layer === own) continue1164      for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)1165    }1166    const visible = new Map<string, ToolDefinition>()1167    const knownNames = new Set<string>()1168    const restrictableNames = new Set<string>()1169    for (const [name, definition] of inherited) {1170      knownNames.add(name)1171      restrictableNames.add(name)1172      // Restrictions intersect across the whole chain: any scope on it may1173      // mask an inherited name for everything nested inside it.1174      if (layers.every(layer => layer.admits(name))) visible.set(name, definition)1175    }1176    // The scope's own registrations last, shadowing an inherited name and1177    // outside the filter above.1178    if (own !== undefined) {1179      for (const [name, definition] of own.tools.entries()) {1180        knownNames.add(name)1181        visible.set(name, definition)1182      }1183    }1184    // Presentation infrastructure is resolved last and outside capability1185    // filtering. Registration rejects this reserved name, so the insertion is1186    // an invariant assertion as well as protection against future layer1187    // changes. Per scope: a native agent must not find `run_code` in its1188    // dispatch table because some other agent in the process presents it.1189    if (this.modeFor(scope) !== 'native') {1190      visible.set(RUN_CODE_NAME, this.requireCodeTransport())1191    }1192    return { visible, knownNames, restrictableNames }1193  }

1161-1165 行先合成继承面:global 打底,链上每层同名覆盖它。1169-1174 行过滤:继承面里每个名字都要链上所有层 admits 才可见。1176-1183 行是 own 层的豁免:自己注册的工具直接进 visible,不受 filter 约束。1189-1191 行补 run_code。

为什么 own 层要豁免?1137-1142 行那段注释讲了原因:delegation runtime 把子 agent 的 reporting 和 structured-output 工具注册进子 agent 自己的层,过滤器若连自己的层一起删,子 agent 就没有应答机制了。1145-1147 行的注脚补充:把豁免面理解成「global 层」,在预设把工具挪到 agent 平面之后就错了。

图 3 把 view 的决策过程画出来,回答:一个工具名最终进不进某个 agent 的 schema。

flowchart TD A[view 该 agent 作用域] --> B[global 工具入 inherited] B --> C[链上祖先层同名覆盖] C --> D{链上所有层都 admits} D -- 否 --> E[从 visible 移除] D -- 是 --> F[进入 visible] B --> G[own 层注册后加] G --> H[同名 shadow 且跳过过滤] H --> I[run_code 按需补入] E --> V[ToolView] F --> V I --> V

admits 是交集合成:

packages/core/tools/src/index.ts737:744
737  /** Whether every compiled restriction in this layer admits a global tool name. */738  admits(name: string): boolean {739    for (const filter of this.restrictions.values()) {740      if ((filter.allow !== undefined && !filter.allow.has(name))741        || (filter.deny !== undefined && filter.deny.has(name))) return false742    }743    return true744  }

allow 不在名单或 deny 在名单都判 false,多个 restriction 交集合成:一个 false 就出局。

最后,schema 投影只留白名单字段:

packages/core/tools/src/index.ts1256:1267
1256  private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {1257    const { name, description, parameters } = definition1258    const detached = detachParameters ? snapshotJsonValue(parameters) : parameters1259    if (detached === undefined) {1260      throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)1261    }1262    return {1263      name,1264      description,1265      parameters: detached,1266    }1267  }

执行函数、输出定义、呈现回调都不进模型视野,模型只看到 name、description、parameters 三件。

事件:关于 agent 的过滤,关于注册表的不过滤

先看 system-prompt 包的事件声明:

packages/core/system-prompt/src/index.ts18:38
18  interface Events {19    /**20     * Expert waterfall over the assembled sections, contexts, tools, and variables.21     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners22     * receive only that scope's assemblies. The returned value is authoritative.23     * A supplied signal controls only this explicit assembly request and must not24     * be retained to control later turns. A registered complete section is25     * restored after this waterfall, so listeners cannot add to or replace26     * that scope's system prompt.27     * @param assembly - the mutable assembly built from registered providers.28     * @param context - the caller's per-assembly context.29     * @mode waterfall30     */31    'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>32    /**33     * Emitted when any prompt provider changes. This registry notification is34     * unfiltered because a global change affects every scope.35     * @mode emit36     */37    'system-prompt/change'(): void38  }

31 行的 assemble 事件带 this: Scoped<SystemPrompt> 载体,这是 scope-filtered dispatch;37 行的 change 事件无载体,注释写明:unfiltered because a global change affects every scope。tools 侧同分法:

packages/core/tools/src/index.ts198:207
198    /**199     * A tool was registered or unregistered, or a scoped restriction changed200     * (the available tool set changed — possibly for one scope only). An201     * UNFILTERED registry-subject notification, deliberately not scope-filtered202     * dispatch: a global change concerns every agent's next assembly, so a203     * scoped listener subscribing here sees every change, not just its own204     * scope's.205     * @mode emit206     */207    'tools/change'(): void

载体机制本身是 scopeTarget:

packages/core/scope/src/index.ts170:185
170export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {171  const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]172  const carrier = {173    [CordisContext.filter](ctx: Context): boolean {174      if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false175      const tag = scopeOf(ctx)176      if (tag === undefined) return true177      for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {178        if (cursor === tag) return true179      }180      return false181    },182  }183  carrierKeys.set(carrier, key)184  return carrier as unknown as Scoped<T>185}

174 行先过 base 自己的过滤器。176 行无标签的 ctx 一律放行。177-179 行沿 key 的父链找 tag,命中就收。注释(164-165 行):「events flow up the chain, never down」。一个 standing 组合因此能观察到组合下面每个 agent 的事件,agent 收不到组合层之外的东西。

图 2 收拢一次 step 的组装时序,回答:谁在什么时候调用谁。

sequenceDiagram participant L as ReactLoopAgent participant S as SystemPrompt participant T as ToolRuntime participant C as LLMConsumer L->>S: assemble 传入 agent 与 signal S->>S: chainLayers 取作用域链 S->>S: merge 同名覆盖 最近者胜 S->>T: 读 visible 工具集 T-->>S: 深拷贝后的 schema S->>S: orderTools 排序 S->>S: waterfall 组装事件 L->>C: renderPrompt 渲染提示词 L->>C: buildRequest 带 assembly.tools

设计决策分析

shadowing:名字即身份

section、context、variable、tool 全部以名字为键,shadowing 让「替换」变成「同名覆盖」,persona 机制因此成了一条普通注册路径。deployment persona 的槽位名字是导出的常量:

packages/core/system-prompt/src/index.ts122:128
122/**123 * The deployment persona's section name and order. Exported because a124 * composition can replace this slot — an agent preset shadows the125 * deployment's persona with its own — and both sides naming the same section126 * is what makes the replacement work rather than duplicate.127 */128export const PERSONA_SECTION = 'deployment:persona'

注释 126 行点破:「both sides naming the same section is what makes the replacement work rather than duplicate」。替换靠双方约定同一个名字。subagent note(.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)27-33 行说明 persona 控制就是一个 child-scoped 的 deployment:persona 注册,33 行:命名贡献对第一轮请求和之后的检查是同一个名字。

备选方案为什么不选,note 84 行有记录:「Copying a resolved view would also freeze dynamic global registrations and conflate composition with authority」。

不用 shadowing 会怎样:scoped 贡献若以追加方式进入,persona 会重复出现;若以整体替换进入,就得发明一层优先级配置,而优先级配置在插件数量上来之后就是一团乱麻。

registry-subject 事件不设过滤

变更通知(change 类)关于注册表本身。tools/change 注释把论证写全(198-207 行):全局注册一个工具,所有 agent 的下一轮组装都该看到它,scoped listener 只收自己的变更,缓存的工具集就会悄悄过期。反过来,assemble 是 per-agent 活动,它带 carrier 过滤,listener 只在自己的组装里生效。分界就是术语表第 17 行的 scoped dispatch 规则:关于 agent 活动的带载体,关于注册表的不带。

不这样做的后果:change 也过滤,依赖变更通知做缓存失效的插件在别的 agent 注册时得不到通知,下次组装读到过期视图;assemble 不过滤,listener 得自己判断 assembly 里的字段归属,而载体机制把这个判断变成了框架行为。

restriction 过滤面与注册面分离

过滤只作用于继承面(global 加祖先层),own 层后加。这个顺序保证机制工具存活:view 的注释(1137-1142 行)写明 delegation runtime 把子 agent 的 reporting/structured-output 工具注册进子 agent 自己的层,过滤器删掉它们,子 agent 就回答不了问题。术语表第 19 行:restriction filters GLOBAL, scope-local merged after。

反过来做的后果有两个层次。浅层:own 层也被过滤,子 agent 的应答机制被自己的 restrict 误伤。深层:presentation 与 execution 分离,note 88 行记录「只藏 schema」的备选因此被否:提示词说没有的工具,模型可以绕过 schema 直接调用。dsh 用同一个 resolver 喂 schema 投影和执行查表(note 37 行),可见性与可执行性一致。

隔离措施,不是安全边界

组合机制管可见性,管不了授权。note 里有标题级的声明:

.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md72:78
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.7778A security design would need a separate authority representation, propagation rule, and execution-time enforcement point. Creation-time grant snapshots, parent-subset grants, explicit future-grant APIs, and generic capability/output/termination tags are outside this feature.

74 行列了四件它不做的事:不建父到子的授权格、不要求子集、不沙箱插件、不阻止其他 Cordis context 直接调服务。78 行说真正的安全设计需要单独的授权表示、传播规则和执行时强制点。一个流传很广的误解是「看不见就等于安全」,实际这条链上的每一层都挡不住持有另一个 ctx 的代码。过滤挡得住「模型以为有、其实没有」的错位,挡不住进程内任意代码直接调用。

不这样做的后果在 78 行写着:把组合机制当安全边界,部署者会以为 deny 是强制约束,真正的威胁模型(同进程任意代码)没人负责。authority 的活由别处干:capability gating(note 62 行)在 provider 层拒绝不支持的控制项,guard(tools 1100-1116 行)在执行前做单调拒绝。

边界条件剖析

如果 restrict 里写了一个没注册的工具名? tools 1088-1091 行:用当前视图的 restrictableNames 对照,未命中的名字直接抛错。restrictableNames 是过滤前的名字全集(view 1171 行),拼写错误在注册时暴露。

如果两个 complete section 同时生效? assemble 505-508 行:多于一个直接抛错。complete 的语义是「这一份就是全部」,两份就矛盾,抛错把矛盾推到配置期。

如果同一层注册了同名 section? NamedEntries.insert 第 45 行抛重复错误(store.ts 43-54 行),PromptLayer 的构造器给全局和 scoped 配了不同文案(system-prompt 316-324 行)。全局重复的文案特意提示 per-agent override:最常见的错误就是把想给某个 agent 的东西注册到全局。测试覆盖了这个行为:scoped.spec.ts 57-64 行分别断言两种文案。

如果 allow 名单是静态的,而全局工具在运行期又注册了新的? 过滤是 live 的:view 每次调用都对当前注册表求值(1169-1174 行)。deny-only 放行后来注册的名字,allow 名单拦下后来的工具(note 48 行)。想长期保留某个工具,用 deny-only 更贴切。

如果 restrict 过滤了子 agent 自己的注册? 做不到:own 层豁免(view 1176-1183 行)。过滤器的对象是继承面,own 层的机制工具永远在。注释 1137-1142 行解释了 delegation runtime 的依赖。

如果全局工具被过滤,子 agent 还能调用它吗? 不能,行为上等于不存在。术语表 19 行:「A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one」。执行端在 resolveExecution 走同一条 view(tools 1221-1226 行),schema 没有的名字执行时返回 UNKNOWN_TOOL。

横向对比

对比点:子 agent 的工具集由什么决定。dsh 的答案是运行时组合加过滤,claude-code 的答案是 agent 定义时静态声明。两侧都把决定权放在创建子 agent 的位置:dsh 在 setup 窗口里注册与过滤,claude-code 在 agent 文件的 frontmatter 里写名单。差别是 dsh 的名单是活的,claude-code 的名单是死的。

claude-code 的 agent 是 markdown 文件,frontmatter 里有一个 tools 字段。feature-dev 插件的 code-architect:

plugins/feature-dev/agents/code-architect.md1:7
1---2name: code-architect3description: Designs feature architectures by analyzing existing codebase patterns and conventions, then providing comprehensive implementation blueprints with specific files to create/modify, component designs, data flows, and build sequences4tools: Glob, Grep, LS, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, KillShell, BashOutput5model: sonnet6color: green7---

第 4 行是工具白名单:这个 agent 能看到的工具在定义文件里一次写死。hookify 插件的 conversation-analyzer 用数组形式:

plugins/hookify/agents/conversation-analyzer.md1:7
1---2name: conversation-analyzer3description: Use this agent when analyzing conversation transcripts to find behaviors worth preventing with hooks. Examples: <example>Context: User is running /hookify command without arguments\nuser: "/hookify"\nassistant: "I'll analyze the conversation to find behaviors you want to prevent"\n<commentary>The /hookify command without arguments triggers conversation analysis to find unwanted behaviors.</commentary></example><example>Context: User wants to create hooks from recent frustrations\nuser: "Can you look back at this conversation and help me create hooks for the mistakes you made?"\nassistant: "I'll use the conversation-analyzer agent to identify the issues and suggest hooks."\n<commentary>User explicitly asks to analyze conversation for mistakes that should be prevented.</commentary></example>4model: inherit5color: yellow6tools: ["Read", "Grep"]7---

第 6 行:这个 agent 只有 Read 和 Grep。省略 tools 字段的含义由插件开发文档写明:

plugins/plugin-dev/skills/agent-development/SKILL.md146:154
146**Format:** Array of tool names# ... 省略原文 yaml 围栏内的示例块·**Default:** If omitted, agent has access to all tools# ... 原文 153 行为空行,跳过154**Best practice:** Limit tools to minimum needed (principle of least privilege)

「If omitted, agent has access to all tools」:白名单语义,省略等于全量。除了白名单,claude-code 还有减法通道,CHANGELOG 记录了两个相关演进:

CHANGELOG.md4895:4895
4895- Added `disallowedTools` field to custom agent definitions for explicit tool blocking
CHANGELOG.md4733:4733
4733- Added `agent` setting to configure main thread with a specific agent's system prompt, tool restrictions, and model

4895 行说明 disallowedTools 是 explicit tool blocking 的减法;4733 行说明主线程可以用某个 agent 的提示词和工具限制来配置,把一个 agent 的定义复用成一条线程的配置。1703 行还有一条:lean system prompt 成为默认,提示词组装在上游是模板化的一体成型,与 dsh 的插件贡献拼接是两种形态。

关于检索的交代:claude-code 仓库里没有主程序源码(无 packages/ 目录),检索关键词 systemPrompt、assemble、allowedTools、subagent、tools: 只在 CHANGELOG.md 和 plugins/、scripts/ 下命中,证据来自插件里的 agent 定义文件、插件开发技能文档、CHANGELOG。上游主程序里 agent 工具集的具体解析逻辑在本仓库不可见,后续可复查 plugins/ 下的 validate-agent.sh(SKILL.md 399 行提到)。

两种答案各自的代价:

  1. dsh 的运行时组合:热注册即时生效,新增工具不必改子 agent 配置;一个 resolver 同时出 schema 和执行查表,不会出现「提示词说有、执行说没有」。代价是要理解 live 语义:allow 名单对后来注册的工具是一堵墙,deny 名单是漏勺;还要理解 global、standing、agent 的有效可见性顺序。
  2. claude-code 的静态声明:agent 文件写什么就是什么,确定性最强,一个文件可审查、可版本化。代价是插件要改工具集必须改 agent 文件再重载;tools 白名单与 disallowedTools 黑名单两套并行维护,同时出现时的合成规则由加载器决定,配置者看不到运算过程。

图 4 把两种答案的路径并排画出来,回答:同一个问题,两种决定路径差在哪。

flowchart LR subgraph D[deepseek-harness] A1[每次请求前实时组合] --> A2[注册 过滤 排序] A2 --> A3[同一 resolver 出 schema 与执行] end subgraph C[claude-code] B1[agent 定义时静态声明] --> B2[frontmatter tools 白名单] B2 --> B3[加载时解析成工具集] end

互动演示设计

形态:格式实验台。一句话结论:打开或关掉一个插件,提示词与 schema 的每一处增减都能归因到某个作用域的某次注册。

舞台元素:左侧是插件货架,分全局栏和子 agent 栏两排;中间是两块画布,上方渲染提示词(按段着色),下方渲染 tool schema(按工具着色);右侧是 diff 面板,高亮增删行;底部是逻辑轨迹面板,随动画步进高亮。生活化比喻:一家餐馆后厨。全局插件是固定菜单,每个来客都看得到;子 agent 插件是当桌加菜,只有这张桌子的客人看得到;restriction 是后厨给这张桌子贴的忌口条;子 agent 自己注册的工具是厨师端上来的拿手菜,忌口条不适用。

分五步:

  1. 空组装:加载 harness 身份段与部署 persona,schema 区为空。字幕:「默认只有身份与部署 persona,没有任何工具。」
  2. 打开 dsh-shell 插件(拖到全局栏):提示词出现工具指引段,schema 出现 Bash。字幕:「全局注册,所有 agent 的下一轮请求都可见。」
  3. 打开 code-review preset(拖到子 agent 栏):persona 段被同名覆盖成子 agent 的角色说明,schema 里 Read、Grep、Glob 保留,Bash 消失。字幕:「子 agent 作用域里,同名 persona 覆盖全局;restriction 先过滤继承面,Bash 被忌口条撤下。」
  4. 打开 structured_output 注册(拖到子 agent 栏):它在子 agent 的 schema 里出现,restriction 名单没拦住。字幕:「子 agent 自己的注册在过滤之后合并,机制工具不会被忌口条误伤。」
  5. 关掉 code-review preset:一切回滚。字幕:「卸载即逆序撤销,提示词与 schema 回到原样。」

读者操作:拖插件到全局栏或子 agent 栏;在 restriction 卡片上切换 allow 与 deny 名单;点击 scope 标签(root / child)切换视角,观察同一份注册在两个作用域下的不同投影。

逻辑轨迹面板的伪代码行,一行一句白话,随动画步进高亮:

text
agent.ctx.section(...)      → 子 agent 注册 shadow section    → system-prompt/src/index.ts:381
ctx.effect 挂到 scope 层     → 层归属由 scopeOf 决定           → scope/src/store.ts:233
assemble({ scope: agent })  → 组装入口                       → agent-loop/src/agent.ts:230
layers.merge(scope)         → 近层覆盖远层同名                → scope/src/store.ts:208
structuredClone 参数        → schema 深拷贝                   → system-prompt/src/index.ts:498
view 过滤继承面              → restriction 交集合成            → tools/src/index.ts:1174
renderPrompt 插值           → 渲染提示词                      → system-prompt/src/index.ts:212

对比沙盘:右侧放一扇窗,显示同一个子 agent 在 claude-code 里的等价配置(agent 文件 frontmatter 的 tools 行)。顶部切换开关「运行时组合 / 声明式快照」,切到声明式快照时左侧拖拽失效,画布只显示 frontmatter 的静态结果。

可迁移结论

  1. 命名 shadowing(最近者胜)值得抄,这条不依赖 TypeScript,任何配置分层系统都适用。最小形态:一个 Map 链,从最近的层开始线性扫描,第一次命中即停;或像 dsh 这样从远到近逐个覆盖,最终值就是答案。一个数组存层、每层一个 Map、覆盖规则写进注释,就够了。
  2. 注册面与事件面分离:注册决定谁看得见,事件决定谁收得到,两条规则分开设计。事件要区分 subject:关于某个实体的活动(组装、执行)按实体过滤;关于注册表本身的变化(工具注册、配置变更)必须全员可见,否则任何缓存都会过期。最小形态:事件带 subject 字段,订阅器按 subject 匹配。
  3. 哪些是过度设计:空层回收、carrier 的弱引用表、dispose 幂等去重,这套账是为几十个插件、每 agent 一个 scope、热替换的体量准备的。如果你的产品只有全局注册,一个 Map 就够;只有一层子 agent,一个全局 Map 加每 agent 一个 Map 也够。等真的需要 HMR、需要插件在运行期改自己的贡献,再引入层链和回收逻辑。shadowing 的规则本身免费,维护回收账本不免费。

思考题

  1. 动手题:在 packages/core/system-prompt/tests/scoped.spec.ts 里加一个用例:全局注册 section x,再通过 scope 注册同名 x,断言 assemble 后 scoped 版本胜出;然后调用 scope 的 dispose,再次 assemble,断言全局版本恢复。运行 pnpm --filter @deepseek-ai/dsh-system-prompt test 观察通过;再把注册顺序换一下,观察测试是否仍然通过,解释为什么(提示:参考 33 行用例名里的 either order)。
  2. 读代码题:tools/change 为什么特意不做 scope 过滤(packages/core/tools/src/index.ts:198-207)?如果带上 carrier,缓存了全局工具集的 scoped 插件在什么情况下会失效?把失效场景写成一段话。
  3. 设计题:allow 名单与 deny 名单对「运行期新注册的全局工具」行为相反(subagent note 48 行)。如果你的产品要长期保留一个工具,选哪个?如果新工具默认对子 agent 可见但可随时封禁,选哪个?分别说明理由。
  4. 动手题:在你自己的部署配置里给一个子 agent 配 restrict,故意写一个未注册的工具名,观察报错;再改成 allow: [],观察该 agent 的 schema 变成什么,解释它与 restrict({}) 报错(packages/core/tools/src/index.ts:1078-1080)的区别。

跨平台说明:提示词与 schema 的组装是纯内存逻辑,与操作系统无关,restriction、shadowing、插值在 macOS、Linux、Windows 上行为一致。