Certified Agent Harness Engineer
Course sections

M4.2

Assemble a system prompt without unbounded growth

895 words · about 6 min

The concept

Concept

The Module 1 spine has a system prompt that is a string constant. Real harnesses do not, because the prompt must carry contributions from many independent sources: the harness's own identity, a deployment persona, per-tool usage guidance, mode policies, and dynamic facts like the current working directory or the active permission mode. If every source can append to one string, the prompt grows by accretion and nobody owns its total size — the classic failure is a prompt that has quietly tripled because six plugins each added "just a paragraph".

Worse, some of those contributions are volatile. The current time, the current mode, and the current sandbox policy all change during a session. If volatile facts live in the system prompt, every change rewrites the prompt, and — as M4.8 will show in detail — that destroys prompt caching from the first changed token.

The mechanism

Mechanism

The fix is to split contributions by volatility and give each a declared order.

dsh exposes two distinct registration kinds on ctx.systemPrompt. Stable contributions register as sections:

interface PromptSection {
  readonly name: string
  readonly order: number
  readonly text: string | ((context: AssembleContext) => string)
  readonly complete?: boolean
}

Volatile contributions register as contexts, with the same shape minus complete. docs/subsystems/system-prompt.md describes PromptContext as "the cache-safe counterpart to PromptSection". Sections are concatenated, interpolated, and rendered as the model's system message. Contexts never touch the system message at all; they become a user-role snapshot appended after retained history.

Bounding growth then rests on three properties of the registry:

Named registration prevents duplicates. Registering two sections with the same name in the same layer throws. The prompt cannot silently accumulate two copies of the same guidance from two plugin load paths.

Explicit order prevents ordering by accident. Orders follow a documented band convention, and the real registrations across the repo bear it out: harness:identity at -100 (packages/core/system-prompt/src/index.ts), harness:source at -99, deployment:persona at 0, plan:policy at 50, then per-tool guidance occupying one integer each from 100 upward — tool:read at 100 (packages/fs/tool-fs/src/read.ts), tool:write at 101, tool:edit at 102, tool:glob at 103, tool:grep at 104, tool:bash at 105 — with generated SDK guidance at 150 and trailing output instructions at 190. The band is a social contract, and dsh is honest that it is only that: its own README lists as a known limitation that sections sharing an order tie-break by registration order, which is a plugin-load artifact rather than a designed guarantee.

Registrations are effects. Each returns a disposer tied to the calling fiber, so unloading a plugin removes its contribution. Growth is reversible, which is what stops a long-lived process from accumulating prompt text from every plugin it has ever loaded.

There is also an escape hatch: a section registered with complete: true becomes the entire prompt, and more than one effective complete section rejects assembly outright. This exists for compatibility deployments that must own the full prompt, and it fails loudly rather than silently concatenating two prompts that each assumed they were alone.

Worked example

A plugin adding guidance for a new tool registers one section and returns nothing:

ctx.systemPrompt.section({
  name: 'tool:grep',
  order: 104,
  text: '...',
})

Whereas a plugin whose contribution changes during the session — sandbox policy, for instance — registers a context instead. The three real context registrations in the repo are sandbox:policy at order 110 (packages/sandbox/sandbox-policy/src/index.ts), approval:policy at 115 (packages/interaction/user-approval/src/index.ts), and subagent:delegation at 120 (packages/subagent/subagent/src/child-agent.ts). Every one of them is a fact that can change mid-session. That is not a coincidence; it is the rule the split encodes.

The rendered snapshot carries a header that makes supersession explicit, from packages/core/system-prompt/src/index.ts:

Current runtime context. This snapshot supersedes earlier runtime-context snapshots.

That sentence is load-bearing. Because snapshots append rather than replace, an older snapshot may still be visible in history, and the model needs to know which one governs.

Tradeoffs

Splitting stable from volatile costs the plugin author a decision they could otherwise skip, and the decision is not always obvious — is the working directory stable or volatile? (dsh treats it as a cwd variable interpolated into sections, which is a third answer: stable within a session, so safe in the prompt.) The cost of getting it wrong is asymmetric: putting a stable fact in a context wastes a little history; putting a volatile fact in a section destroys your cache hit rate.

Strict rendering is the other trade. dsh interpolates {{variable}} references and throws on an unknown reference, a registered-but-valueless one, or a malformed group. This turns a typo into a failed turn rather than a prompt containing the literal text {{modle}}. Loud failure is the right default for something the model reads on every request, but it does mean a variable provider returning undefined at an unlucky moment fails the assembly rather than degrading.

How others differ

Claude Code assembles its system prompt from a fixed core plus CLAUDE.md files discovered on disk, with the discovered content injected as context rather than as prompt text — the same volatility split, reached by a different route. Harnesses that expose a single systemPrompt string in config, which many smaller frameworks do, push the composition problem entirely onto the operator: simpler to implement, but there is no mechanism preventing unbounded growth and no way for a plugin to contribute guidance for a tool it owns.


Check yourself

3 questions on M4.2

Start drill