/** * 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. * * @module @deepseek-ai/dsh-system-prompt */ import { Context, Service } from 'cordis' import z from 'schemastery' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope' import type { ToolSchema } from '@deepseek-ai/dsh-llm' declare module 'cordis' { interface Context { systemPrompt: SystemPrompt } 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. * @mode waterfall */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise /** * 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. * @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. */ 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?: ScopeKey } /** One contributed section of the system prompt (registry input). */ export interface PromptSection { /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ readonly name: string /** * Sections are concatenated in ascending order. Convention: `-100` is the * harness identity, `0` the deployment persona, tool guidance uses 100–199; * other negative orders also render before the persona. */ readonly order: number /** * Static text or a provider evaluated at each assembly with that assembly's * {@link AssembleContext}. The text may reference `{{variable}}`s — they are * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) } /** One section of an assembly: {@link PromptSection} with its text resolved. */ export interface AssembledSection { /** The contributing section's unique name. */ name: string /** The contributing section's order (sections arrive sorted ascending). */ order: number /** The resolved (but not yet interpolated) section text. */ 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). */ export interface ToolProviderResult { /** The schemas this provider contributes to THIS assembly. */ readonly schemas: readonly ToolSchema[] /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */ readonly knownNames?: readonly string[] } /** * 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. */ export interface PromptAssembly { sections: AssembledSection[] tools: ToolSchema[] variables: Record } /** Valid variable names: how they are written between the braces. */ 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. */ export const TOOL_ORDER_REST = '' /** * 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. */ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined { if (toolOrder === undefined) return undefined const seen = new Set() for (const name of toolOrder) { if (seen.has(name)) throw new Error(`toolOrder lists "${name}" more than once`) seen.add(name) } if (!seen.has(TOOL_ORDER_REST)) { throw new Error(`toolOrder must contain the "${TOOL_ORDER_REST}" rest entry (where unlisted tools are inserted)`) } return toolOrder } /** * 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. */ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet): ToolSchema[] { const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST) if (reserved !== undefined) { throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`) } if (toolOrder === undefined) return tools.sort(compareToolNames) const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !knownNames.has(name)) if (unknown.length > 0) { throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; known tools: ${[...knownNames].sort().join(', ') || '(none)'}`) } const listed = new Set(toolOrder) const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames) return toolOrder.flatMap(name => name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name)) } /** 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 } /** 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. */ 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} (`''`) 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. */ 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). */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections .map(section => interpolate(section, assembly.variables)) .filter(text => text.length > 0) .join('\n\n') } /** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */ function interpolate(section: AssembledSection, variables: Record): string { const text = section.text let result = '' let last = 0 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. 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)`) } result += text.slice(last, open + 2) 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. 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. 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)'}`) } const value = variables[name] if (value === undefined) { throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (section "${section.name}")`) } result += text.slice(last, open) + value last = open + group[0].length } 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}). */ export class SystemPrompt extends Service { static Config: z = 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. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) private sections: PromptSection[] = [] private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = [] private variableProviders = new Map string | undefined>() /** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */ private scopedSections = new Map() private scopedToolProviders = new Map ToolProviderResult)[]>() private scopedVariableProviders = new Map string | undefined>>() private readonly toolOrder: string[] | undefined constructor(ctx: Context, public 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. this.section({ name: 'harness:identity', order: -100, text: 'You are an AI agent powered by the DeepSeek Harness SDK.', }) 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. 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. */ section(section: PromptSection): () => void { if (!Number.isFinite(section.order)) { throw new TypeError(`prompt section "${section.name}" order must be a finite number`) } const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined ? this.sections : this.scopedSections.get(scope) ?? (() => { const created: PromptSection[] = [] this.scopedSections.set(scope, created) return created })() if (layer.some(existing => existing.name === section.name)) { throw new Error(scope === undefined ? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)` : `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. yield () => { const index = layer.indexOf(section) /* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */ if (index >= 0) layer.splice(index, 1) if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope) this.ctx.emit('system-prompt/change') } 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. // 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. */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined ? this.toolProviders : this.scopedToolProviders.get(scope) ?? (() => { const created: ((context: AssembleContext) => ToolProviderResult)[] = [] this.scopedToolProviders.set(scope, created) return created })() layer.push(provider) // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). yield () => { const index = layer.indexOf(provider) /* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */ if (index >= 0) layer.splice(index, 1) if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope) this.ctx.emit('system-prompt/change') } 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. // 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. */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void { if (!VARIABLE_NAME.test(name)) { throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`) } const scope = scopeOf(this.ctx) const dispose = this.ctx.effect(function* (this: SystemPrompt) { const layer = scope === undefined ? this.variableProviders : this.scopedVariableProviders.get(scope) ?? (() => { const created = new Map string | undefined>() this.scopedVariableProviders.set(scope, created) return created })() if (layer.has(name)) { throw new Error(scope === undefined ? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)` : `prompt variable "${name}" is already registered in this scope`) } layer.set(name, provider) // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). yield () => { layer.delete(name) if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope) this.ctx.emit('system-prompt/change') } 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. // 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. */ // 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). async assemble(context: AssembleContext = {}): Promise { 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). const variables: Record = {} for (const [name, provider] of this.variableProviders) { variables[name] = provider(context) } const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope) 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). const sectionByName = new Map() 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. const providers = [ ...this.toolProviders, ...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [], ] const collected: ToolSchema[] = [] const knownNames = new Set() for (const provider of providers) { const result = provider(context) const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({ name, description, parameters: structuredClone(parameters), })) const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name) collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) } const assembly: PromptAssembly = { sections: [...sectionByName.values()] .map(section => ({ name: section.name, order: section.order, text: typeof section.text === 'function' ? section.text(context) : section.text, })) .sort((a, b) => a.order - b.order), tools: orderTools(collected, this.toolOrder, knownNames), variables, } return this.ctx.waterfall( scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly), ) } } export default SystemPrompt