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

@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
async function mount(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
await ctx.plugin(ToolRegistry)
return ctx
}
/** Mint a scope whose key doubles as a minimal Agent-like object. */
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> {
const key = { id: name as AgentId } as Agent
let scope!: Scope
// The scoped context resolves services through the MINTING plugin's
// dependency chain — the minter must inject what scope holders will reach
// (in production the agent loop's inject list plays this role).
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
{ inject: ['tools', 'systemPrompt'] }))
return { scope, key }
}
function tool(name: string, reply = `ran:${name}`): ToolDefinition {
return {
name,
description: `tool ${name}`,
parameters: { type: 'object', properties: {} },
execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
}
}
async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
const result = await ctx.tools.execute({
callId: CallId('c1'),
name,
arguments: {},
...agent ? { agent } : {},
})
const first = result.content[0]
return first?.type === 'text' ? first.text : JSON.stringify(result.content)
}
describe('scoped tool registration', () => {
it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
ctx.tools.register(tool('shared'))
scope.ctx.tools.register(tool('mine'))
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['mine', 'shared'])
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['shared'])
expect(ctx.tools.schemas(other).map(t => t.name)).toEqual(['shared'])
expect(await run(ctx, 'mine', key)).toBe('ran:mine')
// Out-of-view execution is indistinguishable from a nonexistent tool.
expect(await run(ctx, 'mine', other)).toBe('Error: unknown tool "mine"')
expect(await run(ctx, 'mine')).toBe('Error: unknown tool "mine"')
})
it('scoped shadows global on a name conflict, in either registration order', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
// scoped-then-global
scope.ctx.tools.register(tool('bash', 'restricted-bash'))
ctx.tools.register(tool('bash', 'global-bash'))
expect(await run(ctx, 'bash', key)).toBe('restricted-bash')
expect(await run(ctx, 'bash')).toBe('global-bash')
expect(ctx.tools.get('bash', key)?.description).toBe(ctx.tools.get('bash', key)?.description)
// Exactly one 'bash' in the scope's schema view (the shadow, not a double).
expect(ctx.tools.schemas(key).filter(t => t.name === 'bash')).toHaveLength(1)
})
it('rejects a duplicate name within one layer, naming agent.ctx for the global case', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('x'))
expect(() => ctx.tools.register(tool('x'))).toThrow(/agent\.ctx/)
scope.ctx.tools.register(tool('y'))
expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/)
})
it('disposing the scope unwinds its registrations and leaves no residue', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
scope.ctx.tools.register(tool('mine'))
expect(ctx.tools.get('mine', key)).toBeDefined()
await scope.dispose()
expect(ctx.tools.get('mine', key)).toBeUndefined()
expect(ctx.tools.knownNames(key)).toEqual([])
})
})
describe('restrict()', () => {
it('masks global tools for the scope; grants bypass; assembly and execute agree', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('read'))
ctx.tools.register(tool('bash'))
scope.ctx.tools.register(tool('capture'))
scope.ctx.tools.restrict({ allow: ['read'] })
// The scoped grant survives the allow-list; the unlisted global is gone.
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read'])
expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"')
expect(await run(ctx, 'read', key)).toBe('ran:read')
expect(await run(ctx, 'capture', key)).toBe('ran:capture')
// Other scopes and the global view are untouched.
expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read'])
})
it('composes multiple restrictions by intersection and lifts each independently', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
for (const name of ['a', 'b', 'c']) ctx.tools.register(tool(name))
const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] })
scope.ctx.tools.restrict({ deny: ['b'] })
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a'])
liftAllow()
// The deny remains after the allow-list is lifted.
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
})
it('snapshots the filter at registration (caller mutation changes nothing)', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('a'))
ctx.tools.register(tool('b'))
const filter = { deny: ['a'] }
scope.ctx.tools.restrict(filter)
filter.deny.push('b')
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
})
it('fails loud on an unscoped call, an empty filter, and unknown names', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('real'))
expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/)
})
})
describe('scoped execution dispatch', () => {
it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
ctx.tools.register(tool('t'))
const seen: (string | undefined)[] = []
scope.ctx.on('tools/pre-execute', (exec: ToolExecution, _next: () => Promise<PreToolDecision>) => {
seen.push(exec.agent?.id)
return Promise.resolve<PreToolDecision>({ kind: 'deny', reason: 'scoped veto' })
})
expect(await run(ctx, 't', key)).toBe('Error: scoped veto')
expect(await run(ctx, 't', other)).toBe('ran:t')
expect(await run(ctx, 't')).toBe('ran:t')
expect(seen).toEqual(['a'])
})
})