Prompt variables and tool-guidance ownership
提示词变量与工具指导归属
The assembled system prompt had four defects, all of one family: facts the harness already knows were restated by hand somewhere else, and drifted. **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent inpu
English
Problem
The assembled system prompt had four defects, all of one family: facts the harness already knows were restated by hand somewhere else, and drifted.
The model could not know its own name. AgentOptions.model drives every request, but no prompt text carried it — and nothing COULD carry it: sections in dsh-system-prompt were context-global while the model name is per-agent, and assemble() took no per-agent input at all.
Tool guidance was hand-written prose in leaf YAML. The shell/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while dsh-tool-fs and dsh-tool-web owned their guidance as ctx.systemPrompt.section() contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand, and the old terminal welcome banner hand-enumerated the tool set too.
The persona rendered after tool guidance. The loop string-joined agent.options.systemPrompt AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline.
The fork tool's description was false. dsh-tool-subagent hardcoded one description written for spawn semantics — "a separate agent that works in its own context … it does not see this conversation" — and the subagent_fork instance (whose child inherits the parent's completed turns) got the same words; the YAML prose corrected the lie out-of-band. Minor kin: PromptSection.name was documented "(diagnostics / dedup)" but duplicates were silently accepted.
Decision
One principle: every prompt fact has exactly one owner. The loop exposes route facts as variables referenced by the persona. The working-directory service supplies the current directory as user context. Per-tool semantics and when-to-use belong to the tool description; cross-call habits belong to its package prompt section. The product name and SDK identity line belong to the static harness:identity section. Deployment role and behavior belong to the deployment persona.
Assemble context
SystemPrompt.assemble(context) takes a merge-extensible AssembleContext. dsh-system-prompt declares the optional scope selector used for scoped routing, while dsh-agent declaration-merges the optional typed agent field onto it (a type-level edge agent → system-prompt, with no runtime dependency cycle). The loop calls assembleContextFor(agent) each step so both fields identify the same agent; section text providers may read that context, and the system-prompt/assemble waterfall receives it so a listener can filter or extend per agent.
Prompt variables
Plugins register {{name}} values through ctx.systemPrompt.variable(name, provider). Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return undefined, malformed complete references, and unbalanced references that still contain a closing }}; a lone unmatched {{ remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. Sections may set interpolate: false to preserve generated documents literally; tools:sdk does so because tool descriptions and schemas may document their own {{…}} syntax.
dsh-agent-loop registers provider (= options.provider) and model (= options.model) as pure projections of the context agent. Example personas reference {{model}} so the route owns the model name. These variables remain on the loop plugin because they describe the agents it drives; a replacement loop supplies its own. The Session working-directory decision owns directory state and its literal user-context placement; the loop does not register cwd.
Persona as the order-0 section
dsh-system-prompt owns harness:identity at first-party order -1000 and the configured deployment:persona-prefix at order 0, so both survive a replacement loop. Prompt rendering has one path, renderPrompt(assembly), and the routed request header therefore records the exact prompt later replayed by ctx.tokenMeter for compaction pressure. An agent-scoped deployment:persona-prefix shadows the global default and lets subagent providers install a persona before publication. The dsh-system-prompt README owns the sparse named placements for identity, policy, tool guidance, generated protocol, and final-output obligations.
Tool guidance ownership
Per-tool semantics and selection guidance live in tool descriptions. Prompt sections carry only cross-call habits, such as checking bash exit markers or preferring filesystem tools over shell commands. todo_write and subagent tools need no section because their descriptions contain the full contract. Deployment personas contain only role and behavior.
The subagent conversation-history descriptor
SubagentProvider.inheritsParentContext describes conversation seeding, not scope, services, tools, or authority. Spawn and ACP set it to false; fork sets it to true. dsh-tool-subagent derives its tool and prompt-parameter descriptions from the flag, including that fork inherits completed turns but not the in-flight turn. Provider lifecycle events keep that wording synchronized with reactive provider registration; their rationale lives in the provider-lifecycle-events Agent Note.
Alternatives considered
- The loop composes an identity line itself — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by
dsh-system-prompt, whosesystem-prompt/assemblewaterfall remains the escape valve for a deployment that must drop it.) - Inject the model name via the
agent/requestwaterfall — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model. - Hand-write the model name in each persona — duplicates the
model:key one line above and silently lies after a config edit; the exact disease this decision cures. - Lenient interpolation (leave unknown refs verbatim, or substitute empty) — a typo ships
{{modle}}(or a hole) to the model and nobody notices until transcript review. Leaving only unknown names unchanged would still substitute registered names inside tool documentation. - Per-instance subagent wording in config — returns model-facing prose to every deployment × instance, reviving the hand-written-guidance-in-leaf-YAML drift. Keying wording off the provider NAME —
providerNameis itself config, so a renamed provider silently gets the wrong words. - Resolving the provider at
applytime (a load-order requirement) and section-only subagent wording (lazily resolved at assemble) — the alternatives to the provider-lifecycle events; both rejected in the provider-lifecycle-events Agent Note.
Out of scope
- Further variables (
date, platform, git state) — the registry makes each a one-line contribution by whichever plugin owns the fact; none is claimed here.
Shipped invariants
- The tui-agent prompt renders identity, persona with the interpolated model, then fs/shell/web guidance through one assembly path.
- Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes.
- In interpolated sections, unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw.
- Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request.
Consequences
- Every fact in the assembled prompt now has exactly one owner, and the hand-maintained tool prose in leaf YAML is gone: loading or dropping a tool plugin no longer means editing any deployment's persona.
{{model}}reflectsAgentOptions.modelat assembly time. A plugin that switches models in theagent/requestwaterfall makes the prompt's claim stale for that step, and one that SUPPLIES the model there (options.model unset — the loop's documented fallback) leaves the variable valueless at render, failing a{{model}}persona before the waterfall runs. Both have the same remedy, and it is the ownership rule itself: the plugin that owns the late-bound model fact states it early on thesystem-prompt/assemblewaterfall (assembly.variables['model'] = …) — one owner, both statements; a loop test pins the supply path end-to-end. Accepted.- While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted.
- Strict interpolation rejects missing or valueless variables before a model request. The turn ends with
errorwhile the loop remains available for later turns; a persona referencing{{model}}requires its composing runtime to supply that value. - Inline escapes remain unsupported in interpolated text; literal sections need no escaping. PTC unit tests cover both modes and runtime languages, and the recorded
ptc-turnscenario preserves tool-template examples in the model-visible prompt.
中文
问题
组装后的系统提示词存在四个缺陷,同属一类:harness 已知的事实在别处被手工重述,然后漂移。
模型无法知道自己的名字。 AgentOptions.model 驱动每个请求,但没有任何提示词文本携带它——也不可能携带:dsh-system-prompt 中的 section 是上下文全局的,而模型名称因 agent(智能体)而异,assemble() 根本不接受任何 per-agent 输入。
工具指导是 leaf YAML 中的手写行文。 shell/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP(Agent Client Protocol)的 persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 dsh-tool-fs 和 dsh-tool-web 则通过 ctx.systemPrompt.section() 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona,旧终端欢迎横幅也手动枚举了工具集。
Persona 渲染在工具指导之后。 agent loop(智能体循环)将 agent.options.systemPrompt 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。
Fork 工具的描述是假的。 dsh-tool-subagent 硬编码了一段为 spawn 语义编写的描述——「a separate agent that works in its own context … it does not see this conversation」——而 subagent_fork 实例(其子 agent 继承父级已完成的轮次)拿到了同样的措辞;YAML 行文在带外纠正了这个谎言。小问题:PromptSection.name 文档标注为「(diagnostics / dedup)」,但重复项被静默接受。
决策
一条原则:提示词中的每个事实恰好有一个归属方。 循环将路由事实暴露为 persona 引用的变量。工作目录服务通过用户上下文提供当前目录。每个工具的语义和选择指导属于工具描述;跨调用习惯属于其包的提示词 section。产品名称和 SDK 身份说明属于静态的 harness:identity section。部署角色与行为属于部署的 persona。
组装上下文
SystemPrompt.assemble(context) 接受一个可合并扩展的 AssembleContext。dsh-system-prompt 声明可选的 scope 选择器用于 scoped 路由,而 dsh-agent 通过声明合并将可选的类型化 agent 字段附加到其上(类型层面的 agent → system-prompt 边,无运行时依赖循环)。循环在每个步骤调用 assembleContextFor(agent),使两个字段标识同一个 agent;section 文本提供方可以读取该上下文,system-prompt/assemble waterfall(瀑布式事件)也接收它,监听器可据此按 agent 过滤或扩展。
提示词变量
插件通过 ctx.systemPrompt.variable(name, provider) 注册 {{name}} 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 undefined、格式错误的完整引用、以及仍包含闭合 }} 的不平衡引用;孤立的未匹配 {{ 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。段可设置 interpolate: false 来原样保留生成的文档;tools:sdk 使用此设置,因为工具描述和 schema 可能会介绍自身的 {{…}} 语法。
dsh-agent-loop 注册 provider(= options.provider)和 model(= options.model),它们都是上下文 agent 的纯投影。示例 persona 引用 {{model}},由路由拥有模型名称。这些变量保留在 loop 插件上,因为它们描述该循环驱动的 agent;替换循环自行提供自己的变量。Session 工作目录决策 拥有目录状态及其字面用户上下文位置;循环不注册 cwd。
Persona 作为 order-0 section
dsh-system-prompt 拥有 first-party order 为 -1000 的 harness:identity 和 order 为 0 的配置 deployment:persona-prefix,因此两者在循环被替换时仍然存活。提示词渲染只有一条路径 renderPrompt(assembly),已路由请求 header 因此会记录准确的提示词,稍后由 ctx.tokenMeter 为压缩(compaction)压力回放。agent 作用域的 deployment:persona-prefix 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。dsh-system-prompt README规定身份、策略、工具指导、生成协议和最终输出义务的稀疏具名位置。
工具指导归属
每个工具的语义和选择指导放在工具 description 中。提示词 section 只承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。todo_write 和 subagent 工具不需要 section,因为它们的 description 包含完整约定。部署 persona 只包含角色和行为。
Subagent 对话历史描述符
SubagentProvider.inheritsParentContext 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 false;fork 设为 true。dsh-tool-subagent 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见提供方生命周期事件 Agent Note。
曾考虑的替代方案
- 循环自行组合一行 identity 文本:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为
dsh-system-prompt注册的普通 section,其system-prompt/assemblewaterfall 仍是部署需要移除它时的逃生阀。) - 通过
agent/requestwaterfall 注入模型名称:提示词文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在提示词中更早出现的声明。 - 在每个 persona 中手写模型名称:与上方一行的
model:键重复,配置修改后静默失实;正是本决策要治愈的病症。 - 宽松插值(未知引用保留原样或替换为空):一个拼写错误
{{modle}}(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。仅保留未知名称仍会替换工具文档中的已注册名称。 - 在配置中为每个 subagent 实例编写措辞:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。根据提供方名称选择措辞:
providerName本身是配置,重命名提供方后会静默获得错误的措辞。 - 在
apply时解析提供方(加载顺序要求)与仅用 section 承载 subagent 措辞(在 assemble 时惰性解析):提供方生命周期事件的替代方案;两者均在提供方生命周期事件 Agent Note中被否决。
不在范围内
- 更多变量(
date、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 Agent Note 不认领任何一个。
交付的不变式
- tui-agent 的提示词通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/shell/web 指导。
- fork 和 fresh subagent 的描述反映提供方是否继承已完成的对话轮次;工具随提供方生命周期变化而出现、消失和重新措辞。
- 在启用插值的段中,未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
- 快照回放与提示词无关:它按轮次和步骤索引已记录的分片流,不比较发出的请求。
后果
- 组装后的提示词中每个事实现在恰好有一个归属方,leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。
{{model}}在组装时反映AgentOptions.model。如果一个插件在agent/requestwaterfall 中切换模型,提示词对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含{{model}}的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在system-prompt/assemblewaterfall 上提前声明它(assembly.variables['model'] = …)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。- 当一个已绑定的提供方不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。
- 严格插值在模型请求之前拒绝缺失或无值的变量。该轮次以
error结束,循环仍可服务后续轮次;引用{{model}}的 persona 要求组合它的运行时提供该值。 - 插值文本仍不支持行内转义;字面文本段无需转义。PTC 单元测试覆盖两种模式和运行时语言,录制的
ptc-turn场景在模型可见的提示词中保留工具模板示例。