Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/pr224-simplification-audit
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# dsh-system-prompt
|
||||
|
||||
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; contributions that implement required protocol may declare themselves owner-final. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent.
|
||||
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -13,19 +13,19 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool-
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. `ownerFinal: true` restores this section's canonical definition after the complete waterfall and reserves a global section against scoped shadows. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames?, ownerFinalNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`; `ownerFinalNames` makes those tools' canonical presence or absence survive the waterfall. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores owner-final contributions from a private pre-waterfall snapshot. Restored entries keep canonical relative order without undoing listener reordering of ordinary entries. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
|
||||
|
||||
### Live events
|
||||
|
||||
Prompt assembly is the scope-filtered transformable seam; registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope. Exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Owner-final restoration applies only after a successful assembly waterfall returns.
|
||||
`system-prompt/assemble` is an expert cooperative seam: its returned assembly is authoritative, and a listener that replaces or removes entries owns preserving any active Code Mode or structured-output protocol. Prefer [`ToolRegistry.restrict()`](../tools/README.md) when tool filtering must stay aligned across model presentation, lookup, and execution. Registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope; exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md).
|
||||
|
||||
### Key types
|
||||
|
||||
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context).
|
||||
- `PromptSection` — `{ name, order, text, ownerFinal? }`. Sections are concatenated in ascending `order`; `ownerFinal` is reserved for required protocol instructions. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
|
||||
- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
|
||||
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
|
||||
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
|
||||
|
||||
@@ -36,8 +36,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
|
||||
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
|
||||
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
|
||||
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
|
||||
- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables).
|
||||
- Owner-final contributions: protocol owners declare finality on the section or tool contribution itself; there is no independent protection registry.
|
||||
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
|
||||
|
||||
### What is NOT here
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections,
|
||||
* tool schema providers, and named prompt variables; protocol contributions
|
||||
* may declare themselves owner-final. `assemble(context)` collates them through a waterfall that
|
||||
* runs once per step, restores owner-final contributions, and `renderPrompt`
|
||||
* tool schema providers, and named prompt variables; `assemble(context)`
|
||||
* collates them through a waterfall that runs once per step, and `renderPrompt`
|
||||
* interpolates `{{variable}}` references into the final text.
|
||||
*
|
||||
* The harness-owned prompt openers live here too: this plugin registers the
|
||||
@@ -30,13 +29,18 @@ declare module 'cordis' {
|
||||
* {@link PromptAssembly} (sections + tools + variables) before it is
|
||||
* rendered. Bound to the {@link SystemPrompt} service; call `next()` to
|
||||
* delegate.
|
||||
* @param assembly - the assembly built from the registered sections, tool
|
||||
* providers, and variable providers; listeners may mutate it or return a
|
||||
* replacement.
|
||||
*
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
|
||||
* by `context.scope` — a listener registered through `agent.ctx` fires only
|
||||
* for that agent's assemblies; a plain plugin listener fires for every
|
||||
* assembly (scope-less ones included, dispatched subject-less).
|
||||
*
|
||||
* The returned assembly is authoritative. This is an expert composition
|
||||
* seam: a listener that removes or replaces another plugin's protocol
|
||||
* contribution owns preserving that protocol's invariants.
|
||||
* @param assembly - the assembly built from the registered sections, tool
|
||||
* providers, and variable providers; listeners may mutate it or return a
|
||||
* replacement.
|
||||
* @param context - the per-assembly {@link AssembleContext} the caller
|
||||
* passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt
|
||||
* is for), so a listener can filter or extend per agent.
|
||||
@@ -93,12 +97,6 @@ export interface PromptSection {
|
||||
* interpolated later, by {@link renderPrompt}.
|
||||
*/
|
||||
readonly text: string | ((context: AssembleContext) => string)
|
||||
/**
|
||||
* Whether this section's canonical presence and definition survive the
|
||||
* complete assembly waterfall. Use this only for owner-required protocol
|
||||
* instructions; ordinary sections remain transformable.
|
||||
*/
|
||||
readonly ownerFinal?: boolean
|
||||
}
|
||||
|
||||
/** One section of an assembly: {@link PromptSection} with its text resolved. */
|
||||
@@ -128,12 +126,6 @@ export interface ToolProviderResult {
|
||||
readonly schemas: readonly ToolSchema[]
|
||||
/** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
|
||||
readonly knownNames?: readonly string[]
|
||||
/**
|
||||
* Tool names this provider owns finally. The names need not be present in
|
||||
* `schemas`: naming a mode-hidden tool makes its canonical absence final, so
|
||||
* an assembly listener cannot fabricate it onto the wire.
|
||||
*/
|
||||
readonly ownerFinalNames?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,30 +218,6 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
|
||||
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
|
||||
}
|
||||
|
||||
/** Restore owner-final entries from `canonical`, anchored before their next ordinary canonical neighbor. */
|
||||
function restoreOwnerFinal<T extends { name: string }>(
|
||||
canonical: readonly T[], result: readonly T[], ownerFinalNames: ReadonlySet<string>,
|
||||
): T[] {
|
||||
const restored = result.filter(entry => !ownerFinalNames.has(entry.name))
|
||||
for (const [index, entry] of canonical.entries()) {
|
||||
if (!ownerFinalNames.has(entry.name)) continue
|
||||
// Protected entries are inserted in canonical order. Anchor each one
|
||||
// before the first later UNPROTECTED canonical neighbor that survived the
|
||||
// waterfall; if none survived, it belongs at the end. Looking only at
|
||||
// ordinary neighbors avoids reversing adjacent owner-final entries.
|
||||
const following = new Set(
|
||||
canonical.slice(index + 1)
|
||||
.filter(candidate => !ownerFinalNames.has(candidate.name))
|
||||
.map(candidate => candidate.name),
|
||||
)
|
||||
const next = restored.findIndex(candidate => following.has(candidate.name))
|
||||
// `canonical` is an owned snapshot made before the waterfall; no second
|
||||
// clone is needed when moving its entries into the finalized assembly.
|
||||
restored.splice(next < 0 ? restored.length : next, 0, entry)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
|
||||
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
||||
@@ -366,10 +334,10 @@ function interpolate(section: AssembledSection, variables: Record<string, string
|
||||
|
||||
/**
|
||||
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
|
||||
* sections, tool-schema providers, named prompt variables, and owner-final
|
||||
* contributions; the agent loop calls `assemble(context)` once per
|
||||
* step. Registers the harness-owned `harness:identity` and
|
||||
* `deployment:persona` sections itself (see {@link Config.persona}).
|
||||
* sections, tool-schema providers, and named prompt variables; the agent loop
|
||||
* calls `assemble(context)` once per step. Registers the harness-owned
|
||||
* `harness:identity` and `deployment:persona` sections itself (see
|
||||
* {@link Config.persona}).
|
||||
*/
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -422,10 +390,8 @@ export class SystemPrompt extends Service {
|
||||
* scoped context (`agent.ctx`) contributes to that scope alone — and a
|
||||
* scoped section SHADOWS a same-named global section for that scope's
|
||||
* assemblies (most-specific-wins; this is how a per-agent persona overrides
|
||||
* `deployment:persona`) unless that global contribution is owner-final: it
|
||||
* reserves its section name against scoped shadows so the
|
||||
* registration owner—not a later scope—defines the canonical value. The
|
||||
* readonly typed contribution is borrowed until disposal; only the semantic
|
||||
* `deployment:persona`). The readonly typed contribution is borrowed until
|
||||
* disposal; only the semantic
|
||||
* finite-order rule is checked at runtime. Throws if the SAME layer already has the name (a
|
||||
* duplicate would silently double prompt text — e.g. a double-loaded tool
|
||||
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
|
||||
@@ -441,17 +407,6 @@ export class SystemPrompt extends Service {
|
||||
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
if (scope !== undefined
|
||||
&& this.sections.some(global => global.name === section.name && global.ownerFinal === true)) {
|
||||
throw new Error(`prompt section "${section.name}" is globally owner-final and cannot be shadowed in an agent scope`)
|
||||
}
|
||||
if (scope === undefined && section.ownerFinal === true) {
|
||||
const hasScopedShadow = [...this.scopedSections.values()]
|
||||
.some(layer => layer.some(scoped => scoped.name === section.name))
|
||||
if (hasScopedShadow) {
|
||||
throw new Error(`owner-final prompt section "${section.name}" cannot be registered while a scoped shadow exists`)
|
||||
}
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.sections
|
||||
@@ -604,11 +559,10 @@ export class SystemPrompt extends Service {
|
||||
* name restricted away for this scope is a normal absence), and every
|
||||
* visible variable resolved against `context` into `assembly.variables`.
|
||||
* Tool schemas are detached because assembly waterfalls may mutate them.
|
||||
* Runs through the `system-prompt/assemble`
|
||||
* waterfall, giving listeners the opportunity to mutate or replace the
|
||||
* assembly, then restores every contribution whose owner declared it final
|
||||
* from the pre-waterfall canonical assembly. Like the sections' `order` sort, tool
|
||||
* canonicalization happens on the initial assembly; ordinary listener
|
||||
* Runs through the `system-prompt/assemble` waterfall, giving listeners the
|
||||
* opportunity to mutate or replace the assembly; the returned value is the
|
||||
* authoritative model-visible composition. Like the sections' `order`
|
||||
* sort, tool canonicalization happens on the initial assembly; listener
|
||||
* output owns its own determinism. Await the result before reading the
|
||||
* assembly values — waterfall listeners may be async.
|
||||
* Interpolation happens later, in {@link renderPrompt}.
|
||||
@@ -640,11 +594,6 @@ export class SystemPrompt extends Service {
|
||||
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
|
||||
sectionByName.set(section.name, section)
|
||||
}
|
||||
const ownerFinalSections = new Set(
|
||||
[...sectionByName.values()]
|
||||
.filter(section => section.ownerFinal === true)
|
||||
.map(section => section.name),
|
||||
)
|
||||
// Tools: consult the global providers plus the scope's, each with this
|
||||
// assembly's context. `schemas` are what the model may see (already
|
||||
// post-restriction, per provider); `knownNames` (defaulting to the
|
||||
@@ -657,7 +606,6 @@ export class SystemPrompt extends Service {
|
||||
]
|
||||
const collected: ToolSchema[] = []
|
||||
const knownNames = new Set<string>()
|
||||
const ownerFinalTools = new Set<string>()
|
||||
for (const provider of providers) {
|
||||
const result = provider(context)
|
||||
const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
|
||||
@@ -668,7 +616,6 @@ export class SystemPrompt extends Service {
|
||||
const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)
|
||||
collected.push(...schemas)
|
||||
for (const name of acceptedKnownNames) knownNames.add(name)
|
||||
for (const name of result.ownerFinalNames ?? []) ownerFinalTools.add(name)
|
||||
}
|
||||
const assembly: PromptAssembly = {
|
||||
sections: [...sectionByName.values()]
|
||||
@@ -681,27 +628,10 @@ export class SystemPrompt extends Service {
|
||||
tools: orderTools(collected, this.toolOrder, knownNames),
|
||||
variables,
|
||||
}
|
||||
// Snapshot only the owner-final fields. The waterfall receives
|
||||
// `assembly` by reference and may mutate it or return a replacement; these
|
||||
// independent snapshots remain the authoritative registry product.
|
||||
const canonicalSections = ownerFinalSections.size > 0 ? structuredClone(assembly.sections) : undefined
|
||||
const canonicalTools = ownerFinalTools.size > 0 ? structuredClone(assembly.tools) : undefined
|
||||
const result = await this.ctx.waterfall(
|
||||
return this.ctx.waterfall(
|
||||
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
|
||||
() => Promise.resolve(assembly),
|
||||
)
|
||||
// Build a replacement instead of mutating the waterfall result: a
|
||||
// listener may legitimately return a frozen assembly. Merge-extensible
|
||||
// fields ride through the spread untouched.
|
||||
return {
|
||||
...result,
|
||||
...canonicalSections !== undefined
|
||||
? { sections: restoreOwnerFinal(canonicalSections, result.sections, ownerFinalSections) }
|
||||
: {},
|
||||
...canonicalTools !== undefined
|
||||
? { tools: restoreOwnerFinal(canonicalTools, result.tools, ownerFinalTools) }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,17 +63,6 @@ describe('scoped sections', () => {
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it('rejects a global owner-final section added after a scoped shadow', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
scope.ctx.systemPrompt.section({ name: 'reserved', order: 1, text: 'scoped reserved' })
|
||||
|
||||
expect(() => ctx.systemPrompt.section({
|
||||
name: 'reserved', order: 1, text: 'global reserved', ownerFinal: true,
|
||||
})).toThrow('owner-final prompt section "reserved"')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
|
||||
.toContain('scoped reserved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped variables', () => {
|
||||
@@ -161,35 +150,4 @@ describe('scoped assemble dispatch', () => {
|
||||
expect(shaped).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('scoped owner-final contributions finalize only their assemblies and disappear with the scope', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const key = scopeKeyOf(scope)
|
||||
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
|
||||
scope.ctx.systemPrompt.section({
|
||||
name: 'required', order: 10, text: 'scoped required', ownerFinal: true,
|
||||
})
|
||||
scope.ctx.systemPrompt.tools(() => ({
|
||||
schemas: [schema('required')], ownerFinalNames: ['required'],
|
||||
}))
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = result.sections.filter(section => section.name !== 'required')
|
||||
result.tools = result.tools.filter(tool => tool.name !== 'required')
|
||||
return result
|
||||
}, { prepend: true })
|
||||
|
||||
const scoped = await ctx.systemPrompt.assemble({ scope: key })
|
||||
const global = await ctx.systemPrompt.assemble()
|
||||
expect(scoped.sections.some(section => section.name === 'required')).toBe(true)
|
||||
expect(scoped.tools.some(tool => tool.name === 'required')).toBe(true)
|
||||
expect(global.sections.some(section => section.name === 'required')).toBe(false)
|
||||
expect(global.tools.some(tool => tool.name === 'required')).toBe(false)
|
||||
|
||||
await scope.dispose()
|
||||
const disposed = await ctx.systemPrompt.assemble({ scope: key })
|
||||
expect(disposed.sections.some(section => section.name === 'required')).toBe(false)
|
||||
expect(disposed.tools.some(tool => tool.name === 'required')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -213,67 +213,6 @@ describe('SystemPrompt', () => {
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('owner-final contributions', () => {
|
||||
it('restores exact owner-final definitions after every listener, in canonical relative order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section', ownerFinal: true })
|
||||
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [
|
||||
{ name: 'alpha', description: 'alpha', parameters: {} },
|
||||
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
|
||||
{ name: 'zulu', description: 'zulu', parameters: {} },
|
||||
], ownerFinalNames: ['protected'] }))
|
||||
|
||||
// Service-level finalization restores the canonical entries after the
|
||||
// complete listener chain returns.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
return Object.freeze({
|
||||
sections: [
|
||||
...result.sections.filter(section => section.name !== 'protected'),
|
||||
{ name: 'protected', order: -999, text: 'wrong section' },
|
||||
{ name: 'protected', order: 999, text: 'duplicate section' },
|
||||
],
|
||||
tools: [
|
||||
...result.tools.filter(tool => tool.name !== 'protected'),
|
||||
{ name: 'protected', description: 'wrong tool', parameters: {} },
|
||||
{ name: 'protected', description: 'duplicate tool', parameters: {} },
|
||||
],
|
||||
variables: result.variables,
|
||||
})
|
||||
}, { prepend: true })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const protectedSections = assembly.sections.filter(section => section.name === 'protected')
|
||||
const protectedTools = assembly.tools.filter(tool => tool.name === 'protected')
|
||||
expect(protectedSections).toEqual([{ name: 'protected', order: 20, text: 'canonical section' }])
|
||||
expect(protectedTools).toEqual([{
|
||||
name: 'protected',
|
||||
description: 'canonical tool',
|
||||
parameters: { type: 'object', properties: { answer: { type: 'number' } } },
|
||||
}])
|
||||
expect(assembly.sections.map(section => section.name).indexOf('protected'))
|
||||
.toBeLessThan(assembly.sections.map(section => section.name).indexOf('after'))
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
|
||||
})
|
||||
|
||||
it('makes an owner-final tool\'s canonical absence survive the waterfall', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [], ownerFinalNames: ['mode-hidden'] }))
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
|
||||
return result
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
Reference in New Issue
Block a user