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
@@ -107,7 +107,7 @@ describe('loop-level canonical tool order', () => {
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
const end = agent.session.events.find(e => e.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
+2
View File
@@ -24,11 +24,13 @@
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+68 -10
View File
@@ -9,6 +9,8 @@
import { Context, Service } from 'cordis'
import { isAbsolute } from 'node:path'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
@@ -33,28 +35,48 @@ declare module 'cordis' {
interface Events {
/**
* A session was created in the store.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* @param session - the session just entered and announced.
* @mode emit
*/
'session/created'(session: Session): void
'session/created'(this: Scoped<Session>, session: Session): void
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* @param session - the session whose log grew.
* @param event - the appended event, exactly as recorded.
* @mode emit
*/
'session/event'(session: Session, event: SessionEvent): void
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/**
* Awaited durability checkpoint. The agent loop awaits
* `ctx.parallel('session/flush', session)` at every turn end; persistence
* `ctx.sessions.flush(session)` at every turn end; persistence
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
* and the loop waits for all of them, but none can veto.
* and the caller waits for all of them, but none can veto. Dispatch it
* through {@link SessionStore.flush} — the store owns the carrier — never
* via a raw `ctx.parallel`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
* session's owner scope, captured when the session was ENTERED (an agent's
* session is entered through `agent.ctx`, so its events dispatch in that
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
* subject-less). A listener registered through `agent.ctx` hears only that
* agent's sessions; a plain plugin listener hears every session.
* @param session - the session whose buffered events must reach durable storage.
* @mode parallel
*/
'session/flush'(session: Session): Promise<void> | void
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
}
}
@@ -404,6 +426,14 @@ export class SessionForkError extends Error {
*/
export class SessionStore extends Service {
private store = new Map<SessionId, Session>()
/**
* Each live session's dispatch carrier, captured at {@link enter} from the
* ENTERING context's scope tag (an agent session is entered through
* `agent.ctx` ⇒ its events dispatch in that agent's scope; a bare session ⇒
* subject-less carrier). WeakMap so a detached session drops its carrier
* with the entry.
*/
private carriers = new WeakMap<Session, Scoped<Session>>()
private counter = 0
constructor(ctx: Context) {
@@ -498,7 +528,15 @@ export class SessionStore extends Service {
*/
enter(session: Session): () => void {
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
// The carrier is decided HERE, once, from the ENTERING context's scope tag
// (`this.ctx` is the caller's context — the tracker mechanism): every
// session/created|event|flush dispatch for this session uses it, so the
// session's whole event feed is scope-filtered consistently. The base is
// the session itself (scoped listeners' `this` is the session).
const carrier = scopeTarget(session, scopeOf(this.ctx))
this.carriers.set(session, carrier)
const emitCtx = this.ctx
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
this.store.set(session.id, session)
return () => {
session.onAppend = undefined
@@ -506,12 +544,32 @@ export class SessionStore extends Service {
}
}
/** Emit `session/created` for an {@link enter}ed session. Separate from
* {@link enter} so the caller can yield the detach disposer first (rollback
* safety — see {@link enter}).
/** Emit `session/created` for an {@link enter}ed session (with the carrier
* {@link enter} captured). Separate from {@link enter} so the caller can
* yield the detach disposer first (rollback safety — see {@link enter}).
* @param session - the entered session to announce to listeners. */
announce(session: Session): void {
this.ctx.emit('session/created', session)
this.ctx.emit(this.carrierFor(session), 'session/created', session)
}
/**
* Dispatch the awaited `session/flush` durability checkpoint for `session`,
* with the carrier captured at {@link enter}. THE flush entry point: the
* store owns the carrier, so callers (the loop's turn-end checkpoint, idle
* injection, teardown drains) must come through here rather than dispatch a
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
* scoped-dispatch invariant can pin it.
* @param session - the session whose buffered events must reach durable storage.
* @returns resolves when every flush listener has settled; rejects if one rejects.
*/
async flush(session: Session): Promise<void> {
await this.ctx.parallel(this.carrierFor(session), 'session/flush', session)
}
/** The carrier {@link enter} captured, or a subject-less one for a session
* never entered (defensive: dispatch stays filtered either way). */
private carrierFor(session: Session): Scoped<Session> {
return this.carriers.get(session) ?? scopeTarget(session, undefined)
}
/**
+112
View File
@@ -0,0 +1,112 @@
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 SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
async function mount(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
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: ['sessions'] }))
return scope
}
/** The key a test scope was minted with. */
function keyOf(scope: Scope): ScopeKey {
return scopeOf(scope.ctx)!
}
describe('session dispatch carriers', () => {
it('a session entered through a scoped context dispatches its events in that scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
const otherScope = await mintScope(ctx, 'other')
const heard: string[] = []
ctx.on('session/event', (_session, event) => void heard.push(`global:${event.type}`))
scope.ctx.on('session/event', (_session, event) => void heard.push(`owner:${event.type}`))
otherScope.ctx.on('session/event', (_session, event) => void heard.push(`other:${event.type}`))
scope.ctx.on('session/created', session => void heard.push(`owner-created:${session.id}`))
otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`))
const session = scope.ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(heard).toEqual([
`owner-created:${session.id}`,
'global:turn/start',
'owner:turn/start',
])
})
it('a bare session dispatches subject-less: scoped listeners never hear it', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
const heard: string[] = []
ctx.on('session/event', (_s, event) => void heard.push(`global:${event.type}`))
scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`))
const bare = ctx.sessions.create()
bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(heard).toEqual(['global:turn/start'])
})
})
describe('sessions.flush()', () => {
it('dispatches session/flush with the owning carrier and awaits all listeners', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
const flushed: string[] = []
ctx.on('session/flush', async (session: Session) => {
await Promise.resolve()
flushed.push(`global:${session.id}`)
})
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
const owned = scope.ctx.sessions.create()
const bare = ctx.sessions.create()
await ctx.sessions.flush(owned)
await ctx.sessions.flush(bare)
// Parallel dispatch: listener completion order is unspecified (the global
// listener awaits a microtask) — assert set membership per flush instead.
expect(flushed.slice(0, 2).sort()).toEqual([`global:${owned.id}`, `owner:${owned.id}`])
expect(flushed.slice(2)).toEqual([`global:${bare.id}`])
})
it('propagates a rejecting flush listener (the caller owns the failure policy)', async () => {
const ctx = await mount()
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
const session = ctx.sessions.create()
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
})
it('flushes a never-entered session with a subject-less carrier (defensive path)', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
const flushed: string[] = []
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
const detached = ctx.sessions.prepare()
await ctx.sessions.flush(detached)
expect(flushed).toEqual([`global:${detached.id}`])
})
it('keyOf sanity: distinct scopes carry distinct keys', async () => {
const ctx = await mount()
const a = await mintScope(ctx, 'a')
const b = await mintScope(ctx, 'b')
expect(keyOf(a)).not.toBe(keyOf(b))
})
})
+3
View File
@@ -19,6 +19,9 @@
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/scope"
}
]
}
+2
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"
}
}
+188 -58
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))
}
}
@@ -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"
}
]
}
+2
View File
@@ -24,12 +24,14 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
+210 -27
View File
@@ -9,6 +9,8 @@
*/
import { Context, Service } from 'cordis'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
@@ -70,10 +72,14 @@ declare module 'cordis' {
* tool body never runs. Input rewrite is deliberately NOT offered here (see
* {@link PreToolDecision}); `ask` degrades to deny until the permission
* system lands (`FIXME(permissions)`).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
* `exec.agent` — a listener registered through `agent.ctx` fires only for
* that agent's calls; a plain plugin listener fires for every call
* (including agent-less ones, which dispatch subject-less).
* @param exec - the pending call (name, parsed arguments, caller agent).
* @mode waterfall
*/
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
/**
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
* accept it (optionally REPLACING the model-facing content, and/or attaching
@@ -85,13 +91,22 @@ declare module 'cordis' {
* `execute`'s outer try/catch (and the tool body keeps its own inner
* try/catch, so a thrown tool still reaches `post-execute` as an `isError`
* result).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
* `exec.agent` — a listener registered through `agent.ctx` fires only for
* that agent's calls; a plain plugin listener fires for every call
* (including agent-less ones, which dispatch subject-less).
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
* @mode waterfall
*/
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* A tool was registered or unregistered (the available tool set changed).
* A tool was registered or unregistered, or a scoped restriction changed
* (the available tool set 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
*/
'tools/change'(): void
@@ -269,44 +284,88 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
}
/**
* A per-scope restriction over the GLOBAL tool surface, registered via
* {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools;
* `deny` removes the listed ones; both present = allow first, then deny.
* Restrictions never touch scoped registrations — a tool registered through
* the same scope is an explicit grant that bypasses them (which is what keeps
* e.g. a structured-output capture tool alive under an allow-list). Multiple
* restrictions on one scope compose by intersection: every one must admit.
*/
export interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
allow?: string[]
/** Global tool names removed from visibility. */
deny?: string[]
}
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly.
*
* Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a
* plain plugin context is GLOBAL (visible to every agent); one through a
* scoped context (`agent.ctx`) is filed in that scope's layer — visible to
* that agent alone, disposed with the scope, and SHADOWING a global tool of
* the same name for that agent (most-specific-wins; within one layer a
* duplicate name still throws). {@link restrict} masks the global layer per
* scope. One visibility function ({@link visible}) feeds prompt assembly,
* {@link get}, and {@link execute}, so what the model is shown, what a
* presenter renders, and what dispatches can never disagree.
*/
export class ToolRegistry extends Service {
static inject = ['systemPrompt']
private store = new Map<string, ToolDefinition>()
private global = new Map<string, ToolDefinition>()
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
private restrictions = new Map<ScopeKey, ToolRestriction[]>()
constructor(ctx: Context) {
super(ctx, 'tools')
ctx.systemPrompt.tools(() => this.schemas())
ctx.systemPrompt.tools(context => ({
schemas: this.schemas(context.scope),
knownNames: this.knownNames(context.scope),
}))
}
/**
* Register a tool. Throws if a tool with the same name is already
* registered. The tool's schema (minus the `execute` function) is
* automatically contributed to the system-prompt assembly. Disposed
* with the calling fiber. Emits `tools/change` on register/unregister.
* Register a tool. The layer is decided by the CALLING context: a plain
* plugin context registers globally; a scoped context (`agent.ctx`)
* registers into that scope's layer — visible to that agent alone, disposed
* with the scope, and shadowing a same-named global tool for that agent.
* Throws if the SAME layer already has the name (cross-layer name twins are
* the shadowing feature, not an error; the global-duplicate message names
* `agent.ctx` as the per-agent alternative). The visible schema set flows
* into prompt assembly automatically. Disposed with the calling fiber.
* Emits `tools/change` on register/unregister.
* @param definition - the tool's schema plus its execute (and optional
* presentation) functions.
* @returns the disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
if (this.store.has(definition.name)) {
throw new Error(`tool "${definition.name}" is already registered`)
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(definition.name)) {
throw new Error(scope === undefined
? `tool "${definition.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${definition.name}" is already registered in this scope`)
}
this.store.set(definition.name, definition)
layer.set(definition.name, definition)
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
// collects each yielded disposer before the next step runs, so a throwing
// `tools/change` listener removes the tool instead of leaking it (a leak
// would wedge the duplicate-name check until restart). The duplicate
// throw above fires before any mutation — it leaks nothing.
yield () => {
this.store.delete(definition.name)
layer.delete(definition.name)
// An emptied scope layer is dropped so a disposed scope leaves no
// residue keyed by its (dead) key.
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
@@ -317,33 +376,150 @@ export class ToolRegistry extends Service {
}
/**
* Look up a registered tool.
* @param name - the tool name as registered.
* @returns the definition, or undefined when no tool has that name.
* Restrict the GLOBAL tool surface for the calling scope. Must be called
* through a scoped context (`agent.ctx`) — restricting "everyone" is not a
* thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op
* that can only be a bug (throw — the materialized-empty-config trap).
* Validates every listed name against the scope's CURRENT pre-restriction
* name universe ({@link knownNames}) and throws on an unknown one (fail loud
* beats a typo silently filtering nothing) — register restrictions after the
* global tools they mask exist (the agent-creation `setup` window satisfies
* this). The filter is SNAPSHOT at registration: later caller mutation of
* the arrays changes nothing. Multiple restrictions compose by intersection.
* Scoped registrations bypass restrictions (explicit grants win). Disposed
* with the calling fiber (revocable independently); emits `tools/change`.
* @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
* @returns the disposer that lifts this restriction.
*/
get(name: string): ToolDefinition | undefined {
return this.store.get(name)
restrict(filter: ToolRestriction): () => void {
const scope = scopeOf(this.ctx)
if (scope === undefined) {
throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')
}
if (filter.allow === undefined && filter.deny === undefined) {
throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)')
}
// Snapshot BEFORE validation so what was checked is what is enforced.
const snapshot: ToolRestriction = {
...filter.allow !== undefined ? { allow: [...filter.allow] } : {},
...filter.deny !== undefined ? { deny: [...filter.deny] } : {},
}
const known = new Set(this.knownNames(scope))
const unknown = [...snapshot.allow ?? [], ...snapshot.deny ?? []].filter(name => !known.has(name))
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known tools for this scope: ${[...known].sort().join(', ') || '(none)'}`)
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const list = this.restrictions.get(scope) ?? []
this.restrictions.set(scope, list)
list.push(snapshot)
yield () => {
const index = list.indexOf(snapshot)
/* v8 ignore next 3 -- defensive: the snapshot was pushed, so indexOf is guaranteed >= 0 */
if (index >= 0) list.splice(index, 1)
if (list.length === 0) this.restrictions.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.restrict()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
/** The (created-on-demand) scoped layer for `scope`. */
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
let layer = this.scoped.get(scope)
if (!layer) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
}
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
private admits(scope: ScopeKey | undefined, name: string): boolean {
if (scope === undefined) return true
const filters = this.restrictions.get(scope)
if (!filters) return true
return filters.every(filter =>
(filter.allow === undefined || filter.allow.includes(name))
&& (filter.deny === undefined || !filter.deny.includes(name)))
}
/**
* Return all registered tool schemas — exactly the model-facing fields
* (`name`, `description`, `parameters`), as sent to the model via the
* THE visibility function — one resolution feeding prompt assembly,
* {@link get}, and {@link execute}: the global layer masked by the scope's
* restrictions, unioned with the scope's own layer, scoped shadowing global
* on a name conflict. No scope = the unrestricted global view.
* @param scope - the viewing scope (the agent), or undefined for the global view.
* @returns the visible definitions (scoped shadows applied), in per-layer
* registration order, global layer first.
*/
visible(scope?: ScopeKey): ToolDefinition[] {
const layer = scope === undefined ? undefined : this.scoped.get(scope)
const result = new Map<string, ToolDefinition>()
for (const [name, definition] of this.global) {
if (this.admits(scope, name)) result.set(name, definition)
}
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
// and grants bypass restrictions by construction (never filtered above).
for (const [name, definition] of layer ?? []) result.set(name, definition)
return [...result.values()]
}
/**
* Look up a tool as one scope sees it ({@link visible} semantics: scoped
* shadows global; a restricted-away global reads as absent). Presenters pass
* the calling agent so the rendered card matches the definition that
* actually executed.
* @param name - the tool name as registered.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @returns the definition the scope resolves, or undefined when none is visible.
*/
get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
const shadowed = scope === undefined ? undefined : this.scoped.get(scope)?.get(name)
if (shadowed) return shadowed
if (!this.admits(scope, name)) return undefined
return this.global.get(name)
}
/**
* The model-facing schemas of everything `scope` can see — exactly the
* fields (`name`, `description`, `parameters`) sent to the model via the
* system-prompt assembly. Constructed EXPLICITLY rather than by stripping
* known non-schema members: a `ToolDefinition` also carries `execute` and the
* optional `presentCall`/`presentResult` UI callbacks, and those (especially
* the functions) must never leak into a model request. An allowlist can't
* drift when a new non-schema member is added to the definition; a denylist
* (rest-destructure) would silently leak it.
* @returns one deep-cloned schema per registered tool, in registration order.
* @param scope - the viewing scope (the agent); omitted = the global view.
* @returns one deep-cloned schema per visible tool.
*/
schemas(): ToolSchema[] {
return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({
schemas(scope?: ScopeKey): ToolSchema[] {
return this.visible(scope).map(({ name, description, parameters }): ToolSchema => ({
name,
description,
parameters: structuredClone(parameters),
}))
}
/**
* The PRE-restriction name universe for `scope`: every global name plus the
* scope's own layer, ignoring restrictions. This is the set configuration
* (`toolOrder`, `restrict()` filters) validates against, so a typo fails
* loud while a restricted-away tool remains a normal, non-erroneous absence.
* @param scope - the viewing scope (the agent); omitted = global names only.
* @returns the known names, deduplicated.
*/
knownNames(scope?: ScopeKey): string[] {
const names = new Set(this.global.keys())
if (scope !== undefined) {
for (const name of this.scoped.get(scope)?.keys() ?? []) names.add(name)
}
return [...names]
}
/**
* Execute one tool call through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
@@ -362,9 +538,12 @@ export class ToolRegistry extends Service {
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
try {
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
// until the permission system lands) skips dispatch entirely. ---
// until the permission system lands) skips dispatch entirely. The
// carrier keys the dispatch by exec.agent, so an `agent.ctx` listener
// gates only its own agent's calls (agent-less calls are subject-less).
const carrier = scopeTarget(this, exec.agent)
const decision = await this.ctx.waterfall(
this, 'tools/pre-execute', exec,
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
if (decision.kind !== 'allow') {
@@ -387,7 +566,11 @@ export class ToolRegistry extends Service {
// inspect it; an unknown tool routes through the same catch. ---
let result: ToolExecutionResult
try {
const tool = this.store.get(exec.name)
// Resolve through the CALLER's visible view ({@link get}): a scoped
// tool shadows its global name-twin for that agent, and a
// restricted-away global tool is exactly as absent as a nonexistent
// one — same UNKNOWN_TOOL result, no capability leak in the error.
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
@@ -435,7 +618,7 @@ export class ToolRegistry extends Service {
...result.meta !== undefined ? { meta: result.meta } : {},
}
const decision = await this.ctx.waterfall(
this, 'tools/post-execute', exec, result,
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
)
const additionalContext = decision.additionalContext
+172
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'])
})
})
+3
View File
@@ -22,6 +22,9 @@
},
{
"path": "../../core/agent"
},
{
"path": "../../core/scope"
}
]
}
+15
View File
@@ -183,6 +183,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../session
@@ -245,6 +248,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../scope
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../session
@@ -278,6 +284,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../scope
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -291,6 +300,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../scope
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -303,6 +315,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-scope':
specifier: workspace:^
version: link:../scope
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../system-prompt