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
@@ -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)
})
})