Merge remote-tracking branch 'origin/master' into codex/simp-prune-tools-prompt-surface

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/session/README.md
#	packages/core/session/tests/derived-cache.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-14 17:30:34 +08:00
562 changed files with 4438 additions and 12565 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. 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 sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
## Config
@@ -20,7 +20,7 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
### Live events
`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).
`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated [event catalog](../../../docs/cordis-catalog/events.md) owns signatures and dispatch contracts.
### Key types

View File

@@ -1,13 +1,5 @@
/**
* System prompt assembly registry. Plugins contribute ordered text sections,
* 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
* static `harness:identity` section (order 100) and the deployment's
* `deployment:persona` section (order 0, from its `persona` config), so they
* exist for every agent regardless of which loop plugin drives it.
* Registry for ordered prompt sections, tool schemas, and prompt variables.
*
* @module @deepseek-ai/dsh-system-prompt
*/
@@ -25,58 +17,28 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall around prompt assembly — mutate or extend the
* {@link PromptAssembly} (sections + tools + variables) before it is
* rendered. Bound to the {@link SystemPrompt} service; call `next()` to
* delegate.
*
* 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.
* Expert waterfall over the assembled sections, tools, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* @param assembly - the mutable assembly built from registered providers.
* @param context - the caller's per-assembly context.
* @mode waterfall
*/
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
/**
* A section, tool provider, or variable provider was registered
* or unregistered (the assembly inputs changed — possibly for one scope
* only). An UNFILTERED registry-subject notification, deliberately not
* scope-filtered dispatch: a global change concerns every agent's next
* assembly, so a scoped listener subscribing here sees every change, not
* just its own scope's.
* Emitted when any prompt provider changes. This registry notification is
* unfiltered because a global change affects every scope.
* @mode emit
*/
'system-prompt/change'(): void
}
}
/**
* Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR.
* Merge-extensible and agnostic of who assembles — `@deepseek-ai/dsh-agent`
* declares the `agent` field, so section text and variable providers can be
* functions of the calling agent. Every field is optional by nature: a bare
* `assemble()` (tests, diagnostics) carries an empty, scope-less context, and
* providers must tolerate absent fields.
*/
/** Merge-extensible context for one prompt assembly. */
export interface AssembleContext {
/**
* The scope layer this assembly resolves (`@deepseek-ai/dsh-scope`): scoped
* sections/variables/tool-providers registered through this key's context
* join the assembly (shadowing same-named global contributions), and the
* `system-prompt/assemble` waterfall dispatches in this scope. The agent
* loop sets it to the agent (alongside the `agent` DX field — never set
* `agent` without `scope`; the dev invariants flag the mismatch). Absent =
* a scope-less assembly: global layer only, subject-less dispatch.
* Scope whose providers and waterfall listeners participate. When absent,
* only global providers and subject-less listeners participate.
*/
scope?: ScopeKey
}
@@ -111,16 +73,7 @@ export interface AssembledSection {
text: string
}
/**
* What one tool-schema provider contributes to an assembly
* ({@link SystemPrompt.tools}). `schemas` is the provider's POST-restriction
* visible set for the assembly's scope — exactly what the model may be shown.
* `knownNames` is its PRE-restriction name universe: the set configured names
* (`toolOrder`) are validated against, so a config typo fails loud while a
* restricted-away tool stays a normal, non-erroneous absence. Omitted,
* `knownNames` defaults to the names of `schemas` (right for providers with no
* restriction concept).
*/
/** Tool schemas visible in one assembly and their pre-restriction name set. */
export interface ToolProviderResult {
/** The schemas this provider contributes to THIS assembly. */
readonly schemas: readonly ToolSchema[]
@@ -129,20 +82,8 @@ export interface ToolProviderResult {
}
/**
* The assembled prompt.
*
* Tool schemas are part of the assembly by design: "what the model is told it
* can do" is one coherent thing managed here, even though adapters transmit
* `tools` as a separate wire field rather than prompt text. They arrive in
* the canonical model-facing order (see {@link Config.toolOrder}).
*
* `variables` carries every registered prompt variable resolved against this
* assembly's context — key present means registered, `undefined` value means
* "no value for this assembly" (referencing it renders an error). Section
* texts are resolved but NOT yet interpolated; {@link renderPrompt} applies
* the variables, so waterfall listeners can still add sections or variables.
*
* Merge-extensible: plugins can declare extra fields on this interface.
* Merge-extensible assembled prompt. Sections remain uninterpolated until
* {@link renderPrompt}; tools are already in canonical model-facing order.
*/
export interface PromptAssembly {
sections: AssembledSection[]
@@ -156,22 +97,12 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
/** A complete `{{...}}` reference group at the scan position (validated after). */
const GROUP_AT = /^\{\{([^{}]*)\}\}/
/**
* The rest entry for {@link Config.toolOrder}: the position where registered
* tools not named in the list are inserted (in lexicographic name order).
* Reserved: collected tool schemas using this name are rejected before
* ordering, so the marker can never collide with a real model-facing tool.
*/
/** Reserved {@link Config.toolOrder} marker for unlisted tools. */
export const TOOL_ORDER_REST = '<unlisted-tools>'
/**
* Validate a configured tool-order list's shape at service construction:
* the {@link TOOL_ORDER_REST} rest entry exactly once, no duplicate names.
* Returns the list (or undefined when unconfigured); throws otherwise,
* failing the service at load — a bad order config must never reach an
* assembly. Whether every listed name matches a registered tool is checked
* at each assembly instead ({@link orderTools}): tool plugins register after
* this service constructs, so the tool set does not exist yet here.
* Validate duplicate names and the required {@link TOOL_ORDER_REST} marker.
* Registered names are checked later because plugins have not loaded yet.
*/
function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined {
if (toolOrder === undefined) return undefined
@@ -187,20 +118,9 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine
}
/**
* Order collected tool schemas by the validated policy: with no configured
* list, plain lexicographic name order; with one, listed names take their
* listed position and every unlisted tool lands at the
* {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed
* name outside `knownNames` — the providers' PRE-restriction name universe —
* throws: misconfiguration fails loud, and each assembly is the earliest
* moment the registered tool set exists to check against (tool plugins
* register after the service constructs, so load time is too early); the
* assembly rejects, failing the caller's turn before any model request. A
* listed name that is KNOWN but not collected (a tool restricted away for
* this assembly's scope) is a normal absence: its position simply
* contributes nothing — `toolOrder` stays compatible with per-agent
* `restrict()` masks. Never drops a collected tool, and both sorts are
* stable, so tools sharing a name keep their collection order.
* Apply configured tool order, inserting unlisted tools lexicographically at
* {@link TOOL_ORDER_REST}. Unknown configured names fail; known but restricted
* names may be absent.
*/
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet<string>): ToolSchema[] {
const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
@@ -226,62 +146,25 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number {
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
export interface Config {
/**
* The deployment's persona — the ONE deployment-authored fragment of the
* system prompt, rendered as the order-0 `deployment:persona` section
* (after the harness identity, before all tool guidance). Every agent in
* the context shares it by default; a per-agent persona is a SCOPED section
* of the same name registered through that agent's `agent.ctx` (it shadows
* this one for that agent — the subagent seam's `persona` request field does
* exactly that). Template, not free-form text:
* every complete `{{…}}` group is interpreted strictly against the
* registered prompt variables (the shipped agent loop registers `{{model}}`
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
* yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
* `''` — the empty section is dropped at render, so a persona-less
* deployment opens with the harness identity alone.
* Deployment-wide order-0 persona template. A scoped section named
* `deployment:persona` shadows it; `{{variable}}` references are strict.
*/
persona?: string
/**
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
* tools take their listed position, and tools absent from the list are
* inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
* lexicographic name order. A configured list must contain the rest entry
* exactly once, no duplicate names, and no name without a registered tool —
* a misconfigured order blocks work instead of silently reaching a model
* request: shape violations throw at load, and an unregistered name rejects
* every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
* not be a collected tool name; such a provider output also rejects the
* assembly. The single assembly-time validation rejects either failure
* before any model request — the earliest moment the registered tool set
* exists to check against, since tool plugins register after this service
* constructs. When omitted, tools are ordered lexicographically by name.
* Applied to the tools
* {@link SystemPrompt.assemble} collects, BEFORE the
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
* canonicalizes what the registry contributed (registration order is a
* plugin-load artifact); a waterfall listener that mutates the tool list
* owns the determinism of what it emits. Rationale (and why not per-plugin
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
* Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
* Shape errors fail at load and unknown names fail at assembly; known names
* hidden in one scope may be absent there. Omitted means lexicographic order.
*/
toolOrder?: string[]
}
/**
* Renders the text part of an assembly: interpolates `{{variable}}`
* references in each section from `assembly.variables`, drops empty sections,
* and joins the rest with blank lines.
*
* Strict by design (fail loud beats shipping a malformed prompt): a reference
* to an unregistered variable, to a registered variable with no value for
* this assembly, a complete `{{…}}` group that is not a well-formed variable
* name (e.g. `{{ model }}`), or a `{{` that does not open a complete group
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
* through verbatim. Substituted values are never re-scanned.
* @param assembly - the assembly to render (typically the awaited result of
* {@link SystemPrompt.assemble}); only `sections` and `variables` are read.
* @returns the full system prompt text; `''` when every section renders empty
* (the caller then sends no system prompt at all).
* Interpolate strict `{{variable}}` references, drop empty sections, and join
* the rest with blank lines. Malformed, unknown, or undefined references throw;
* a lone `{{` without any later `}}` is literal prose, and substituted values
* are not scanned again.
* @param assembly - the assembly whose sections and variables to render.
* @returns the rendered prompt, or `''` when all sections are empty.
*/
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
@@ -298,10 +181,7 @@ function interpolate(section: AssembledSection, variables: Record<string, string
for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {
const group = GROUP_AT.exec(text.slice(open))
if (group === null) {
// No complete simple group starts at this `{{`. A `}}` further on means
// a mangled reference (extra or nested braces) — fail loud. With no
// closing `}}` anywhere after, it is ordinary prose (shell, JSON) and
// passes through verbatim.
// A later closing brace makes this malformed; otherwise it is literal prose.
if (text.indexOf('}}', open + 2) >= 0) {
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`)
}
@@ -309,15 +189,12 @@ function interpolate(section: AssembledSection, variables: Record<string, string
last = open + 2
continue
}
// group[0] is the whole `{{...}}` match (a plain string, no optional
// index): the name is its interior. `{{}}` yields '' → the malformed path.
// `{{}}` yields an empty name and follows the malformed-reference path.
const name = group[0].slice(2, -2)
if (!VARIABLE_NAME.test(name)) {
throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`)
}
// Object.hasOwn, NOT `in`: `in` walks the prototype chain, so an
// unregistered `{{constructor}}` would resolve to Object.prototype's and
// splice a function's source text into the prompt instead of throwing.
// Do not resolve unregistered names through Object.prototype.
if (!Object.hasOwn(variables, name)) {
const known = Object.keys(variables)
throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
@@ -332,22 +209,11 @@ function interpolate(section: AssembledSection, variables: Record<string, string
return result + text.slice(last)
}
/**
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
* 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}).
*/
/** Registry service for the prompt inputs assembled before each model step. */
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
persona: z.string().default(''),
// A schemastery array defaults to [] when omitted, but an omitted
// toolOrder must stay absent ("lexicographic order"), not become an
// explicitly-configured empty list (which is invalid — it lacks the
// rest entry). Forcing the default to undefined keeps the key out of the
// validated config; the cast is needed because .default() expects the
// array type.
// Preserve omission because an explicit empty order lacks the rest marker.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
@@ -363,12 +229,7 @@ export class SystemPrompt extends Service {
constructor(ctx: Context, config: Config) {
super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder)
// The harness-owned openers. They live HERE (not on the loop plugin) so a
// deployment that swaps in a different loop keeps them: the identity is a
// harness fact stated ahead of everything, and the persona is the
// deployment's config, one section of the full prompt, never the whole.
// An empty persona still RESERVES the section name (one owner — a plugin
// re-registering it throws); renderPrompt drops the empty text.
// Keep harness-owned openers independent of the selected loop plugin.
this.section({
name: 'harness:identity',
order: -100,
@@ -377,30 +238,18 @@ export class SystemPrompt extends Service {
this.section({
name: 'deployment:persona',
order: 0,
// The schema already defaulted an omitted persona to ''; the ?? only
// narrows the optional-input TYPE, it never supplies a different value.
// The fallback narrows the optional input type; the schema already defaults it.
text: config.persona ?? '',
})
}
/**
* Contribute a text section to the system prompt. Order is determined by
* `section.order` (ascending). The layer is decided by the CALLING context
* (`@deepseek-ai/dsh-scope`): a plain plugin context contributes globally; a
* 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`). 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
* alternative). Removed when the calling fiber is disposed. Emits
* `system-prompt/change` on register/unregister.
* @param section - the section to contribute (name, order, text or provider).
* @returns the disposer that removes the section. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
* Register an ordered prompt section in the calling context's scope. A scoped
* section shadows a global section with the same name; duplicates within one
* layer and non-finite orders throw. Registration and disposal emit
* `system-prompt/change`.
* @param section - the section to register.
* @returns the exact Cordis effect disposer.
*/
section(section: PromptSection): () => void {
if (!Number.isFinite(section.order)) {
@@ -421,10 +270,7 @@ export class SystemPrompt extends Service {
: `prompt section "${section.name}" is already registered in this scope`)
}
layer.push(section)
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
// effect collects each yielded disposer before the next step runs, so a
// throwing change listener removes the section instead of leaking it into
// every future assembly.
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(section)
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
@@ -434,31 +280,17 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.section()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Cleanup is synchronous because this
// registration installs only synchronous state and notifications.
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/**
* Contribute a tool-schema provider, evaluated at each assembly call with
* that assembly's {@link AssembleContext} (so it reflects the live registry
* state AND the assembly's scope — see {@link ToolProviderResult} for the
* `schemas`/`knownNames` split). The layer is decided by the calling
* context: a scoped provider (registered through `agent.ctx`) is consulted
* only for that scope's assemblies. Removed when the calling fiber is
* disposed. A provider must not return a schema named
* {@link TOOL_ORDER_REST}; that name is reserved for
* {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
* `system-prompt/change`.
* @param provider - evaluated at every {@link assemble} for fresh schemas.
* @returns the disposer that removes the provider. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
* Register a tool-schema provider in the calling context's scope. Global and
* matching scoped providers both contribute; returning the reserved
* {@link TOOL_ORDER_REST} name makes assembly fail.
* @param provider - evaluated for each assembly with its context.
* @returns the exact Cordis effect disposer.
*/
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
const scope = scopeOf(this.ctx)
@@ -471,7 +303,7 @@ export class SystemPrompt extends Service {
return created
})()
layer.push(provider)
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(provider)
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
@@ -481,33 +313,18 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.tools()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Cleanup is synchronous because this
// registration installs only synchronous state and notifications.
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/**
* Contribute a named prompt variable, referenced from section text as
* `{{name}}`. The provider is evaluated at each assembly with that
* assembly's {@link AssembleContext}; returning `undefined` means "no value
* for this assembly" (a section referencing it then fails to render — a
* deployment must not claim facts it does not have). The layer is decided
* by the calling context: a scoped variable (registered through
* `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a
* same-named global variable there. Throws on a name that does not match
* `[a-z][a-z0-9_]*` (it could never be referenced) or one already registered
* in the SAME layer. Removed when the calling fiber is disposed; emits
* `system-prompt/change` on register/unregister.
* @param name - the reference name (matches `[a-z][a-z0-9_]*`).
* @param provider - evaluated at every {@link assemble} for the value.
* @returns the disposer that removes the variable. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
* yield it directly — exact identity nests the teardown in order.
* Register a prompt variable in the calling context's scope. Scoped values
* shadow globals; invalid or duplicate names throw. A provider may return
* `undefined`, but rendering a section that references that value then fails.
* @param name - the `[a-z][a-z0-9_]*` reference name.
* @param provider - evaluated for each assembly.
* @returns the exact Cordis effect disposer.
*/
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
if (!VARIABLE_NAME.test(name)) {
@@ -528,7 +345,7 @@ export class SystemPrompt extends Service {
: `prompt variable "${name}" is already registered in this scope`)
}
layer.set(name, provider)
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
// Install rollback before notifying listeners that may throw.
yield () => {
layer.delete(name)
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
@@ -536,47 +353,22 @@ export class SystemPrompt extends Service {
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.variable()')
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
// effect that owns a teardown ORDER must be able to yield THIS function —
// cordis nests a disposer out of the fiber's concurrent sibling list by
// exact function identity, so a wrapper would silently break the nesting
// (the agents.register() lesson). Cleanup is synchronous because this
// registration installs only synchronous state and notifications.
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/**
* Assemble the current prompt for one caller: the global layer merged with
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
* same-named global ones — most-specific-wins) — section texts resolved
* against `context` and sorted by order across the union, tools collected
* from the global providers plus the scope's and put in the canonical
* model-facing order ({@link Config.toolOrder}, or lexicographic name order
* when unconfigured — provider registration order is a plugin-load artifact
* and never reaches the assembly; a configured order naming a tool outside
* the providers' `knownNames` universe rejects the assembly, while a known
* 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; 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}.
* @param context - what this assembly is for (defaults to an empty context;
* see {@link AssembleContext}).
* @returns the assembly after the waterfall has run.
* Assemble global and scoped providers, detach tool parameters, apply
* canonical ordering, then run the assembly waterfall. Scoped sections and
* variables shadow globals; the returned waterfall value is authoritative.
* @param context - the optional scope and plugin-defined assembly fields.
* @returns the authoritative post-waterfall assembly.
*/
// async so the misconfigured-toolOrder throw in orderTools surfaces as a
// rejection: a Promise-returning method must not throw synchronously
// (`assemble().catch(...)` would miss it).
// Keep configuration failures on the declared asynchronous error path.
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
const scope = context.scope
// Variables: global layer first, then the scope's layer OVERWRITES
// same-named entries (shadowing — a per-agent value wins for that agent).
// Scoped variables shadow globals.
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
variables[name] = provider(context)
@@ -585,21 +377,13 @@ export class SystemPrompt extends Service {
for (const [name, provider] of scopedVariables ?? []) {
variables[name] = provider(context)
}
// Sections: merge by name, scoped REPLACING same-named global entries
// (most-specific-wins — the per-agent persona mechanism), then sort by
// order across the union. Registration order within a layer is preserved
// for equal orders (stable sort).
// Scoped sections shadow globals before the stable order sort.
const sectionByName = new Map<string, PromptSection>()
for (const section of this.sections) sectionByName.set(section.name, section)
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
sectionByName.set(section.name, section)
}
// 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
// schemas' names) form the pre-restriction universe `toolOrder` is
// validated against, so a restricted-away tool is a normal absence while
// a config typo still fails every assembly loudly.
// Validate order against pre-restriction names while collecting visible schemas.
const providers = [
...this.toolProviders,
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],