feat(core): scope-aware registries and session dispatch carriers

dsh-tools and dsh-system-prompt gain a per-scope registration layer over
dsh-scope: a registration through a scoped context files into that scope,
shadows a same-named global contribution for that scope (per-agent persona
and tool variants), and unwinds with the scope. tools.restrict() masks the
global surface per scope (snapshot-at-registration, loud unknown-name
validation, intersection composition; scoped grants bypass). One visibility
function feeds schemas/get/execute, so prompt, presentation, and dispatch
can never disagree; out-of-view executes as UNKNOWN_TOOL.

Prompt tool providers now receive the AssembleContext and return
{schemas, knownNames}: toolOrder validates against the pre-restriction name
universe (a typo fails every assembly loudly) while ordering operates on
the post-restriction schemas (a restricted-away tool is a normal absence).

dsh-session captures each session's dispatch carrier at enter() from the
entering context's scope tag, and the new sessions.flush(session) owns the
awaited session/flush dispatch. tools/pre|post-execute and
system-prompt/assemble dispatch with scope carriers keyed by their subject;
session/created|event|flush by the owning session's scope.
This commit is contained in:
Tianyi Cui
2026-07-09 01:09:21 +08:00
parent 32db205c10
commit 3d16026eb0
16 changed files with 940 additions and 117 deletions

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
@@ -30,6 +31,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -14,6 +14,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
@@ -30,15 +32,23 @@ declare module 'cordis' {
* @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).
* @param context - the per-assembly {@link AssembleContext} the caller
* passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt
* is for), so a listener can filter or extend per agent.
* @mode waterfall
*/
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
'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).
* unregistered (the assembly inputs changed — possibly for one scope
* only). An UNFILTERED registry-subject notification, deliberately not
* scope-filtered dispatch: a global change concerns every agent's next
* assembly, so a scoped listener subscribing here sees every change, not
* just its own scope's.
* @mode emit
*/
'system-prompt/change'(): void
@@ -47,13 +57,24 @@ declare module 'cordis' {
/**
* Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR.
* Declared empty here so this package stays agnostic of who assembles;
* merge-extensible — `@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 context, and providers must tolerate absent fields.
* Merge-extensible and agnostic of who assembles — `@deepseek-ai/dsh-agent`
* declares the `agent` field, so section text and variable providers can be
* functions of the calling agent. Every field is optional by nature: a bare
* `assemble()` (tests, diagnostics) carries an empty, scope-less context, and
* providers must tolerate absent fields.
*/
export interface AssembleContext {}
export interface AssembleContext {
/**
* The scope layer this assembly resolves (`@deepseek-ai/dsh-scope`): scoped
* sections/variables/tool-providers registered through this key's context
* join the assembly (shadowing same-named global contributions), and the
* `system-prompt/assemble` waterfall dispatches in this scope. The agent
* loop sets it to the agent (alongside the `agent` DX field — never set
* `agent` without `scope`; the dev invariants flag the mismatch). Absent =
* a scope-less assembly: global layer only, subject-less dispatch.
*/
scope?: ScopeKey
}
/** One contributed section of the system prompt (registry input). */
export interface PromptSection {
@@ -83,6 +104,23 @@ 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).
*/
export interface ToolProviderResult {
/** The schemas this provider contributes to THIS assembly. */
schemas: ToolSchema[]
/** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
knownNames?: readonly string[]
}
/**
* The assembled prompt.
*
@@ -146,23 +184,26 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine
* 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 with no collected tool throws — misconfiguration fails loud, and this
* 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. Never drops a tool, and both sorts are stable, so tools sharing a
* name keep their collection order.
* name outside `knownNames` — the providers' PRE-restriction name universe —
* throws: misconfiguration fails loud, and each assembly is the earliest
* moment the registered tool set exists to check against (tool plugins
* register after the service constructs, so load time is too early); the
* assembly rejects, failing the caller's turn before any model request. A
* listed name that is KNOWN but not collected (a tool restricted away for
* this assembly's scope) is a normal absence: its position simply
* contributes nothing — `toolOrder` stays compatible with per-agent
* `restrict()` masks. Never drops a collected tool, and both sorts are
* stable, so tools sharing a name keep their collection order.
*/
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet<string>): ToolSchema[] {
const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
if (reserved !== undefined) {
throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
}
if (toolOrder === undefined) return tools.sort(compareToolNames)
const registered = new Set(tools.map(tool => tool.name))
const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name))
const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !knownNames.has(name))
if (unknown.length > 0) {
throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`)
throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; known tools: ${[...knownNames].sort().join(', ') || '(none)'}`)
}
const listed = new Set(toolOrder)
const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
@@ -181,7 +222,10 @@ 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, subagents included. Template, not free-form text:
* 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
@@ -301,8 +345,12 @@ export class SystemPrompt extends Service {
})
private sections: PromptSection[] = []
private toolProviders: (() => ToolSchema[])[] = []
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
private scopedSections = new Map<ScopeKey, PromptSection[]>()
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) {
@@ -330,27 +378,44 @@ export class SystemPrompt extends Service {
/**
* Contribute a text section to the system prompt. Order is determined by
* `section.order` (ascending). Throws if a section with the same name is
* already registered (a duplicate would silently double prompt text — e.g.
* a double-loaded tool plugin). The section is removed when the calling
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
* `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`). 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.
*/
section(section: PromptSection): () => void {
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
if (this.sections.some(existing => existing.name === section.name)) {
throw new Error(`prompt section "${section.name}" is already registered`)
const layer = scope === undefined
? this.sections
: this.scopedSections.get(scope) ?? (() => {
const created: PromptSection[] = []
this.scopedSections.set(scope, created)
return created
})()
if (layer.some(existing => existing.name === section.name)) {
throw new Error(scope === undefined
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${section.name}" is already registered in this scope`)
}
this.sections.push(section)
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 = this.sections.indexOf(section)
const index = layer.indexOf(section)
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) this.sections.splice(index, 1)
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
@@ -361,23 +426,36 @@ export class SystemPrompt extends Service {
}
/**
* Contribute a tool-schema provider that is evaluated at each assembly
* call (so it can reflect the live registry state). The provider is
* removed when the calling fiber is disposed. A provider must not return a
* schema named {@link TOOL_ORDER_REST}; that name is reserved for
* 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.
*/
tools(provider: () => ToolSchema[]): () => void {
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
this.toolProviders.push(provider)
const layer = scope === undefined
? this.toolProviders
: this.scopedToolProviders.get(scope) ?? (() => {
const created: ((context: AssembleContext) => ToolProviderResult)[] = []
this.scopedToolProviders.set(scope, created)
return created
})()
layer.push(provider)
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
yield () => {
const index = this.toolProviders.indexOf(provider)
const index = layer.indexOf(provider)
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) this.toolProviders.splice(index, 1)
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
@@ -392,26 +470,40 @@ export class SystemPrompt extends Service {
* `{{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). Throws on a name that
* does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is
* already registered. Removed when the calling fiber is disposed; emits
* `system-prompt/change` on register/unregister.
* 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.
*/
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
if (!VARIABLE_NAME.test(name)) {
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
}
if (this.variableProviders.has(name)) {
throw new Error(`prompt variable "${name}" is already registered`)
const layer = scope === undefined
? this.variableProviders
: this.scopedVariableProviders.get(scope) ?? (() => {
const created = new Map<string, (context: AssembleContext) => string | undefined>()
this.scopedVariableProviders.set(scope, created)
return created
})()
if (layer.has(name)) {
throw new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`)
}
this.variableProviders.set(name, provider)
layer.set(name, provider)
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
yield () => {
this.variableProviders.delete(name)
layer.delete(name)
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
@@ -422,13 +514,17 @@ export class SystemPrompt extends Service {
}
/**
* Assemble the current prompt for one caller: section texts are resolved
* against `context` and sorted by order, tools collected from all providers
* 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 no provider contributed rejects the assembly), and every
* registered variable resolved against `context` into `assembly.variables`.
* 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 deep-cloned because adapters and request waterfalls may
* mutate schema objects. Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the
@@ -445,25 +541,59 @@ export class SystemPrompt extends Service {
// rejection: a Promise-returning method must not throw synchronously
// (`assemble().catch(...)` would miss it).
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).
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
variables[name] = provider(context)
}
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
for (const [name, provider] of scopedVariables ?? []) {
variables[name] = provider(context)
}
// Sections: merge by name, scoped REPLACING same-named global entries
// (most-specific-wins — the per-agent persona mechanism), then sort by
// order across the union. Registration order within a layer is preserved
// for equal orders (stable sort).
const sectionByName = new Map<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.
const providers = [
...this.toolProviders,
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
]
const collected: ToolSchema[] = []
const knownNames = new Set<string>()
for (const provider of providers) {
const result = provider(context)
for (const tool of result.schemas) {
collected.push({ ...tool, parameters: structuredClone(tool.parameters) })
}
for (const name of result.knownNames ?? result.schemas.map(tool => tool.name)) {
knownNames.add(name)
}
}
const assembly: PromptAssembly = {
sections: this.sections
sections: [...sectionByName.values()]
.map(section => ({
name: section.name,
order: section.order,
text: typeof section.text === 'function' ? section.text(context) : section.text,
}))
.sort((a, b) => a.order - b.order),
tools: orderTools(
this.toolProviders.flatMap(provider =>
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
this.toolOrder),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
return this.ctx.waterfall(scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
}
}

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
import SystemPrompt, { TOOL_ORDER_REST, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { Config, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
async function mount(config: Config = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt, config)
return ctx
}
async function mintScope(ctx: Context, name: string): Promise<Scope> {
let scope!: Scope
// The scoped context resolves services through the MINTING plugin's
// dependency chain — the minter must inject what scope holders will reach.
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) },
{ inject: ['systemPrompt'] }))
return scope
}
const schema = (name: string) => ({ name, description: `tool ${name}`, parameters: {} })
/** The key a test scope was minted with (scopeOf over the scope's own ctx). */
function scopeKeyOf(scope: Scope): ScopeKey {
// scopeOf never answers undefined for a context the scope itself minted.
return scopeOf(scope.ctx)!
}
describe('scoped sections', () => {
it('a scoped persona shadows deployment:persona for that scope only (either order)', async () => {
const ctx = await mount({ persona: 'You are the deployment.' })
const scope = await mintScope(ctx, 'child')
scope.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
const scoped = renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))
const global = renderPrompt(await ctx.systemPrompt.assemble())
expect(scoped).toContain('You run tests.')
expect(scoped).not.toContain('You are the deployment.')
expect(global).toContain('You are the deployment.')
expect(global).not.toContain('You run tests.')
})
it('scoped-only sections join that scope alone; disposal removes them', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
scope.ctx.systemPrompt.section({ name: 'child:extra', order: 50, text: 'Extra guidance.' })
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Extra guidance.')
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('Extra guidance.')
await scope.dispose()
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).not.toContain('Extra guidance.')
})
it('duplicate names throw per layer, naming agent.ctx for the global case', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
ctx.systemPrompt.section({ name: 'x', order: 1, text: 'a' })
expect(() => ctx.systemPrompt.section({ name: 'x', order: 1, text: 'b' })).toThrow(/agent\.ctx/)
scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' })
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
})
describe('scoped variables', () => {
it('a scoped variable shadows its global name-twin for that scope', async () => {
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
const scope = await mintScope(ctx, 'child')
ctx.systemPrompt.variable('mode', () => 'normal')
scope.ctx.systemPrompt.variable('mode', () => 'strict')
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Mode: strict.')
expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Mode: normal.')
})
it('same-layer duplicates throw; scoped layer cleans up on dispose', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
scope.ctx.systemPrompt.variable('v', () => '1')
expect(() => scope.ctx.systemPrompt.variable('v', () => '2')).toThrow(/already registered in this scope/)
await scope.dispose()
// Re-minting a scope with the SAME key starts clean.
const again = await mintScope(ctx, 'child2')
again.ctx.systemPrompt.variable('v', () => '3')
})
})
describe('scoped tool providers and toolOrder × restriction', () => {
it('scoped providers are consulted only for their scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
ctx.systemPrompt.tools(() => ({ schemas: [schema('global_tool')] }))
scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
const global = await ctx.systemPrompt.assemble()
expect(scoped.tools.map(t => t.name)).toEqual(['global_tool', 'scoped_tool'])
expect(global.tools.map(t => t.name)).toEqual(['global_tool'])
})
it('a toolOrder entry restricted away for a scope is a normal absence, while a typo still throws', async () => {
const ctx = await mount({ toolOrder: ['bash', TOOL_ORDER_REST] })
// A provider mimicking the registry's restriction split: bash exists
// (knownNames) but is masked for this assembly (schemas).
ctx.systemPrompt.tools(() => ({
schemas: [schema('read')],
knownNames: ['read', 'bash'],
}))
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(t => t.name)).toEqual(['read'])
const bad = await mount({ toolOrder: ['basj', TOOL_ORDER_REST] })
bad.systemPrompt.tools(() => ({ schemas: [schema('read')], knownNames: ['read', 'bash'] }))
await expect(bad.systemPrompt.assemble()).rejects.toThrow('toolOrder lists unregistered tool "basj"; known tools: bash, read')
})
})
describe('scoped assemble dispatch', () => {
it('an agent.ctx assemble listener shapes only its own scope\'s assemblies', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const shaped: (ScopeKey | undefined)[] = []
scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise<PromptAssembly>) => {
shaped.push(context.scope)
const result = await next()
result.sections.push({ name: 'listener:extra', order: 999, text: 'listener text' })
return result
})
const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
const global = await ctx.systemPrompt.assemble()
expect(scoped.sections.some(s => s.name === 'listener:extra')).toBe(true)
expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
expect(shaped).toHaveLength(1)
})
})

View File

@@ -52,7 +52,7 @@ describe('SystemPrompt', () => {
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] }))
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
@@ -84,7 +84,7 @@ describe('SystemPrompt', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }])
inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] }))
inner.systemPrompt.variable('scoped_var', () => 'v')
}, { inject: ['systemPrompt'] }))
@@ -141,11 +141,11 @@ describe('SystemPrompt', () => {
if (!threw) { threw = true; throw new Error('boom change listener') }
})
expect(() => ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])).toThrow('boom change listener')
expect(() => ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))).toThrow('boom change listener')
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) // nothing leaked
off()
ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
})
@@ -209,7 +209,7 @@ describe('SystemPrompt', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
ctx.systemPrompt.tools(() => [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }] }))
const first = await ctx.systemPrompt.assemble()
first.sections[0]!.name = 'mutated'
@@ -243,7 +243,7 @@ describe('SystemPrompt', () => {
let changeCount = 0
ctx.on('system-prompt/change', () => void changeCount++)
const dispose = ctx.systemPrompt.tools(() => [])
const dispose = ctx.systemPrompt.tools(() => ({ schemas: [] }))
// registration emits change
expect(changeCount).toBe(1)
@@ -257,7 +257,7 @@ describe('SystemPrompt', () => {
await ctx.plugin(SystemPrompt)
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.systemPrompt.tools(() => [{ name: 'fiber-tool', description: '', parameters: {} }])
inner.systemPrompt.tools(() => ({ schemas: [{ name: 'fiber-tool', description: '', parameters: {} }] }))
}, { inject: ['systemPrompt'] }))
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
@@ -280,7 +280,7 @@ describe('SystemPrompt', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const dispose = ctx.systemPrompt.tools(() => [{ name: 'direct-tool', description: '', parameters: {} }])
const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] }))
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
dispose()

View File

@@ -26,39 +26,39 @@ describe('SystemPrompt tool order', () => {
it('assembles tools in lexicographic name order when no toolOrder is configured', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('charlie'), tool('alpha')])
ctx.systemPrompt.tools(() => [tool('bravo')])
ctx.systemPrompt.tools(() => ({ schemas: [tool('charlie'), tool('alpha')] }))
ctx.systemPrompt.tools(() => ({ schemas: [tool('bravo')] }))
expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie'])
})
it('assembles the same order regardless of provider registration order', async () => {
const forward = await mount()
forward.systemPrompt.tools(() => [tool('alpha')])
forward.systemPrompt.tools(() => [tool('zulu')])
forward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] }))
forward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] }))
const backward = await mount()
backward.systemPrompt.tools(() => [tool('zulu')])
backward.systemPrompt.tools(() => [tool('alpha')])
backward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] }))
backward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] }))
expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
})
it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => {
const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] })
ctx.systemPrompt.tools(() => [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')])
ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')] }))
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
})
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(() => [tool('bash'), tool('todo_write')])
ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] }))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
'toolOrder lists unregistered tools "ghost", "wraith"; registered tools: bash, todo_write')
'toolOrder lists unregistered tools "ghost", "wraith"; known tools: bash, todo_write')
})
it('names the single unregistered tool when no tools are registered at all', async () => {
const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] })
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
'toolOrder lists unregistered tool "ghost"; registered tools: (none)')
'toolOrder lists unregistered tool "ghost"; known tools: (none)')
})
it.each([
@@ -66,21 +66,21 @@ describe('SystemPrompt tool order', () => {
['with only the rest entry configured', [TOOL_ORDER_REST]],
])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => {
const ctx = await mount(toolOrder === undefined ? {} : { toolOrder })
ctx.systemPrompt.tools(() => [tool(TOOL_ORDER_REST)])
ctx.systemPrompt.tools(() => ({ schemas: [tool(TOOL_ORDER_REST)] }))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
`tool provider returned reserved tool name "${TOOL_ORDER_REST}"`)
})
it('keeps collection order between tools that share a name (stable sort)', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')])
ctx.systemPrompt.tools(() => ({ schemas: [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')] }))
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
})
it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
const ctx = await mount()
ctx.systemPrompt.tools(() => [tool('zulu'), tool('alpha')])
ctx.systemPrompt.tools(() => ({ schemas: [tool('zulu'), tool('alpha')] }))
let seen: string[] | undefined
ctx.on('system-prompt/assemble', function (assembly, _context, next) {
seen = assembly.tools.map(t => t.name)

View File

@@ -19,6 +19,9 @@
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/scope"
}
]
}