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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
138
packages/core/system-prompt/tests/scoped.spec.ts
Normal file
138
packages/core/system-prompt/tests/scoped.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user