Merge branch 'codex/simp-prune-tools-prompt-surface' into codex/simp-drop-assembled-section-order
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -107,16 +69,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[]
|
||||
@@ -125,20 +78,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[]
|
||||
@@ -152,22 +93,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
|
||||
@@ -183,20 +114,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)
|
||||
@@ -222,62 +142,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
|
||||
@@ -294,10 +177,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)`)
|
||||
}
|
||||
@@ -305,15 +185,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)'}`)
|
||||
@@ -328,22 +205,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[]),
|
||||
})
|
||||
|
||||
@@ -359,12 +225,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,
|
||||
@@ -373,30 +234,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)) {
|
||||
@@ -417,10 +266,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 */
|
||||
@@ -430,31 +276,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)
|
||||
@@ -467,7 +299,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 */
|
||||
@@ -477,33 +309,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)) {
|
||||
@@ -524,7 +341,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)
|
||||
@@ -532,47 +349,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)
|
||||
@@ -581,21 +373,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)) ?? [],
|
||||
|
||||
Reference in New Issue
Block a user