feat(system-prompt): prompt variables, persona-as-section, tool-guidance ownership
One principle: every fact in the assembled prompt has exactly one owner.
- dsh-system-prompt: merge-extensible AssembleContext on assemble();
a variable(name, provider) registry; {{name}} interpolation in
renderPrompt, strict (unknown/valueless/malformed references throw);
duplicate section and variable names rejected; assembly carries
resolved section text + variables through the assemble waterfall.
- dsh-agent declares AssembleContext.agent; dsh-agent-loop registers
the agent:persona section (order 0 - identity renders before tool
guidance) and the model/cwd variables, and drops its string join:
renderPrompt(assembly) IS the full prompt.
- Tool guidance moves to its owners: descriptions carry per-tool
semantics; sections only cross-call habits (tool:bash exit-code
habit at order 105; read's not-shell nudge). todo/subagent need no
section - their descriptions already carry the contract.
- SubagentProvider.inheritsParentContext (spawn/acp false, fork true);
dsh-tool-subagent derives truthful per-provider wording and resolves
the provider at load (backend must be listed first).
- Example personas shrink to identity + behavior with {{model}} (and
{{cwd}} in the ACP tree); the welcome banner stops enumerating tools.
RFC: docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md
This commit is contained in:
@@ -152,6 +152,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 |
|
||||
| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 |
|
||||
| [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 |
|
||||
| [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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), a registered-but-valueless reference throws, and a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws. Only complete double-brace groups are interpreted; a lone `{{` passes through verbatim. 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. `apply` resolves the provider at LOAD time and throws if it is not registered — the backend plugin must be listed before the tool plugin in `cordis.yml`; a wiring mistake fails loudly at boot instead of shipping a lying description.
|
||||
|
||||
## 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/request` waterfall** — prompt text composed in two places, and `agent/pre-step`'s `fullSystemPrompt` would 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** — `providerName` is itself config, so a renamed provider silently gets the wrong words.
|
||||
- **Resolving the subagent flag lazily (section-only wording)** — would tolerate any load order, but the DESCRIPTION is fixed at tool registration and is where tool-choice guidance belongs; a deterministic load-order requirement with a loud, actionable failure is the smaller cost.
|
||||
|
||||
## What we give up
|
||||
|
||||
- `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step; such a plugin can rewrite `options.system` in the same waterfall if it cares. Accepted.
|
||||
- `dsh-tool-subagent` now has a hard load-order requirement on its backend. The examples already ordered backends first; the failure mode is an immediate boot error naming the fix.
|
||||
- Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, 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 `cwd` for 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.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `renderPrompt(assemble({agent}))` for the coding-agent example contains the persona FIRST (with the agent's model name interpolated), then fs/bash/web guidance sections; the loop contains no other prompt-composition path.
|
||||
- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. Loading `dsh-tool-subagent` before its backend fails at load with a message naming the ordering fix.
|
||||
- Unknown/valueless/malformed `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw.
|
||||
- The gating runs (`test:coverage`, `test:snapshot`, `doc-sync`, `build`, `hygiene`) are green; no golden re-record is needed (replay never re-verifies the outgoing request).
|
||||
Reference in New Issue
Block a user