refactor(core): simplify tools prompts and trusted services
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# dsh-system-prompt
|
||||
|
||||
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, named prompt variables, and owner-final named protections; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
|
||||
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; contributions that implement required protocol may declare themselves owner-final. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -13,22 +13,20 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The registry reads `name`, `order`, and the text value/callback once, validates their fixed string/finite-number/string-or-function types, and stores only that accepted record; later caller-object mutation cannot rename or reshape it. The layer is the CALLING context's scope: `agent.ctx` contributes to that agent alone, SHADOWING a same-named global section there (the per-agent persona mechanism — a scoped `deployment:persona`). Duplicate names within one layer throw, and a globally protected section name cannot be shadowed. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context; a non-function provider rejects before effect storage. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set for `context.scope`; `knownNames` (defaulting to the same captured schemas' names) is the pre-restriction universe `toolOrder` validates against. Assembly reads the result, each schema field, and the optional known-name list once before detaching them, rejects non-string schema names/descriptions or known names, and uses those same accepted strings for validation and the model-visible collection. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | void` Contribute a prompt variable, referenced from section text as `{{name}}`. The fixed string name and function provider types reject before effect storage. Scoped variables (via `agent.ctx`) shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.protect(protection: PromptProtection): () => Promise<void> | void` Make named section/tool contributions owner-final after the assembly waterfall. Protection restores canonical registry/provider presence and definition; restored entries keep canonical order with one another and anchor before their first surviving later unprotected canonical neighbor (or at the end), without undoing listener reordering of unprotected entries. Canonical absence is owner-final too, so a mode-hidden tool cannot be fabricated by a listener. Calling through `agent.ctx` protects only that agent's assemblies. A global section protection additionally reserves its name against scoped shadows; registering either side of that conflict fails loudly instead of treating the shadow as canonical. Each optional field and array slot is read once, non-array fields or non-string names reject before effect storage, and the accepted arrays are deduplicated and frozen. Finalization materializes each waterfall-produced entry name once, so a stateful getter cannot evade canonical replacement. Empty protections throw, and disposal removes the protection.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer (scoped shadows global). Provider output becomes one coherent detached snapshot before `toolOrder` validation. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores protected contributions from the pre-waterfall canonical assembly. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe (a restricted-away KNOWN tool is a normal absence), or when a provider returns the reserved rest-entry name.
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => Promise<void> | void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => Promise<void> | void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores owner-final contributions from a private pre-waterfall snapshot. Restored entries keep canonical relative order without undoing listener reordering of ordinary entries. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
|
||||
|
||||
### Live events
|
||||
|
||||
Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Named protections apply only after a successful assembly waterfall returns and are owned by the service rather than represented as another event listener.
|
||||
Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Owner-final restoration applies only after a successful assembly waterfall returns.
|
||||
|
||||
### Key types
|
||||
|
||||
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context).
|
||||
- `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona.
|
||||
- `PromptSection` — `{ name, order, text, ownerFinal? }`. Sections are concatenated in ascending `order`; `ownerFinal` is reserved for required protocol instructions. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
|
||||
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
|
||||
- `PromptProtection` — `{ sections?: readonly string[], tools?: readonly string[] }`. Named contributions whose canonical pre-waterfall state is restored after all listeners; protections compose by set union rather than callback order, and global section names are reserved against scoped shadows.
|
||||
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
|
||||
|
||||
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
|
||||
@@ -39,7 +37,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
|
||||
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
|
||||
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
|
||||
- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables).
|
||||
- `systemPrompt.protect()`: reserve canonical section/tool contributions for invariants that ordinary waterfall listeners must not be able to remove or replace.
|
||||
- Owner-final contributions: protocol owners declare finality on the section or tool contribution itself; there is no independent protection registry.
|
||||
|
||||
### What is NOT here
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -33,7 +32,6 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections,
|
||||
* tool schema providers, named prompt variables, and owner-final named
|
||||
* protections; `assemble(context)` collates them through a waterfall that
|
||||
* runs once per step, restores protected contributions, and `renderPrompt`
|
||||
* tool schema providers, and named prompt variables; protocol contributions
|
||||
* may declare themselves owner-final. `assemble(context)` collates them through a waterfall that
|
||||
* runs once per step, restores owner-final contributions, and `renderPrompt`
|
||||
* interpolates `{{variable}}` references into the final text.
|
||||
*
|
||||
* The harness-owned prompt openers live here too: this plugin registers the
|
||||
@@ -18,7 +18,6 @@ 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'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -45,7 +44,7 @@ declare module 'cordis' {
|
||||
*/
|
||||
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
/**
|
||||
* A section, tool provider, variable provider, or protection was registered
|
||||
* 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
|
||||
@@ -81,19 +80,25 @@ export interface AssembleContext {
|
||||
/** One contributed section of the system prompt (registry input). */
|
||||
export interface PromptSection {
|
||||
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
|
||||
name: string
|
||||
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.
|
||||
*/
|
||||
order: number
|
||||
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}.
|
||||
*/
|
||||
text: string | ((context: AssembleContext) => string)
|
||||
readonly text: string | ((context: AssembleContext) => string)
|
||||
/**
|
||||
* Whether this section's canonical presence and definition survive the
|
||||
* complete assembly waterfall. Use this only for owner-required protocol
|
||||
* instructions; ordinary sections remain transformable.
|
||||
*/
|
||||
readonly ownerFinal?: boolean
|
||||
}
|
||||
|
||||
/** One section of an assembly: {@link PromptSection} with its text resolved. */
|
||||
@@ -118,30 +123,15 @@ export interface AssembledSection {
|
||||
*/
|
||||
export interface ToolProviderResult {
|
||||
/** The schemas this provider contributes to THIS assembly. */
|
||||
schemas: ToolSchema[]
|
||||
readonly schemas: readonly ToolSchema[]
|
||||
/** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
|
||||
knownNames?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical prompt contributions that survive the assembly waterfall.
|
||||
*
|
||||
* Protection is declarative by contribution name rather than an ordered
|
||||
* callback: after every `system-prompt/assemble` listener has finished, the
|
||||
* service restores each protected name to the exact presence and definition
|
||||
* produced by its registries before the waterfall. Restored entries keep
|
||||
* canonical order with one another and anchor before their first surviving
|
||||
* later unprotected canonical neighbor (or at the end); the service does not
|
||||
* undo a listener's reordering of unprotected entries. A name absent from that
|
||||
* canonical assembly is removed from the result. This makes mode-dependent
|
||||
* absence protectable too (for example, a native tool that intentionally stays
|
||||
* off the wire in Code Mode).
|
||||
*/
|
||||
export interface PromptProtection {
|
||||
/** Section names whose canonical presence and definition are restored after the waterfall. */
|
||||
sections?: readonly string[]
|
||||
/** Tool names whose canonical presence and definition are restored after the waterfall. */
|
||||
tools?: readonly string[]
|
||||
readonly knownNames?: readonly string[]
|
||||
/**
|
||||
* Tool names this provider owns finally. The names need not be present in
|
||||
* `schemas`: naming a mode-hidden tool makes its canonical absence final, so
|
||||
* an assembly listener cannot fabricate it onto the wire.
|
||||
*/
|
||||
readonly ownerFinalNames?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,76 +224,28 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
|
||||
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
|
||||
}
|
||||
|
||||
/** Snapshot one waterfall-produced named entry with a stable, own data `name`. */
|
||||
function snapshotNamedEntry<T extends { name: string }>(entry: T): { entry: T; name: string } {
|
||||
// Read the name exactly once before protection matching. The waterfall owns
|
||||
// its output and may return accessor-backed records; retaining such an entry
|
||||
// would let a getter answer "unprotected" during filtering and the protected
|
||||
// name later when a consumer reads the final assembly.
|
||||
const name = entry.name
|
||||
const snapshot: Record<string, unknown> = {}
|
||||
Object.defineProperty(snapshot, 'name', {
|
||||
value: name,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
// Copy every other enumerable field once while deliberately skipping name.
|
||||
// defineProperty keeps a literal "__proto__" extension field ordinary data.
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (key === 'name') continue
|
||||
Object.defineProperty(snapshot, key, {
|
||||
value: (entry as unknown as Record<string, unknown>)[key],
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
return { entry: snapshot as T, name }
|
||||
}
|
||||
|
||||
/** Restore protected named entries from `canonical`, anchored before their next unprotected canonical neighbor. */
|
||||
function restoreProtected<T extends { name: string }>(
|
||||
canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet<string>,
|
||||
/** Restore owner-final entries from `canonical`, anchored before their next ordinary canonical neighbor. */
|
||||
function restoreOwnerFinal<T extends { name: string }>(
|
||||
canonical: readonly T[], result: readonly T[], ownerFinalNames: ReadonlySet<string>,
|
||||
): T[] {
|
||||
const restored = result
|
||||
.map(snapshotNamedEntry)
|
||||
.filter(record => !protectedNames.has(record.name))
|
||||
const restored = result.filter(entry => !ownerFinalNames.has(entry.name))
|
||||
for (const [index, entry] of canonical.entries()) {
|
||||
if (!protectedNames.has(entry.name)) continue
|
||||
if (!ownerFinalNames.has(entry.name)) continue
|
||||
// Protected entries are inserted in canonical order. Anchor each one
|
||||
// before the first later UNPROTECTED canonical neighbor that survived the
|
||||
// waterfall; if none survived, it belongs at the end. Looking only at
|
||||
// unprotected neighbors avoids reversing adjacent protected entries.
|
||||
// ordinary neighbors avoids reversing adjacent owner-final entries.
|
||||
const following = new Set(
|
||||
canonical.slice(index + 1)
|
||||
.filter(candidate => !protectedNames.has(candidate.name))
|
||||
.filter(candidate => !ownerFinalNames.has(candidate.name))
|
||||
.map(candidate => candidate.name),
|
||||
)
|
||||
const next = restored.findIndex(candidate => following.has(candidate.name))
|
||||
restored.splice(next < 0 ? restored.length : next, 0, {
|
||||
entry: structuredClone(entry),
|
||||
name: entry.name,
|
||||
})
|
||||
// `canonical` is an owned snapshot made before the waterfall; no second
|
||||
// clone is needed when moving its entries into the finalized assembly.
|
||||
restored.splice(next < 0 ? restored.length : next, 0, entry)
|
||||
}
|
||||
return restored.map(record => record.entry)
|
||||
}
|
||||
|
||||
/** Validate and detach one protection-name array without rereading an element. */
|
||||
function snapshotProtectionNames(value: unknown, field: 'sections' | 'tools'): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeError(`systemPrompt.protect() ${field} must be an array of strings`)
|
||||
}
|
||||
const names: string[] = []
|
||||
const length = value.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const name: unknown = value[index]
|
||||
if (typeof name !== 'string') {
|
||||
throw new TypeError(`systemPrompt.protect() ${field} must be an array of strings`)
|
||||
}
|
||||
names.push(name)
|
||||
}
|
||||
return Object.freeze([...new Set(names)])
|
||||
return restored
|
||||
}
|
||||
|
||||
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
|
||||
@@ -423,7 +365,7 @@ function interpolate(section: AssembledSection, variables: Record<string, string
|
||||
/**
|
||||
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
|
||||
* sections, tool-schema providers, named prompt variables, and owner-final
|
||||
* contribution protections; the agent loop calls `assemble(context)` once per
|
||||
* contributions; the agent loop calls `assemble(context)` once per
|
||||
* step. Registers the harness-owned `harness:identity` and
|
||||
* `deployment:persona` sections itself (see {@link Config.persona}).
|
||||
*/
|
||||
@@ -442,12 +384,10 @@ export class SystemPrompt extends Service {
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
private protections: PromptProtection[] = []
|
||||
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
|
||||
private scopedSections = new Map<ScopeKey, PromptSection[]>()
|
||||
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
|
||||
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
|
||||
private scopedProtections = new Map<ScopeKey, PromptProtection[]>()
|
||||
private readonly toolOrder: string[] | undefined
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
@@ -480,13 +420,11 @@ export class SystemPrompt extends Service {
|
||||
* scoped context (`agent.ctx`) contributes to that scope alone — and a
|
||||
* scoped section SHADOWS a same-named global section for that scope's
|
||||
* assemblies (most-specific-wins; this is how a per-agent persona overrides
|
||||
* `deployment:persona`) unless that global name is protected: global
|
||||
* protection reserves its section name against scoped shadows so the
|
||||
* `deployment:persona`) unless that global contribution is owner-final: it
|
||||
* reserves its section name against scoped shadows so the
|
||||
* registration owner—not a later scope—defines the canonical value. The
|
||||
* registry reads `name`, `order`, and `text` once, validates their fixed
|
||||
* string/finite-number/string-or-function types, and stores only that
|
||||
* accepted record, so later caller-object mutation cannot rename or reshape
|
||||
* a contribution. Throws if the SAME layer already has the name (a
|
||||
* 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
|
||||
@@ -497,25 +435,20 @@ export class SystemPrompt extends Service {
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
section(section: PromptSection): () => Promise<void> | void {
|
||||
const input: unknown = section
|
||||
if (typeof input !== 'object' || input === null) {
|
||||
throw new TypeError('systemPrompt.section() requires a section object')
|
||||
}
|
||||
const accepted = input as PromptSection
|
||||
const name = accepted.name
|
||||
const order = accepted.order
|
||||
const text = accepted.text
|
||||
if (typeof name !== 'string') throw new TypeError('prompt section name must be a string')
|
||||
if (typeof order !== 'number' || !Number.isFinite(order)) {
|
||||
throw new TypeError(`prompt section "${name}" order must be a finite number`)
|
||||
}
|
||||
if (typeof text !== 'string' && typeof text !== 'function') {
|
||||
throw new TypeError(`prompt section "${name}" text must be a string or function`)
|
||||
if (!Number.isFinite(section.order)) {
|
||||
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptSection = { name, order, text }
|
||||
if (scope !== undefined && this.protections.some(record => record.sections?.includes(snapshot.name))) {
|
||||
throw new Error(`prompt section "${snapshot.name}" is globally protected and cannot be shadowed in an agent scope`)
|
||||
if (scope !== undefined
|
||||
&& this.sections.some(global => global.name === section.name && global.ownerFinal === true)) {
|
||||
throw new Error(`prompt section "${section.name}" is globally owner-final and cannot be shadowed in an agent scope`)
|
||||
}
|
||||
if (scope === undefined && section.ownerFinal === true) {
|
||||
const hasScopedShadow = [...this.scopedSections.values()]
|
||||
.some(layer => layer.some(scoped => scoped.name === section.name))
|
||||
if (hasScopedShadow) {
|
||||
throw new Error(`owner-final prompt section "${section.name}" cannot be registered while a scoped shadow exists`)
|
||||
}
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
@@ -525,18 +458,18 @@ export class SystemPrompt extends Service {
|
||||
this.scopedSections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.some(existing => existing.name === snapshot.name)) {
|
||||
if (layer.some(existing => existing.name === section.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `prompt section "${snapshot.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${snapshot.name}" is already registered in this scope`)
|
||||
? `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(snapshot)
|
||||
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(snapshot)
|
||||
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)
|
||||
@@ -560,8 +493,7 @@ export class SystemPrompt extends Service {
|
||||
* `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 non-function provider is rejected before any effect is stored.
|
||||
* A provider must not return a schema named
|
||||
* 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`.
|
||||
@@ -571,9 +503,6 @@ export class SystemPrompt extends Service {
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void {
|
||||
if (typeof provider !== 'function') {
|
||||
throw new TypeError('system prompt tool provider must be a function')
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
@@ -611,8 +540,7 @@ export class SystemPrompt extends Service {
|
||||
* 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. The fixed name and callback types are
|
||||
* validated before effect storage. Throws on a name that does not match
|
||||
* 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.
|
||||
@@ -623,13 +551,8 @@ export class SystemPrompt extends Service {
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void {
|
||||
const inputName: unknown = name
|
||||
if (typeof inputName !== 'string') throw new TypeError('prompt variable name must be a string')
|
||||
if (!VARIABLE_NAME.test(inputName)) {
|
||||
throw new Error(`invalid prompt variable name "${inputName}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
if (typeof provider !== 'function') {
|
||||
throw new TypeError(`prompt variable "${inputName}" provider must be a function`)
|
||||
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) {
|
||||
@@ -663,91 +586,6 @@ export class SystemPrompt extends Service {
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect named section/tool contributions from the assembly waterfall.
|
||||
* The layer is decided by the calling context: a global protection applies
|
||||
* to every assembly, while one registered through `agent.ctx` applies only
|
||||
* to that agent's scope. The name's canonical registry/provider output is
|
||||
* restored AFTER the whole waterfall, so listener registration order cannot
|
||||
* strip, replace, duplicate, or fabricate it. Canonical absence is restored
|
||||
* too: if the protected name is intentionally absent for an assembly, a
|
||||
* listener-injected entry with that name is removed. Each optional field and
|
||||
* array slot is read once; non-array fields or non-string names reject before
|
||||
* effect storage, and the accepted deduplicated arrays are frozen. During
|
||||
* finalization each waterfall-produced entry name is likewise read once into
|
||||
* an owned data record, so a stateful getter cannot look unprotected during
|
||||
* filtering and later impersonate a protected name. An empty protection
|
||||
* throws because it cannot affect output.
|
||||
* Removed with the calling fiber and emits `system-prompt/change` on
|
||||
* registration/unregistration. A global section protection also reserves the
|
||||
* name against scoped section shadows; registering protection when such a
|
||||
* shadow already exists fails loudly instead of protecting the wrong owner.
|
||||
* @param protection - section and/or tool names whose canonical presence and definitions are restored after the waterfall.
|
||||
* @returns the exact Cordis effect disposer that removes the protection.
|
||||
*/
|
||||
protect(protection: PromptProtection): () => Promise<void> | void {
|
||||
const input: unknown = protection
|
||||
if (typeof input !== 'object' || input === null) {
|
||||
throw new TypeError('systemPrompt.protect() requires a protection object')
|
||||
}
|
||||
const accepted = input as PromptProtection
|
||||
const inputSections = accepted.sections
|
||||
const inputTools = accepted.tools
|
||||
const sections = inputSections === undefined
|
||||
? undefined
|
||||
: snapshotProtectionNames(inputSections, 'sections')
|
||||
const tools = inputTools === undefined
|
||||
? undefined
|
||||
: snapshotProtectionNames(inputTools, 'tools')
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptProtection = Object.freeze({
|
||||
...sections !== undefined ? { sections } : {},
|
||||
...tools !== undefined ? { tools } : {},
|
||||
})
|
||||
if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) {
|
||||
throw new Error('systemPrompt.protect() requires at least one section or tool name')
|
||||
}
|
||||
if (scope === undefined && snapshot.sections !== undefined) {
|
||||
const protectedSections = new Set(snapshot.sections)
|
||||
const conflicts = [...this.scopedSections.values()]
|
||||
.flatMap(layer => layer.filter(section => protectedSections.has(section.name)).map(section => section.name))
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(`systemPrompt.protect() cannot globally protect section${conflicts.length > 1 ? 's' : ''} ${[...new Set(conflicts)].map(name => `"${name}"`).join(', ')} while scoped shadows are registered`)
|
||||
}
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.protections
|
||||
: this.scopedProtections.get(scope) ?? (() => {
|
||||
const created: PromptProtection[] = []
|
||||
this.scopedProtections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
layer.push(snapshot)
|
||||
yield () => {
|
||||
const index = layer.indexOf(snapshot)
|
||||
/* v8 ignore next 3 -- defensive: protection was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedProtections.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.protect()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** Resolve the owner-final names registered for one assembly scope. */
|
||||
private protectedNames(scope: ScopeKey | undefined): { sections: Set<string>; tools: Set<string> } {
|
||||
const records = [
|
||||
...this.protections,
|
||||
...(scope === undefined ? [] : this.scopedProtections.get(scope)) ?? [],
|
||||
]
|
||||
return {
|
||||
sections: new Set(records.flatMap(record => record.sections ?? [])),
|
||||
tools: new Set(records.flatMap(record => record.tools ?? [])),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the current prompt for one caller: the global layer merged with
|
||||
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
|
||||
@@ -760,14 +598,12 @@ export class SystemPrompt extends Service {
|
||||
* 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`.
|
||||
* Each provider result and schema field is read once; those same captured
|
||||
* names drive both `toolOrder` validation and the model-visible collection.
|
||||
* Tool schemas are deep-cloned because adapters and request waterfalls may
|
||||
* mutate schema objects. Runs through the `system-prompt/assemble`
|
||||
* Tool schemas are detached because assembly waterfalls may mutate them.
|
||||
* Runs through the `system-prompt/assemble`
|
||||
* waterfall, giving listeners the opportunity to mutate or replace the
|
||||
* assembly, then restores every visible {@link PromptProtection} from the
|
||||
* pre-waterfall canonical assembly. Like the sections' `order` sort, tool
|
||||
* canonicalization happens on the initial assembly; unprotected listener
|
||||
* assembly, then restores every contribution whose owner declared it final
|
||||
* from the pre-waterfall canonical assembly. Like the sections' `order` sort, tool
|
||||
* canonicalization happens on the initial assembly; ordinary listener
|
||||
* output owns its own determinism. Await the result before reading the
|
||||
* assembly values — waterfall listeners may be async.
|
||||
* Interpolation happens later, in {@link renderPrompt}.
|
||||
@@ -780,10 +616,6 @@ export class SystemPrompt extends Service {
|
||||
// (`assemble().catch(...)` would miss it).
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||
const scope = context.scope
|
||||
// Protection is a registry input too: snapshot which names are protected
|
||||
// at assembly start. Registrations that land while an async waterfall is
|
||||
// in flight affect the NEXT assembly, matching the other registries.
|
||||
const protectedNames = this.protectedNames(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<string, string | undefined> = {}
|
||||
@@ -803,6 +635,11 @@ export class SystemPrompt extends Service {
|
||||
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
|
||||
sectionByName.set(section.name, section)
|
||||
}
|
||||
const ownerFinalSections = new Set(
|
||||
[...sectionByName.values()]
|
||||
.filter(section => section.ownerFinal === true)
|
||||
.map(section => section.name),
|
||||
)
|
||||
// Tools: consult the global providers plus the scope's, each with this
|
||||
// assembly's context. `schemas` are what the model may see (already
|
||||
// post-restriction, per provider); `knownNames` (defaulting to the
|
||||
@@ -815,45 +652,18 @@ export class SystemPrompt extends Service {
|
||||
]
|
||||
const collected: ToolSchema[] = []
|
||||
const knownNames = new Set<string>()
|
||||
const ownerFinalTools = new Set<string>()
|
||||
for (const provider of providers) {
|
||||
const result = provider(context)
|
||||
// One provider result snapshot: `schemas`, `knownNames`, and each schema
|
||||
// field may be accessor-backed. The same captured names must drive both
|
||||
// toolOrder validation and the model-visible collection.
|
||||
const inputSchemas = result.schemas
|
||||
const inputKnownNames = result.knownNames
|
||||
const schemas = inputSchemas.map((tool, index): ToolSchema => {
|
||||
const name = tool.name
|
||||
const description = tool.description
|
||||
const inputParameters = tool.parameters
|
||||
if (typeof name !== 'string') {
|
||||
throw new TypeError(`system prompt tool schema at index ${index} name must be a string`)
|
||||
}
|
||||
if (typeof description !== 'string') {
|
||||
throw new TypeError(`system prompt tool "${name}" description must be a string`)
|
||||
}
|
||||
const parameters = snapshotJsonValue(inputParameters)
|
||||
if (parameters === undefined) {
|
||||
throw new TypeError(`system prompt tool "${name}" parameters must be losslessly JSON-serializable`)
|
||||
}
|
||||
return { name, description, parameters }
|
||||
})
|
||||
let acceptedKnownNames: string[]
|
||||
if (inputKnownNames === undefined) {
|
||||
acceptedKnownNames = schemas.map(tool => tool.name)
|
||||
} else {
|
||||
if (!Array.isArray(inputKnownNames)) {
|
||||
throw new TypeError('system prompt tool provider knownNames must be an array of strings')
|
||||
}
|
||||
acceptedKnownNames = Array.from(inputKnownNames, (name) => {
|
||||
if (typeof name !== 'string') {
|
||||
throw new TypeError('system prompt tool provider knownNames must be an array of strings')
|
||||
}
|
||||
return name
|
||||
})
|
||||
}
|
||||
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)
|
||||
for (const name of result.ownerFinalNames ?? []) ownerFinalTools.add(name)
|
||||
}
|
||||
const assembly: PromptAssembly = {
|
||||
sections: [...sectionByName.values()]
|
||||
@@ -866,11 +676,11 @@ export class SystemPrompt extends Service {
|
||||
tools: orderTools(collected, this.toolOrder, knownNames),
|
||||
variables,
|
||||
}
|
||||
// Snapshot only the fields protection can restore. The waterfall receives
|
||||
// Snapshot only the owner-final fields. The waterfall receives
|
||||
// `assembly` by reference and may mutate it or return a replacement; these
|
||||
// independent snapshots remain the authoritative registry product.
|
||||
const canonicalSections = protectedNames.sections.size > 0 ? structuredClone(assembly.sections) : undefined
|
||||
const canonicalTools = protectedNames.tools.size > 0 ? structuredClone(assembly.tools) : undefined
|
||||
const canonicalSections = ownerFinalSections.size > 0 ? structuredClone(assembly.sections) : undefined
|
||||
const canonicalTools = ownerFinalTools.size > 0 ? structuredClone(assembly.tools) : undefined
|
||||
const result = await this.ctx.waterfall(
|
||||
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
|
||||
() => Promise.resolve(assembly),
|
||||
@@ -881,10 +691,10 @@ export class SystemPrompt extends Service {
|
||||
return {
|
||||
...result,
|
||||
...canonicalSections !== undefined
|
||||
? { sections: restoreProtected(canonicalSections, result.sections, protectedNames.sections) }
|
||||
? { sections: restoreOwnerFinal(canonicalSections, result.sections, ownerFinalSections) }
|
||||
: {},
|
||||
...canonicalTools !== undefined
|
||||
? { tools: restoreProtected(canonicalTools, result.tools, protectedNames.tools) }
|
||||
? { tools: restoreOwnerFinal(canonicalTools, result.tools, ownerFinalTools) }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,19 +63,16 @@ describe('scoped sections', () => {
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['reserved'], 'section "reserved"'],
|
||||
[['first', 'second'], 'sections "first", "second"'],
|
||||
])('rejects global protection added after scoped shadows (%j)', async (names, message) => {
|
||||
it('rejects a global owner-final section added after a scoped shadow', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
for (const name of names) {
|
||||
scope.ctx.systemPrompt.section({ name, order: 1, text: `scoped ${name}` })
|
||||
}
|
||||
scope.ctx.systemPrompt.section({ name: 'reserved', order: 1, text: 'scoped reserved' })
|
||||
|
||||
expect(() => ctx.systemPrompt.protect({ sections: names })).toThrow(message)
|
||||
expect(() => ctx.systemPrompt.section({
|
||||
name: 'reserved', order: 1, text: 'global reserved', ownerFinal: true,
|
||||
})).toThrow('owner-final prompt section "reserved"')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
|
||||
.toContain(`scoped ${names[0]}`)
|
||||
.toContain('scoped reserved')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,13 +161,18 @@ describe('scoped assemble dispatch', () => {
|
||||
expect(shaped).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a scoped protection finalizes only its own assemblies and disappears with the scope', async () => {
|
||||
it('scoped owner-final contributions finalize only their assemblies and disappear with the scope', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const key = scopeKeyOf(scope)
|
||||
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
|
||||
scope.ctx.systemPrompt.protect({ sections: ['required'], tools: ['required'] })
|
||||
scope.ctx.systemPrompt.section({
|
||||
name: 'required', order: 10, text: 'scoped required', ownerFinal: true,
|
||||
})
|
||||
scope.ctx.systemPrompt.tools(() => ({
|
||||
schemas: [schema('required')], ownerFinalNames: ['required'],
|
||||
}))
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = result.sections.filter(section => section.name !== 'required')
|
||||
|
||||
@@ -111,86 +111,14 @@ describe('SystemPrompt', () => {
|
||||
expect(contributed(assembly).map(s => s.text)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('rejects malformed fixed registration fields before storing an effect', async () => {
|
||||
it('rejects a non-finite section order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
const badName = { value: 'name' }
|
||||
const badText = { value: 'text' }
|
||||
|
||||
expect(() => ctx.systemPrompt.section(null as unknown as Parameters<typeof ctx.systemPrompt.section>[0]))
|
||||
.toThrow('requires a section object')
|
||||
expect(() => ctx.systemPrompt.section(1 as unknown as Parameters<typeof ctx.systemPrompt.section>[0]))
|
||||
.toThrow('requires a section object')
|
||||
expect(() => ctx.systemPrompt.section({ name: badName as unknown as string, order: 1, text: 'x' }))
|
||||
.toThrow('prompt section name must be a string')
|
||||
expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: '1' as unknown as number, text: 'x' }))
|
||||
.toThrow('order must be a finite number')
|
||||
expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: Number.NaN, text: 'x' }))
|
||||
.toThrow('order must be a finite number')
|
||||
expect(() => ctx.systemPrompt.section({ name: 'bad-text', order: 1, text: badText as unknown as string }))
|
||||
.toThrow('text must be a string or function')
|
||||
expect(() => ctx.systemPrompt.tools(1 as unknown as Parameters<typeof ctx.systemPrompt.tools>[0]))
|
||||
.toThrow('tool provider must be a function')
|
||||
expect(() => ctx.systemPrompt.variable({} as unknown as string, () => 'x'))
|
||||
.toThrow('prompt variable name must be a string')
|
||||
expect(() => ctx.systemPrompt.variable('valid', 1 as unknown as Parameters<typeof ctx.systemPrompt.variable>[1]))
|
||||
.toThrow('provider must be a function')
|
||||
expect(() => ctx.systemPrompt.protect(null as unknown as Parameters<typeof ctx.systemPrompt.protect>[0]))
|
||||
.toThrow('requires a protection object')
|
||||
expect(() => ctx.systemPrompt.protect(1 as unknown as Parameters<typeof ctx.systemPrompt.protect>[0]))
|
||||
.toThrow('requires a protection object')
|
||||
expect(() => ctx.systemPrompt.protect({ sections: 'x' as unknown as string[] }))
|
||||
.toThrow('sections must be an array of strings')
|
||||
expect(() => ctx.systemPrompt.protect({ tools: 'x' as unknown as string[] }))
|
||||
.toThrow('tools must be an array of strings')
|
||||
expect(() => ctx.systemPrompt.protect({ sections: ['ok', {} as unknown as string] }))
|
||||
.toThrow('sections must be an array of strings')
|
||||
expect(() => ctx.systemPrompt.protect({ tools: [{} as unknown as string] }))
|
||||
.toThrow('tools must be an array of strings')
|
||||
|
||||
expect(Object.isFrozen(badName)).toBe(false)
|
||||
expect(Object.isFrozen(badText)).toBe(false)
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
|
||||
})
|
||||
|
||||
it('reads each section field and protection-name slot once at registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
const reads = { name: 0, order: 0, text: 0, sections: 0, item: 0 }
|
||||
const section = Object.defineProperties({}, {
|
||||
name: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.name === 1 ? 'stable' : 42),
|
||||
},
|
||||
order: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.order === 1 ? 10 : Number.NaN),
|
||||
},
|
||||
text: {
|
||||
enumerable: true,
|
||||
get: () => (++reads.text === 1 ? 'stable text' : null),
|
||||
},
|
||||
}) as unknown as Parameters<typeof ctx.systemPrompt.section>[0]
|
||||
const names = new Array<string>(1)
|
||||
Object.defineProperty(names, 0, {
|
||||
enumerable: true,
|
||||
get: () => (++reads.item === 1 ? 'stable' : 'drifted'),
|
||||
})
|
||||
const protection = {
|
||||
get sections(): string[] {
|
||||
reads.sections += 1
|
||||
return reads.sections === 1 ? names : ['drifted']
|
||||
},
|
||||
}
|
||||
|
||||
ctx.systemPrompt.section(section)
|
||||
ctx.systemPrompt.protect(protection)
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
|
||||
expect(reads).toEqual({ name: 1, order: 1, text: 1, sections: 1, item: 1 })
|
||||
expect(assembly.sections).toContainEqual({ name: 'stable', order: 10, text: 'stable text' })
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -285,28 +213,21 @@ describe('SystemPrompt', () => {
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('canonical contribution protection', () => {
|
||||
it('restores exact protected definitions after every listener, in canonical relative order', async () => {
|
||||
describe('owner-final contributions', () => {
|
||||
it('restores exact owner-final definitions after every listener, in canonical relative order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section' })
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section', ownerFinal: true })
|
||||
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [
|
||||
{ name: 'alpha', description: 'alpha', parameters: {} },
|
||||
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
|
||||
{ name: 'zulu', description: 'zulu', parameters: {} },
|
||||
] }))
|
||||
const protection = { sections: ['protected'], tools: ['protected'] }
|
||||
ctx.systemPrompt.protect(protection)
|
||||
// Registration snapshots its arrays; caller mutation cannot change what
|
||||
// the service makes authoritative.
|
||||
protection.sections[0] = 'after'
|
||||
protection.tools[0] = 'zulu'
|
||||
], ownerFinalNames: ['protected'] }))
|
||||
|
||||
// Registered AFTER the protection and prepended: it is outside every
|
||||
// ordinary listener that existed when protect() ran, but service-level
|
||||
// finalization still restores the canonical entries after it returns.
|
||||
// Service-level finalization restores the canonical entries after the
|
||||
// complete listener chain returns.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
return Object.freeze({
|
||||
@@ -338,121 +259,18 @@ describe('SystemPrompt', () => {
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
|
||||
})
|
||||
|
||||
it('reads protection accessors once so the checked names are the protected names', async () => {
|
||||
it('makes an owner-final tool\'s canonical absence survive the waterfall', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
|
||||
let reads = 0
|
||||
const protection = {
|
||||
get sections(): string[] {
|
||||
reads += 1
|
||||
return reads === 1 ? ['protected'] : undefined as unknown as string[]
|
||||
},
|
||||
}
|
||||
ctx.systemPrompt.protect(protection)
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [], ownerFinalNames: ['mode-hidden'] }))
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = result.sections.filter(section => section.name !== 'protected')
|
||||
return result
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(assembly.sections).toContainEqual({ name: 'protected', order: 10, text: 'canonical' })
|
||||
})
|
||||
|
||||
it('materializes waterfall entry names once before restoring protected definitions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical section' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'protected', description: 'canonical tool', parameters: {} }] }))
|
||||
ctx.systemPrompt.protect({ sections: ['protected'], tools: ['protected'] })
|
||||
let sectionNameReads = 0
|
||||
let toolNameReads = 0
|
||||
const hostileSection = {
|
||||
get name(): string {
|
||||
sectionNameReads += 1
|
||||
return sectionNameReads === 1 ? 'impostor-section' : 'protected'
|
||||
},
|
||||
order: 999,
|
||||
text: 'listener section',
|
||||
}
|
||||
const hostileTool = {
|
||||
get name(): string {
|
||||
toolNameReads += 1
|
||||
return toolNameReads === 1 ? 'impostor-tool' : 'protected'
|
||||
},
|
||||
description: 'listener tool',
|
||||
parameters: {},
|
||||
}
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = [
|
||||
...result.sections.filter(section => section.name !== 'protected'),
|
||||
hostileSection,
|
||||
]
|
||||
result.tools = [
|
||||
...result.tools.filter(tool => tool.name !== 'protected'),
|
||||
hostileTool,
|
||||
]
|
||||
return result
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
|
||||
expect(sectionNameReads).toBe(1)
|
||||
expect(toolNameReads).toBe(1)
|
||||
expect(assembly.sections.map(section => section.name)).toEqual([
|
||||
'harness:identity',
|
||||
'deployment:persona',
|
||||
'impostor-section',
|
||||
'protected',
|
||||
])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['impostor-tool', 'protected'])
|
||||
})
|
||||
|
||||
it('protects canonical absence and rejects an empty protection', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
// Separate registrations exercise the set-union contract: protections
|
||||
// may name only sections or only tools and still compose.
|
||||
ctx.systemPrompt.protect({ sections: ['mode-hidden'] })
|
||||
ctx.systemPrompt.protect({ tools: ['mode-hidden'] })
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections.push({ name: 'mode-hidden', order: 100, text: 'fabricated' })
|
||||
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
|
||||
return result
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.some(section => section.name === 'mode-hidden')).toBe(false)
|
||||
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
|
||||
expect(() => ctx.systemPrompt.protect({})).toThrow(/at least one section or tool name/)
|
||||
expect(() => ctx.systemPrompt.protect({ sections: [], tools: [] })).toThrow(/at least one section or tool name/)
|
||||
})
|
||||
|
||||
it('removes a protection with its contributing fiber (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = result.sections.filter(section => section.name !== 'protected')
|
||||
return result
|
||||
})
|
||||
let changes = 0
|
||||
ctx.on('system-prompt/change', () => { changes++ })
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.protect({ sections: ['protected'] })
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(true)
|
||||
expect(changes).toBe(1)
|
||||
await fiber.dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(false)
|
||||
expect(changes).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -48,100 +48,6 @@ describe('SystemPrompt tool order', () => {
|
||||
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
|
||||
})
|
||||
|
||||
it('reads provider schemas once so toolOrder validates the model-visible collection', async () => {
|
||||
const ctx = await mount({ toolOrder: ['actual', TOOL_ORDER_REST] })
|
||||
let reads = 0
|
||||
ctx.systemPrompt.tools(() => ({
|
||||
get schemas(): ToolSchema[] {
|
||||
reads += 1
|
||||
return reads === 1 ? [tool('actual')] : [tool('phantom')]
|
||||
},
|
||||
}))
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(names(assembly)).toEqual(['actual'])
|
||||
})
|
||||
|
||||
it('reads each provider schema field once before detaching it', async () => {
|
||||
const ctx = await mount()
|
||||
const accepted = { type: 'object', properties: { accepted: { type: 'string' } } }
|
||||
let reads = 0
|
||||
const schema = {
|
||||
name: 'stable',
|
||||
description: 'stable',
|
||||
get parameters(): object {
|
||||
reads += 1
|
||||
return reads === 1 ? accepted : { type: 'object', properties: { drifted: { type: 'number' } } }
|
||||
},
|
||||
} as ToolSchema
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [schema] }))
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(assembly.tools[0]?.parameters).toEqual(accepted)
|
||||
})
|
||||
|
||||
it('rejects exotic provider parameters before model-visible assembly', async () => {
|
||||
const ctx = await mount()
|
||||
class ExoticParameters {
|
||||
readonly type = 'object'
|
||||
readonly properties = { value: { type: 'string' } }
|
||||
}
|
||||
ctx.systemPrompt.tools(() => ({
|
||||
schemas: [{
|
||||
name: 'exotic',
|
||||
description: 'must not be sanitized',
|
||||
parameters: new ExoticParameters() as unknown as ToolSchema['parameters'],
|
||||
}],
|
||||
}))
|
||||
|
||||
await expect(ctx.systemPrompt.assemble())
|
||||
.rejects.toThrow(/parameters must be losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it('rejects malformed fixed provider fields without freezing caller objects', async () => {
|
||||
const ctx = await mount()
|
||||
const badName = { value: 'object-name' }
|
||||
const badDescription = { value: 'object-description' }
|
||||
ctx.systemPrompt.tools(() => ({
|
||||
schemas: [{
|
||||
name: badName as unknown as string,
|
||||
description: 'bad name',
|
||||
parameters: {},
|
||||
}],
|
||||
}))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('name must be a string')
|
||||
expect(Object.isFrozen(badName)).toBe(false)
|
||||
|
||||
const descriptions = await mount()
|
||||
descriptions.systemPrompt.tools(() => ({
|
||||
schemas: [{
|
||||
name: 'bad-description',
|
||||
description: badDescription as unknown as string,
|
||||
parameters: {},
|
||||
}],
|
||||
}))
|
||||
await expect(descriptions.systemPrompt.assemble()).rejects.toThrow('description must be a string')
|
||||
expect(Object.isFrozen(badDescription)).toBe(false)
|
||||
|
||||
const knownNames = await mount()
|
||||
knownNames.systemPrompt.tools(() => ({
|
||||
schemas: [tool('valid')],
|
||||
knownNames: [{} as unknown as string],
|
||||
}))
|
||||
await expect(knownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings')
|
||||
|
||||
const nonArrayKnownNames = await mount()
|
||||
nonArrayKnownNames.systemPrompt.tools(() => ({
|
||||
schemas: [tool('valid')],
|
||||
knownNames: 'valid' as unknown as string[],
|
||||
}))
|
||||
await expect(nonArrayKnownNames.systemPrompt.assemble()).rejects.toThrow('knownNames must be an array of strings')
|
||||
})
|
||||
|
||||
it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
|
||||
const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] }))
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user