The exact-disposer fix (5fbac8be B1) repaired agents.register but left the same wrapper (return () => void dispose()) at seven sibling sites: tools.register, tools.restrict, systemPrompt.section/tools/variable, agents.setFactory, and subagents.registerProvider. A wrapper makes correct composite usage unrepresentable — the exact disposer cannot be recovered, so a generator effect yielding it leaves the inner effect disposing as a CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering corruption. The exact disposer serves both usages (composite-nestable AND fire-and-forget callable); all seven now return it, typed () => Promise<void> | void, with the convention pinned by a discriminating test: an async-link composite probe that passes with the exact disposer and observes the sibling unregistration firing mid-drain with a wrapper. Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it changed register()'s return type without updating cross-file consumers (agent.spec.ts dispose() statements, tool-bash's disposer list), which the staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD. Those three sites and this change's own fallout are fixed together: tests now await disposers (stronger — they observe the full unwind), sync paths void them, and the two annotation sites carry the honest union type. agents.register's README line had drifted the same way (B1 updated the JSDoc, not the README) — all seven README signatures now match; services catalog regenerated.
dsh-system-prompt
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls assemble(context) once per step, and renderPrompt(assembly) is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static harness:identity section and the deployment's deployment:persona section — so they exist for every agent regardless of which loop plugin drives it.
Config
| Key | Default | Meaning |
|---|---|---|
persona |
'' |
The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 deployment:persona section and shared by every agent in the context (subagents included). A template — complete {{…}} groups are interpreted strictly against the registered variables (the shipped loop registers {{model}}/{{cwd}}), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
toolOrder |
— | Explicit model-facing tool order, as a list of ToolSchema.names with one '<unlisted-tools>' rest entry (TOOL_ORDER_REST): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the system-prompt/assemble waterfall — like the sections' order sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every assemble(); a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: Explicit model-facing tool order. |
Service: SystemPrompt (ctx key: systemPrompt)
Public API
ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | voidContribute a section. The layer is the CALLING context's scope:agent.ctxcontributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scopeddeployment:persona). Duplicate names within one layer throw. Disposed with the calling fiber.ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | voidContribute tool schemas, evaluated at each assembly with that assembly's context.ToolProviderResult={ schemas, knownNames? }:schemasis the post-restriction visible set forcontext.scope;knownNames(defaulting to the schemas' names) is the pre-restriction universetoolOrdervalidates against. A provider must not return a schema namedTOOL_ORDER_REST. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | voidContribute a prompt variable, referenced from section text as{{name}}. Scoped variables (viaagent.ctx) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw;undefinedmeans "no value for this assembly". Disposed with the calling fiber.ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>Assemble the prompt for one caller: the global layer merged withcontext.scope's layer (scoped shadows global). Runs through thesystem-prompt/assemblewaterfall (scope-filtered bycontext.scope). Rejects when a configuredtoolOrdernames a tool outside the providers'knownNamesuniverse (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
Events
| Event | Mode | Purpose |
|---|---|---|
system-prompt/assemble |
waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model |
system-prompt/change |
emit | A section, tool provider, or variable was registered or unregistered (possibly for one scope); deliberately unfiltered |
Key types
AssembleContext— what oneassemble()call is FOR. Merge-extensible; declaresscope?: ScopeKey(the layer selector) here, anddsh-agentdeclaresagent?: Agent(the typed DX field — never set withoutscope; useassembleContextFor(agent)). Providers must tolerate absent fields (a bareassemble()carries an empty, scope-less context).PromptSection—{ name, order, text: string | ((context) => string) }. Sections are concatenated in ascendingorder. Order bands:-100is the harness identity,0the deployment persona (both registered by this plugin), tool guidance uses100–199; other negative orders also render before the persona.PromptAssembly—{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }. Section texts arrive resolved but not yet interpolated;variablesholds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.renderPrompt(assembly)— interpolates{{variable}}references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (Object.hasOwnlookup — prototype names like{{constructor}}are unknown), a registered-but-valueless reference, a malformed complete{{…}}group, or a{{that opens no complete group while a}}still follows ({{{model}}}) throws — fail loud beats shipping a malformed prompt. A lone{{with no}}anywhere after it passes through verbatim; substituted values are never re-scanned.
Merge-extensible: plugins can declare extra fields on PromptAssembly and AssembleContext via declaration merging.
Extension points
- Section providers: tool packages own their cross-call guidance (
tool:bash,tool:read, …); this plugin ownsharness:identityanddeployment:persona. - Variable providers: the agent loop registers
modelandcwd; any plugin can register the facts it owns (a futuredate, git state, …). - Tool schema providers:
ToolRegistryregisters itself as a tool provider automatically. - The
system-prompt/assemblewaterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables).
What is NOT here
- Any deployment-authored prompt text outside config — the persona is this plugin's
personaconfig, and every other section comes from the plugin that owns the fact. (Theharness:identityline is deliberately a code literal: a harness fact, not a deployment choice; thesystem-prompt/assemblewaterfall is the escape valve for a deployment that must drop it.) - Prompt compaction (belongs on the
agent/pre-stepseam indsh-agent).
Design rationale: the prompt-variables RFC.