第 5 章
系统提示与工具 schema 组装
场景还原
你在一份部署里给子 agent 定了两件事:把它自己的角色说明换上去,工具只留只读的三件。第一轮请求发出去,你在请求体里看到三件怪事。
第一件,persona 没换。提示词开头还是部署 persona,子 agent 自己的角色说明没出现。第二件,工具没过滤。schema 里躺着父 agent 的 Bash,你配的 allow 名单像没生效。第三件最隐蔽:等前两件修好,子 agent 自己注册的 structured_output 工具从它的 schema 里消失了。
三个症状指向同一个根因:一份提示词和一份 tool schema,在每一轮请求之前,是从几十个插件的注册里临时拼出来的;拼出什么,取决于这一轮是给哪个 agent 的。注册时你写的是「我贡献一段提示词」,执行时才回答它属于谁、和谁同名、被谁过滤。本章把这台组装机拆开:注册怎么挂上作用域,同名怎么覆盖,过滤怎么只删继承面。
图 1 是这台组装机的全景:左边是注册面,中间是组装,右边是两份产物,回答几十个插件的注册怎么变成一份提示词和一份 schema。
逐行精读
注册面:一次注册,两件事同时定下来
每个 Agent 对象构造时给自己铸一个作用域,把自身塞进上下文。
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 标签。
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 包自己的函数认它。读取函数就三行:
154export function scopeOf(ctx: Context): ScopeKey | undefined {155 return (ctx as Context & { [kScope]?: ScopeKey })[kScope]156}SystemPrompt.section 的实现:
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 的完整实现:
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 继承的正是这个组合,注释写得很直白:
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 一条输入,然后组装:
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 绑在一起传进去:
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 是本章的心脏,分两段读,第一段是收集:
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:
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 的语义天然是后者覆盖前者。
第二段是排序与收尾:
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}}。渲染函数:
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}每段插值,空的丢掉,其余用空行连接。动态上下文快照有自己的组装:
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 是严格插值:
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 一锤定音。先看三个类型:
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 的注册面:
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 是解析端:
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。
admits 是交集合成:
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 投影只留白名单字段:
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 包的事件声明:
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 侧同分法:
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:
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 的组装时序,回答:谁在什么时候调用谁。
设计决策分析
shadowing:名字即身份
section、context、variable、tool 全部以名字为键,shadowing 让「替换」变成「同名覆盖」,persona 机制因此成了一条普通注册路径。deployment persona 的槽位名字是导出的常量:
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 里有标题级的声明:
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:
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 用数组形式:
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 字段的含义由插件开发文档写明:
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 记录了两个相关演进:
4895- Added `disallowedTools` field to custom agent definitions for explicit tool blocking4733- Added `agent` setting to configure main thread with a specific agent's system prompt, tool restrictions, and model4895 行说明 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 行提到)。
两种答案各自的代价:
- dsh 的运行时组合:热注册即时生效,新增工具不必改子 agent 配置;一个 resolver 同时出 schema 和执行查表,不会出现「提示词说有、执行说没有」。代价是要理解 live 语义:allow 名单对后来注册的工具是一堵墙,deny 名单是漏勺;还要理解 global、standing、agent 的有效可见性顺序。
- claude-code 的静态声明:agent 文件写什么就是什么,确定性最强,一个文件可审查、可版本化。代价是插件要改工具集必须改 agent 文件再重载;tools 白名单与 disallowedTools 黑名单两套并行维护,同时出现时的合成规则由加载器决定,配置者看不到运算过程。
图 4 把两种答案的路径并排画出来,回答:同一个问题,两种决定路径差在哪。
互动演示设计
形态:格式实验台。一句话结论:打开或关掉一个插件,提示词与 schema 的每一处增减都能归因到某个作用域的某次注册。
舞台元素:左侧是插件货架,分全局栏和子 agent 栏两排;中间是两块画布,上方渲染提示词(按段着色),下方渲染 tool schema(按工具着色);右侧是 diff 面板,高亮增删行;底部是逻辑轨迹面板,随动画步进高亮。生活化比喻:一家餐馆后厨。全局插件是固定菜单,每个来客都看得到;子 agent 插件是当桌加菜,只有这张桌子的客人看得到;restriction 是后厨给这张桌子贴的忌口条;子 agent 自己注册的工具是厨师端上来的拿手菜,忌口条不适用。
分五步:
- 空组装:加载 harness 身份段与部署 persona,schema 区为空。字幕:「默认只有身份与部署 persona,没有任何工具。」
- 打开 dsh-shell 插件(拖到全局栏):提示词出现工具指引段,schema 出现 Bash。字幕:「全局注册,所有 agent 的下一轮请求都可见。」
- 打开 code-review preset(拖到子 agent 栏):persona 段被同名覆盖成子 agent 的角色说明,schema 里 Read、Grep、Glob 保留,Bash 消失。字幕:「子 agent 作用域里,同名 persona 覆盖全局;restriction 先过滤继承面,Bash 被忌口条撤下。」
- 打开 structured_output 注册(拖到子 agent 栏):它在子 agent 的 schema 里出现,restriction 名单没拦住。字幕:「子 agent 自己的注册在过滤之后合并,机制工具不会被忌口条误伤。」
- 关掉 code-review preset:一切回滚。字幕:「卸载即逆序撤销,提示词与 schema 回到原样。」
读者操作:拖插件到全局栏或子 agent 栏;在 restriction 卡片上切换 allow 与 deny 名单;点击 scope 标签(root / child)切换视角,观察同一份注册在两个作用域下的不同投影。
逻辑轨迹面板的伪代码行,一行一句白话,随动画步进高亮:
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 的静态结果。
可迁移结论
- 命名 shadowing(最近者胜)值得抄,这条不依赖 TypeScript,任何配置分层系统都适用。最小形态:一个 Map 链,从最近的层开始线性扫描,第一次命中即停;或像 dsh 这样从远到近逐个覆盖,最终值就是答案。一个数组存层、每层一个 Map、覆盖规则写进注释,就够了。
- 注册面与事件面分离:注册决定谁看得见,事件决定谁收得到,两条规则分开设计。事件要区分 subject:关于某个实体的活动(组装、执行)按实体过滤;关于注册表本身的变化(工具注册、配置变更)必须全员可见,否则任何缓存都会过期。最小形态:事件带 subject 字段,订阅器按 subject 匹配。
- 哪些是过度设计:空层回收、carrier 的弱引用表、dispose 幂等去重,这套账是为几十个插件、每 agent 一个 scope、热替换的体量准备的。如果你的产品只有全局注册,一个 Map 就够;只有一层子 agent,一个全局 Map 加每 agent 一个 Map 也够。等真的需要 HMR、需要插件在运行期改自己的贡献,再引入层链和回收逻辑。shadowing 的规则本身免费,维护回收账本不免费。
思考题
- 动手题:在
packages/core/system-prompt/tests/scoped.spec.ts里加一个用例:全局注册 sectionx,再通过 scope 注册同名x,断言 assemble 后 scoped 版本胜出;然后调用 scope 的 dispose,再次 assemble,断言全局版本恢复。运行pnpm --filter @deepseek-ai/dsh-system-prompt test观察通过;再把注册顺序换一下,观察测试是否仍然通过,解释为什么(提示:参考 33 行用例名里的 either order)。 - 读代码题:tools/change 为什么特意不做 scope 过滤(
packages/core/tools/src/index.ts:198-207)?如果带上 carrier,缓存了全局工具集的 scoped 插件在什么情况下会失效?把失效场景写成一段话。 - 设计题:allow 名单与 deny 名单对「运行期新注册的全局工具」行为相反(subagent note 48 行)。如果你的产品要长期保留一个工具,选哪个?如果新工具默认对子 agent 可见但可随时封禁,选哪个?分别说明理由。
- 动手题:在你自己的部署配置里给一个子 agent 配 restrict,故意写一个未注册的工具名,观察报错;再改成
allow: [],观察该 agent 的 schema 变成什么,解释它与restrict({})报错(packages/core/tools/src/index.ts:1078-1080)的区别。
跨平台说明:提示词与 schema 的组装是纯内存逻辑,与操作系统无关,restriction、shadowing、插值在 macOS、Linux、Windows 上行为一致。