Codex round-2 findings: - A committed test now proves the REAL plugin fiber (not a direct apply) owns the provider-lifecycle listeners: disposing a mounted tool's fiber unmounts the tool and leaves the provider intact, and a fiber disposed while WAITING never zombie-mounts when its provider arrives later. - TODO(subagent-dup-toolname) records the invalid-config blast radius of two waiting fibers sharing a toolName (the duplicate throw propagates through subagent/provider-added and rolls back the provider). - CONTEXT.md drops its creation-history sentence; the RFC's acceptance checklist becomes present-tense shipped invariants (docs/AGENTS.md writing rules).
11 KiB
RFC: Prompt variables and tool-guidance ownership
Status: implemented (proposed and accepted 2026-07-05)
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 bash/subagent/todo_write usage guidance lived in the systemPrompt strings of examples/coding-agent/cordis.yml and examples/acp-agent/cordis.yml — 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; both YAMLs carried a FIXME(config-comments) apologizing for a symptom of the split, and the stdio 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 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 fact in the prompt has exactly one owner. The model name and workspace are config/session facts → the harness exposes them as variables and the persona references them. Per-tool semantics and when-to-use → the tool's description. Cross-call habits a description cannot carry → the tool package's prompt section. Identity and behavior → the deployment's persona, and nothing else.
Assemble context
SystemPrompt.assemble(context) takes an AssembleContext — declared EMPTY and merge-extensible in dsh-system-prompt (the package stays agnostic of who assembles); dsh-agent declaration-merges agent?: Agent onto it (a new type-level edge agent → system-prompt, no cycle — tools already depends on both). The loop passes { agent } each step; section text providers become string | ((context) => string) (zero-arg providers stay valid), and the system-prompt/assemble waterfall gains the context parameter so a listener can filter or extend per agent.
Prompt variables
Plugins contribute named values via ctx.systemPrompt.variable(name, provider); prompt text references them as {{name}}. Providers are functions of the AssembleContext and may return undefined — "no value for THIS assembly". assemble() resolves every registered variable into PromptAssembly.variables (waterfall listeners can see, add, or override); renderPrompt interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists; lookup is Object.hasOwn, so a prototype property like {{constructor}} is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete {{…}} group that is not a well-formed name ([a-z][a-z0-9_]*, e.g. {{ model }}) throws, and a {{ that opens no complete group while a }} still follows ({{{model}}}, {{a{b}}) throws. A lone {{ with no }} anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and section() now rejects duplicate section names, making the documented dedup real.
dsh-agent-loop registers the two built-ins, both pure projections of the context agent: model (= options.model) and cwd (= session.header.cwd). The example personas write powered by the {{model}} model — the model name is stated once, in the model: config key. {{cwd}} is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming {{cwd}} there fails the turn — by design).
Persona as the order-0 section
The loop plugin registers ONE section, agent:persona at order 0, whose text is context.agent?.options.systemPrompt ?? ''. The loop's special-case join is deleted: fullSystemPrompt ≡ renderPrompt(assembly), one ordered pipeline for everything the model sees, and agent/pre-step (compaction's token-pressure input) measures exactly the real prompt. Order bands are now convention: persona 0, tool guidance 100–199, negative orders render before the persona. AgentOptions.systemPrompt keeps its familiar key but is documented as what it is — the persona template fragment, one section of the full prompt, never the whole (see CONTEXT.md).
Tool guidance ownership
Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: dsh-tool-bash contributes tool:bash (order 105) — check the [exit code: N] marker on every result; dsh-tool-fs's read section gains the "not shell commands like cat" contrast. todo_write and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools.
The subagent context contract
SubagentProvider gains readonly inheritsParentContext: boolean — a DESCRIPTIVE fact beside capabilities, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare false, fork declares true. dsh-tool-subagent derives both the tool description and the prompt parameter description from the flag (providerWording): the fork instance now tells the model the child inherits the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Because the description is fixed at tool registration while providers arrive on their own fibers, the registry announces provider lifecycle (subagent/provider-added/subagent/provider-removed) and the tool MIRRORS it: it registers when its provider is (or becomes) available, unregisters when the provider goes away, and re-derives the wording on re-registration (HMR). There is deliberately NO load-order requirement — the cordis Loader starts sibling entries concurrently (Promise.all over the group), so "listed first" never guaranteed "registered first"; while the provider is absent the tool does not exist, which cannot lie.
Rejected alternatives
- The loop composes an identity line itself — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and contradicts
dsh-system-prompt's "no hardcoded prompt text" stance. - Inject the model name via the
agent/requestwaterfall — prompt text composed in two places, andagent/pre-step'sfullSystemPromptwould omit it, so compaction would measure a prompt that is not what the model sees. - 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 RFC 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. - Per-instance subagent wording in config — returns model-facing prose to every deployment × instance, the P2 disease again. Keying wording off the provider NAME —
providerNameis itself config, so a renamed provider silently gets the wrong words. - Resolving the provider at
applytime and throwing when absent (a load-order requirement) — the first implementation. Rejected after review reproduced the failure: the Loader starts sibling entries concurrently andEntry.init()does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first" — the ordering the requirement leaned on is not a contract the Loader offers ("async state is not synchronous state"). Provider-lifecycle events make the ordering question disappear instead of documenting it. - Section-only subagent wording (lazily resolved at assemble) — would also tolerate any load order, but the DESCRIPTION is fixed at tool registration and is where tool-choice guidance belongs; reactive registration keeps the description authoritative AND order-free.
What we give up
{{model}}reflectsAgentOptions.modelat assembly time. A plugin that switches models in theagent/requestwaterfall makes the prompt's claim stale for that step; such a plugin can rewriteoptions.systemin the same waterfall if it cares. 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.
- Strictness means a persona can fail a turn at render (e.g.
{{cwd}}on a cwd-less session). The failure is contained — the turn endserror, the loop survives — and it is an authoring error we WANT loud. - No escape syntax for a literal
{{name}}in prompt prose yet; add one if a real prompt ever needs it.
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. - A config
cwdfor pre-created stdio agents (would let the stdio persona use{{cwd}}and partition persistence by real path) — deferred until the session-cwd story is revisited.
Shipped invariants
renderPrompt(assemble({ agent }))for the coding-agent example renders the persona FIRST (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path.- The
subagent_forkschema description says the child inherits the conversation; thesubagentone says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload. - Unknown/valueless/malformed/unbalanced
{{…}}references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. - Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request.