Files
deepseek-harness/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md
Yichen Jiang 61d5cb9e41 fix(apiproxy): serve history events and projections from one log position
Review found two defects in the previous commit's ordering fix.

@pku-xht: `historyStateFor` copied the attached session's events, the handler
then awaited `presenterScopeFor`, and only then read the projection baseline
off the still-live Session. An append during that await served events cut at N
beside a baseline folded to N+1 — one response describing two moments. The
same restructure had also moved the baseline read outside the `try`, so a
failing snapshot escaped the structured `internal` error.

Both awaits now happen before the cut: `historySourceFor` resolves which
session serves the read, `presenterScopeFor` ensures the recorded composition,
and `historyCutOf` then reads events and baseline adjacently with nothing
between them. The whole sequence is back inside the try.

The invariant judged any scoped assembly with a chain of one as an unjoined
agent, which rejects a legitimate assembly in a standing preset key (that key
has no parent of its own). It now gates on `context.agent` — a scope-only read
is not an agent and is out of range by construction rather than by a premise
about who else calls `assemble` — and asks the roster's own
`composedPreset()` instead of introspecting chain length. The advisory warning
uses the same relation.

Also from review: the `2026-08-05-per-agent-tool-presentation` note still
described `presentAs` as per-agent, which standing mounts made false and this
branch's own rewording contradicts; the duplicated "process-wide unit table"
argument collapses to the Agent Note with pointers from the five copies; a
dead `.sort()` before `arrayContaining`; and change-history narration in the
tool-cordis README.
2026-08-11 10:45:15 +08:00

5.7 KiB

Agent Note: Per-agent tool presentation, and the code preset

Status: implemented

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.

ToolRegistry.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-mode 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_code is appended per scope. Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find run_code in 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_code was 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-mode, 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 ToolRegistry 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.