Per-agent tool presentation, and the `code` preset
按 agent 的工具呈现方式,以及 `code` 预设
Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. Code Mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in Code Mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had noth
English
Problem
Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. Code Mode — one run_code tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide mode field on the host's dsh-tools row. A deployment either ran every session in Code Mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on.
The naive reading of "move tools down to the agent plane" does not work. ctx.tools has host-plane consumers that cannot follow it: dsh-agent-loop reads the registry's private scheduler seam, dsh-apiproxy reads its presenters to render tool cards, and every tool plugin registers into it. By the stack's own rule — a service moves into a preset only when ALL of its consumers move with it — the registry stays where it is.
Decision
Split the registry from its projection. The registry stays host-plane; the presentation becomes scope state inside it, alongside the scoped restrictions and guards that already live there.
ToolRuntime.presentAs(mode) is scoped-only and mirrors restrict(): it writes one cell on the calling scope's ToolLayer through ScopedLayers.effect, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the code preset carries the tool-presentation row — so one declaration covers every agent joined to that preset, and modeFor(scope) takes the nearest declaration on the chain. It resolves against the config mode, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the run_code entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's.
Two consequences fell out and are load-bearing:
run_codeis appended per scope. Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not findrun_codein its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need.- The reserved name is now unconditional.
run_codewas rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted.
The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per scope by presentAs, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section.
The preset expresses the choice through one row, @deepseek-ai/dsh-agent-tool-presentation, whose whole body is a presentAs call. A code mode waits for ctx.codeRuntime through ctx.inject rather than assuming it: the runtime is host-plane, and a pending row is what dsh-agent-presets already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act.
Alternatives considered
A second ToolRuntime inside the preset's isolate realm. Rejected: dsh-agent-loop resolves the registry once from the host context through a private symbol, so a per-agent registry would be invisible to the scheduler. Making the loop registry-per-agent is a far larger change than making one field scope-aware.
A top-level key in the preset's own YAML. Rejected for the reason preset display metadata went to a separate preset.yml: the composition is a top-level list of plugin rows and cannot carry sibling keys.
Naming the package dsh-tool-mode. Rejected by a gate, correctly. gen-tool-catalog globs packages/*/tool-* and requires every match to publish a model-facing tool schema, because that prefix means "ships a tool" in this repo. This row ships none.
Registering the SDK section unconditionally from the constructor. Rejected after trying it: renderPrompt drops empty sections but PromptAssembly.sections retains them, so every native deployment would carry a tools:sdk entry rendering nothing, and two existing assertions on that list would have had to be weakened to accommodate it.
Sharing standard's composition by include. Rejected per the stack's own convention: cordis already duplicates standard, and a preset's value is that its whole composition is readable in one file. The cost — a third copy of ~240 lines that must move together — is real and is the strongest argument for a future include mechanism.
Consequences
Two sessions in one process can now present differently, so "which tools does the model see" is no longer answerable from the deployment config alone; it requires the agent. Every diagnostic that quotes a mode now quotes the scope's, not the service's.
ctx.tools.schemas(agent) remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; web-agent-presets.spec.ts asserts both sides of that distinction for the shipped code preset.
The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no code-mode preset; the shipped Web overlay carries one, the base composition does not.
中文
问题
agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种形态抵达模型。Code Mode——一个 run_code 工具加一份生成的 TypeScript SDK,用一段程序替代一串调用——此前是宿主 dsh-tools 那一行上的部署级 mode 字段。一个部署要么所有会话都跑 Code Mode,要么一个都不跑,于是那个显而易见的产品形态(预设选择器里「代码模式」与标准/极简/创造并列)无处安放。
「把 tools 下沉到 agent 平面」这个字面读法行不通。ctx.tools 有一批跟不下来的宿主平面消费者:dsh-agent-loop 读它私有的调度器 seam,dsh-apiproxy 读它的 presenter 来渲染工具卡,每个工具插件都往里注册。按本 stack 自己的规则——只有所有消费者一起下沉,服务才能下沉——注册表必须留在原地。
决策
把注册表和它的投影拆开。注册表留在宿主平面;呈现方式变成它内部按 scope 的状态,与已经住在那里的作用域限制和守卫并列。
ToolRuntime.presentAs(mode) 只接受 scoped 上下文,形状照抄 restrict():它通过 ScopedLayers.effect 在调用方 scope 的 ToolLayer 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——code preset 携带 tool-presentation 行——因此一份声明覆盖加入该 preset 的每个 agent,而 modeFor(scope) 取作用域链上最近的那份声明。它与 config 的 mode 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 run_code 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。
有两个随之而来的结果,且都是承重的:
run_code按 scope 追加。 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到run_code——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。- 保留名现在无条件生效。
run_code此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。
SDK 提示词段由 code 模式的部署全局注册(不变),并由 presentAs 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。
preset 用一行来表达这个选择:@deepseek-ai/dsh-agent-tool-presentation,其全部内容就是一次 presentAs 调用。code 类模式通过 ctx.inject 等待 ctx.codeRuntime 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 dsh-agent-presets 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。
考虑过的替代方案
在 preset 的 isolate realm 里再起一个 ToolRuntime。 否决:dsh-agent-loop 通过一个私有 symbol 从宿主上下文一次性解析注册表,因此按 agent 的注册表对调度器不可见。把 loop 改成按 agent 解析注册表,远比把一个字段变成 scope 感知的改动大。
在 preset 自己的 YAML 里加一个顶层键。 否决,理由与 preset 展示元数据落到独立 preset.yml 相同:组装是一个顶层的插件行列表,装不下并列的键。
把包命名为 dsh-tool-mode。 被一道 gate 否决,而且它是对的。gen-tool-catalog 以 packages/*/tool-* 通配,并要求每个命中项发布一个面向模型的工具 schema——因为在本仓库里这个前缀就意味着「带工具」。而这一行不带任何工具。
在构造函数里无条件注册 SDK 段。 试过之后否决:renderPrompt 会丢弃空段,但 PromptAssembly.sections 会保留它们,于是每个 native 部署都将携带一个什么也不渲染的 tools:sdk 条目,而两处既有断言不得不为此放宽。
用 include 共享 standard 的组装。 按本 stack 自己的惯例否决:cordis 已经复制了一份 standard,而 preset 的价值恰在于整份组装能在一个文件里读完。代价——第三份约 240 行、且必须同步演进的副本——是真实的,也正是未来引入 include 机制最有力的论据。
后果
同一进程内的两个会话现在可以有不同的呈现方式,因此「模型看到哪些工具」不再能只凭部署配置回答,必须给出 agent。凡是引用模式的诊断信息,现在引用的都是该 scope 的,而不是服务的。
ctx.tools.schemas(agent) 仍然是该 agent 的能力清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly;web-agent-presets.spec.ts 对随附的 code 预设同时断言了这个区分的两侧。
随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 code 模式的 preset;随附的 Web overlay 带了一个,base 组装没有。