Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/pr224-simplification-audit

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
This commit is contained in:
Tianyi Cui
2026-07-13 13:47:08 +08:00
43 changed files with 170 additions and 502 deletions

View File

@@ -1,6 +1,6 @@
# dsh-system-prompt
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; contributions that implement required protocol may declare themselves owner-final. 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 global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent.
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 global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent.
## Config
@@ -13,19 +13,19 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_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): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "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 with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores owner-final contributions from a private pre-waterfall snapshot. Restored entries keep canonical relative order without undoing listener reordering of ordinary entries. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
### Live events
Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Owner-final restoration applies only after a successful assembly waterfall returns.
`system-prompt/assemble` is an expert cooperative seam: its returned assembly is authoritative, and a listener that replaces or removes entries owns preserving any active Code Mode or structured-output protocol. Prefer [`ToolRegistry.restrict()`](../tools/README.md) when tool filtering must stay aligned across model presentation, lookup, and execution. Registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope; exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md).
### Key types
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context).
- `PromptSection` — `{ name, order, text, ownerFinal? }`. Sections are concatenated in ascending `order`; `ownerFinal` is reserved for required protocol instructions. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds 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.hasOwn` lookup — 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.
@@ -36,8 +36,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables).
- Owner-final contributions: protocol owners declare finality on the section or tool contribution itself; there is no independent protection registry.
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
### What is NOT here

View File

@@ -1,8 +1,7 @@
/**
* System prompt assembly registry. Plugins contribute ordered text sections,
* tool schema providers, and named prompt variables; protocol contributions
* may declare themselves owner-final. `assemble(context)` collates them through a waterfall that
* runs once per step, restores owner-final contributions, and `renderPrompt`
* tool schema providers, and named prompt variables; `assemble(context)`
* collates them through a waterfall that runs once per step, and `renderPrompt`
* interpolates `{{variable}}` references into the final text.
*
* The harness-owned prompt openers live here too: this plugin registers the
@@ -30,13 +29,18 @@ declare module 'cordis' {
* {@link PromptAssembly} (sections + tools + variables) before it is
* rendered. Bound to the {@link SystemPrompt} service; call `next()` to
* delegate.
* @param assembly - the assembly built from the registered sections, tool
* providers, and variable providers; listeners may mutate it or return a
* replacement.
*
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
* by `context.scope` — a listener registered through `agent.ctx` fires only
* for that agent's assemblies; a plain plugin listener fires for every
* assembly (scope-less ones included, dispatched subject-less).
*
* The returned assembly is authoritative. This is an expert composition
* seam: a listener that removes or replaces another plugin's protocol
* contribution owns preserving that protocol's invariants.
* @param assembly - the assembly built from the registered sections, tool
* providers, and variable providers; listeners may mutate it or return a
* replacement.
* @param context - the per-assembly {@link AssembleContext} the caller
* passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt
* is for), so a listener can filter or extend per agent.
@@ -93,12 +97,6 @@ export interface PromptSection {
* interpolated later, by {@link renderPrompt}.
*/
readonly text: string | ((context: AssembleContext) => string)
/**
* Whether this section's canonical presence and definition survive the
* complete assembly waterfall. Use this only for owner-required protocol
* instructions; ordinary sections remain transformable.
*/
readonly ownerFinal?: boolean
}
/** One section of an assembly: {@link PromptSection} with its text resolved. */
@@ -128,12 +126,6 @@ export interface ToolProviderResult {
readonly schemas: readonly ToolSchema[]
/** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
readonly knownNames?: readonly string[]
/**
* Tool names this provider owns finally. The names need not be present in
* `schemas`: naming a mode-hidden tool makes its canonical absence final, so
* an assembly listener cannot fabricate it onto the wire.
*/
readonly ownerFinalNames?: readonly string[]
}
/**
@@ -226,30 +218,6 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
}
/** Restore owner-final entries from `canonical`, anchored before their next ordinary canonical neighbor. */
function restoreOwnerFinal<T extends { name: string }>(
canonical: readonly T[], result: readonly T[], ownerFinalNames: ReadonlySet<string>,
): T[] {
const restored = result.filter(entry => !ownerFinalNames.has(entry.name))
for (const [index, entry] of canonical.entries()) {
if (!ownerFinalNames.has(entry.name)) continue
// Protected entries are inserted in canonical order. Anchor each one
// before the first later UNPROTECTED canonical neighbor that survived the
// waterfall; if none survived, it belongs at the end. Looking only at
// ordinary neighbors avoids reversing adjacent owner-final entries.
const following = new Set(
canonical.slice(index + 1)
.filter(candidate => !ownerFinalNames.has(candidate.name))
.map(candidate => candidate.name),
)
const next = restored.findIndex(candidate => following.has(candidate.name))
// `canonical` is an owned snapshot made before the waterfall; no second
// clone is needed when moving its entries into the finalized assembly.
restored.splice(next < 0 ? restored.length : next, 0, entry)
}
return restored
}
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
@@ -366,10 +334,10 @@ function interpolate(section: AssembledSection, variables: Record<string, string
/**
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
* sections, tool-schema providers, named prompt variables, and owner-final
* contributions; the agent loop calls `assemble(context)` once per
* step. Registers the harness-owned `harness:identity` and
* `deployment:persona` sections itself (see {@link Config.persona}).
* sections, tool-schema providers, and named prompt variables; the agent loop
* calls `assemble(context)` once per step. Registers the harness-owned
* `harness:identity` and `deployment:persona` sections itself (see
* {@link Config.persona}).
*/
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
@@ -422,10 +390,8 @@ export class SystemPrompt extends Service {
* scoped context (`agent.ctx`) contributes to that scope alone — and a
* scoped section SHADOWS a same-named global section for that scope's
* assemblies (most-specific-wins; this is how a per-agent persona overrides
* `deployment:persona`) unless that global contribution is owner-final: it
* reserves its section name against scoped shadows so the
* registration owner—not a later scope—defines the canonical value. The
* readonly typed contribution is borrowed until disposal; only the semantic
* `deployment:persona`). The readonly typed contribution is borrowed until
* disposal; only the semantic
* finite-order rule is checked at runtime. Throws if the SAME layer already has the name (a
* duplicate would silently double prompt text — e.g. a double-loaded tool
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
@@ -441,17 +407,6 @@ export class SystemPrompt extends Service {
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
}
const scope = scopeOf(this.ctx)
if (scope !== undefined
&& this.sections.some(global => global.name === section.name && global.ownerFinal === true)) {
throw new Error(`prompt section "${section.name}" is globally owner-final and cannot be shadowed in an agent scope`)
}
if (scope === undefined && section.ownerFinal === true) {
const hasScopedShadow = [...this.scopedSections.values()]
.some(layer => layer.some(scoped => scoped.name === section.name))
if (hasScopedShadow) {
throw new Error(`owner-final prompt section "${section.name}" cannot be registered while a scoped shadow exists`)
}
}
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.sections
@@ -604,11 +559,10 @@ export class SystemPrompt extends Service {
* name restricted away for this scope is a normal absence), and every
* visible variable resolved against `context` into `assembly.variables`.
* Tool schemas are detached because assembly waterfalls may mutate them.
* Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the
* assembly, then restores every contribution whose owner declared it final
* from the pre-waterfall canonical assembly. Like the sections' `order` sort, tool
* canonicalization happens on the initial assembly; ordinary listener
* Runs through the `system-prompt/assemble` waterfall, giving listeners the
* opportunity to mutate or replace the assembly; the returned value is the
* authoritative model-visible composition. Like the sections' `order`
* sort, tool canonicalization happens on the initial assembly; listener
* output owns its own determinism. Await the result before reading the
* assembly values — waterfall listeners may be async.
* Interpolation happens later, in {@link renderPrompt}.
@@ -640,11 +594,6 @@ export class SystemPrompt extends Service {
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
sectionByName.set(section.name, section)
}
const ownerFinalSections = new Set(
[...sectionByName.values()]
.filter(section => section.ownerFinal === true)
.map(section => section.name),
)
// Tools: consult the global providers plus the scope's, each with this
// assembly's context. `schemas` are what the model may see (already
// post-restriction, per provider); `knownNames` (defaulting to the
@@ -657,7 +606,6 @@ export class SystemPrompt extends Service {
]
const collected: ToolSchema[] = []
const knownNames = new Set<string>()
const ownerFinalTools = new Set<string>()
for (const provider of providers) {
const result = provider(context)
const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
@@ -668,7 +616,6 @@ export class SystemPrompt extends Service {
const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)
collected.push(...schemas)
for (const name of acceptedKnownNames) knownNames.add(name)
for (const name of result.ownerFinalNames ?? []) ownerFinalTools.add(name)
}
const assembly: PromptAssembly = {
sections: [...sectionByName.values()]
@@ -681,27 +628,10 @@ export class SystemPrompt extends Service {
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
// Snapshot only the owner-final fields. The waterfall receives
// `assembly` by reference and may mutate it or return a replacement; these
// independent snapshots remain the authoritative registry product.
const canonicalSections = ownerFinalSections.size > 0 ? structuredClone(assembly.sections) : undefined
const canonicalTools = ownerFinalTools.size > 0 ? structuredClone(assembly.tools) : undefined
const result = await this.ctx.waterfall(
return this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
)
// Build a replacement instead of mutating the waterfall result: a
// listener may legitimately return a frozen assembly. Merge-extensible
// fields ride through the spread untouched.
return {
...result,
...canonicalSections !== undefined
? { sections: restoreOwnerFinal(canonicalSections, result.sections, ownerFinalSections) }
: {},
...canonicalTools !== undefined
? { tools: restoreOwnerFinal(canonicalTools, result.tools, ownerFinalTools) }
: {},
}
}
}

View File

@@ -63,17 +63,6 @@ describe('scoped sections', () => {
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
it('rejects a global owner-final section added after a scoped shadow', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
scope.ctx.systemPrompt.section({ name: 'reserved', order: 1, text: 'scoped reserved' })
expect(() => ctx.systemPrompt.section({
name: 'reserved', order: 1, text: 'global reserved', ownerFinal: true,
})).toThrow('owner-final prompt section "reserved"')
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain('scoped reserved')
})
})
describe('scoped variables', () => {
@@ -161,35 +150,4 @@ describe('scoped assemble dispatch', () => {
expect(shaped).toHaveLength(1)
})
it('scoped owner-final contributions finalize only their assemblies and disappear with the scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
scope.ctx.systemPrompt.section({
name: 'required', order: 10, text: 'scoped required', ownerFinal: true,
})
scope.ctx.systemPrompt.tools(() => ({
schemas: [schema('required')], ownerFinalNames: ['required'],
}))
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'required')
result.tools = result.tools.filter(tool => tool.name !== 'required')
return result
}, { prepend: true })
const scoped = await ctx.systemPrompt.assemble({ scope: key })
const global = await ctx.systemPrompt.assemble()
expect(scoped.sections.some(section => section.name === 'required')).toBe(true)
expect(scoped.tools.some(tool => tool.name === 'required')).toBe(true)
expect(global.sections.some(section => section.name === 'required')).toBe(false)
expect(global.tools.some(tool => tool.name === 'required')).toBe(false)
await scope.dispose()
const disposed = await ctx.systemPrompt.assemble({ scope: key })
expect(disposed.sections.some(section => section.name === 'required')).toBe(false)
expect(disposed.tools.some(tool => tool.name === 'required')).toBe(false)
})
})

View File

@@ -213,67 +213,6 @@ describe('SystemPrompt', () => {
expect(assembly.sections).toHaveLength(0)
})
describe('owner-final contributions', () => {
it('restores exact owner-final definitions after every listener, in canonical relative order', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section', ownerFinal: true })
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
ctx.systemPrompt.tools(() => ({ schemas: [
{ name: 'alpha', description: 'alpha', parameters: {} },
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
{ name: 'zulu', description: 'zulu', parameters: {} },
], ownerFinalNames: ['protected'] }))
// Service-level finalization restores the canonical entries after the
// complete listener chain returns.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
return Object.freeze({
sections: [
...result.sections.filter(section => section.name !== 'protected'),
{ name: 'protected', order: -999, text: 'wrong section' },
{ name: 'protected', order: 999, text: 'duplicate section' },
],
tools: [
...result.tools.filter(tool => tool.name !== 'protected'),
{ name: 'protected', description: 'wrong tool', parameters: {} },
{ name: 'protected', description: 'duplicate tool', parameters: {} },
],
variables: result.variables,
})
}, { prepend: true })
const assembly = await ctx.systemPrompt.assemble()
const protectedSections = assembly.sections.filter(section => section.name === 'protected')
const protectedTools = assembly.tools.filter(tool => tool.name === 'protected')
expect(protectedSections).toEqual([{ name: 'protected', order: 20, text: 'canonical section' }])
expect(protectedTools).toEqual([{
name: 'protected',
description: 'canonical tool',
parameters: { type: 'object', properties: { answer: { type: 'number' } } },
}])
expect(assembly.sections.map(section => section.name).indexOf('protected'))
.toBeLessThan(assembly.sections.map(section => section.name).indexOf('after'))
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
})
it('makes an owner-final tool\'s canonical absence survive the waterfall', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.tools(() => ({ schemas: [], ownerFinalNames: ['mode-hidden'] }))
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
})
})
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)