Merge branch 'codex/code-mode-typed-results' into codex/code-mode-complete-result-card
This commit is contained in:
@@ -12,6 +12,10 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
|
||||
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
|
||||
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
|
||||
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
|
||||
|
||||
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
|
||||
|
||||
@@ -19,6 +23,8 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse
|
||||
|
||||
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
|
||||
|
||||
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
|
||||
|
||||
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
|
||||
export type { ScopeLayer } from './store.ts'
|
||||
|
||||
/** An opaque, identity-compared scope key. */
|
||||
export type ScopeKey = object
|
||||
|
||||
|
||||
247
packages/core/scope/src/store.ts
Normal file
247
packages/core/scope/src/store.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Shared insertion-ordered storage and effect ownership for scope-aware registries.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scope
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { scopeOf } from './index.ts'
|
||||
import type { ScopeKey } from './index.ts'
|
||||
|
||||
/** One scope's aggregate contribution to a registry. */
|
||||
export interface ScopeLayer {
|
||||
/** Whether every table in this layer is empty. */
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
/** Internal common read contract for the two entry-table implementations. */
|
||||
interface EntryValues<V> {
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
|
||||
*
|
||||
* Values are borrowed. Iterators are live within one nonempty table
|
||||
* generation; draining the table detaches them from later insertions. Each
|
||||
* successful insertion returns an idempotent undo for that exact entry.
|
||||
*/
|
||||
export class NamedEntries<V> implements EntryValues<V> {
|
||||
private data = new Map<string, V>()
|
||||
|
||||
constructor(
|
||||
private readonly duplicateError: (name: string) => Error,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Insert one unique name.
|
||||
* @param name - name unique within this table.
|
||||
* @param value - borrowed value to retain.
|
||||
* @returns an idempotent undo that removes only this insertion.
|
||||
*/
|
||||
insert(name: string, value: V): () => void {
|
||||
const data = this.data
|
||||
if (data.has(name)) throw this.duplicateError(name)
|
||||
data.set(name, value)
|
||||
let active = true
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
data.delete(name)
|
||||
if (data.size === 0 && this.data === data) this.data = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one named value.
|
||||
* @param name - name to resolve.
|
||||
* @returns the retained value, or `undefined` when absent.
|
||||
*/
|
||||
get(name: string): V | undefined {
|
||||
return this.data.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test one name for membership.
|
||||
* @param name - name to test.
|
||||
* @returns whether the table contains that name.
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.data.has(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live names in insertion order.
|
||||
* @returns the native live key iterator.
|
||||
*/
|
||||
keys(): IterableIterator<string> {
|
||||
return this.data.keys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live entries in insertion order.
|
||||
* @returns the native live entry iterator.
|
||||
*/
|
||||
entries(): IterableIterator<[string, V]> {
|
||||
return this.data.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live values in insertion order.
|
||||
* @returns the native live value iterator.
|
||||
*/
|
||||
values(): IterableIterator<V> {
|
||||
return this.data.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this table has no entries.
|
||||
* @returns whether the table is empty.
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this.data.size === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insertion-ordered anonymous entries with independent registration identity.
|
||||
*
|
||||
* Equal values remain separate registrations. Values are borrowed, and
|
||||
* iterators are live within one nonempty table generation; draining the table
|
||||
* detaches them from later appends.
|
||||
*/
|
||||
export class AnonymousEntries<V> implements EntryValues<V> {
|
||||
private data = new Map<symbol, V>()
|
||||
|
||||
/**
|
||||
* Append one independently owned value.
|
||||
* @param value - borrowed value to retain.
|
||||
* @returns an idempotent undo for this exact append.
|
||||
*/
|
||||
append(value: V): () => void {
|
||||
const data = this.data
|
||||
const key = Symbol()
|
||||
data.set(key, value)
|
||||
let active = true
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
data.delete(key)
|
||||
if (data.size === 0 && this.data === data) this.data = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live values in insertion order.
|
||||
* @returns the native live value iterator.
|
||||
*/
|
||||
values(): IterableIterator<V> {
|
||||
return this.data.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this table has no entries.
|
||||
* @returns whether the table is empty.
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this.data.size === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Own the global and exact-scope layers for one registry.
|
||||
*
|
||||
* Reads never create scoped layers. Registrations derive both visibility and
|
||||
* effect ownership from the supplied Cordis context, collect undo before
|
||||
* notification, and reclaim only a completely empty aggregate layer.
|
||||
*/
|
||||
export class ScopedLayers<L extends ScopeLayer> {
|
||||
/** The eagerly constructed context-global layer. */
|
||||
readonly global: L
|
||||
|
||||
private readonly scoped = new Map<ScopeKey, L>()
|
||||
|
||||
constructor(
|
||||
private readonly createLayer: (scope: ScopeKey | undefined) => L,
|
||||
private readonly onChange: () => void,
|
||||
) {
|
||||
this.global = createLayer(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an existing exact-scope overlay.
|
||||
* @param scope - exact scope key; `undefined` denotes no overlay.
|
||||
* @returns the existing scoped layer, or `undefined` without creating one.
|
||||
*/
|
||||
peek(scope: ScopeKey | undefined): L | undefined {
|
||||
if (scope === undefined) return undefined
|
||||
return this.scoped.get(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize global named entries followed by exact-scope shadows.
|
||||
* @param scope - exact viewing scope, or `undefined` for the global view.
|
||||
* @param pick - select the named table from a layer.
|
||||
* @returns an insertion-ordered effective map.
|
||||
*/
|
||||
merge<V>(
|
||||
scope: ScopeKey | undefined,
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V> {
|
||||
const merged = new Map(pick(this.global).entries())
|
||||
const layer = this.peek(scope)
|
||||
if (layer === undefined) return merged
|
||||
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach one synchronous layer mutation to its registration context.
|
||||
* @param ctx - context that determines both scope visibility and effect ownership.
|
||||
* @param action - atomic mutation returning its synchronous undo.
|
||||
* @param options - Cordis effect label and optional change notification.
|
||||
* @returns the exact disposer returned by `ctx.effect()`.
|
||||
*/
|
||||
effect(
|
||||
ctx: Context,
|
||||
action: (layer: L) => () => void,
|
||||
options: { label: string; notify?: boolean },
|
||||
): () => void {
|
||||
const scope = scopeOf(ctx)
|
||||
const notify = options.notify ?? true
|
||||
const dispose = ctx.effect(function* (this: ScopedLayers<L>) {
|
||||
let layer: L
|
||||
let created = false
|
||||
if (scope === undefined) {
|
||||
layer = this.global
|
||||
} else {
|
||||
const existing = this.scoped.get(scope)
|
||||
if (existing === undefined) {
|
||||
layer = this.createLayer(scope)
|
||||
this.scoped.set(scope, layer)
|
||||
created = true
|
||||
} else {
|
||||
layer = existing
|
||||
}
|
||||
}
|
||||
|
||||
let undo: () => void
|
||||
try {
|
||||
undo = action(layer)
|
||||
} catch (error) {
|
||||
if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)
|
||||
throw error
|
||||
}
|
||||
|
||||
yield () => {
|
||||
undo()
|
||||
if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)
|
||||
if (notify) this.onChange()
|
||||
}
|
||||
if (notify) this.onChange()
|
||||
}.bind(this), options.label)
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
|
||||
return dispose
|
||||
}
|
||||
}
|
||||
289
packages/core/scope/tests/store.spec.ts
Normal file
289
packages/core/scope/tests/store.spec.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
AnonymousEntries,
|
||||
createScope,
|
||||
NamedEntries,
|
||||
ScopedLayers,
|
||||
type Scope,
|
||||
type ScopeKey,
|
||||
type ScopeLayer,
|
||||
} from '@deepseek-ai/dsh-scope'
|
||||
|
||||
class TestLayer implements ScopeLayer {
|
||||
readonly named: NamedEntries<number>
|
||||
readonly anonymous = new AnonymousEntries<string>()
|
||||
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.named = new NamedEntries(name =>
|
||||
new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`))
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.named.isEmpty() && this.anonymous.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint one active scope for lifecycle tests. */
|
||||
async function mintScope(ctx: Context, key: ScopeKey): Promise<Scope> {
|
||||
let scope!: Scope
|
||||
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
|
||||
return scope
|
||||
}
|
||||
|
||||
describe('NamedEntries', () => {
|
||||
it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => {
|
||||
const duplicate = new Error('caller duplicate')
|
||||
const duplicateError = vi.fn(() => duplicate)
|
||||
const entries = new NamedEntries<number>(duplicateError)
|
||||
const undoA = entries.insert('a', 1)
|
||||
const values = entries.values()
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
const undoB = entries.insert('b', 2)
|
||||
|
||||
expect([...values]).toEqual([2])
|
||||
expect([...entries.keys()]).toEqual(['a', 'b'])
|
||||
expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]])
|
||||
expect(entries.get('a')).toBe(1)
|
||||
expect(entries.get('missing')).toBeUndefined()
|
||||
expect(entries.has('b')).toBe(true)
|
||||
expect(entries.has('missing')).toBe(false)
|
||||
expect(entries.isEmpty()).toBe(false)
|
||||
expect(() => entries.insert('a', 3)).toThrow(duplicate)
|
||||
expect(duplicateError).toHaveBeenCalledWith('a')
|
||||
|
||||
undoA()
|
||||
entries.insert('a', 3)
|
||||
undoA()
|
||||
expect(entries.get('a')).toBe(3)
|
||||
undoB()
|
||||
expect([...entries.entries()]).toEqual([['a', 3]])
|
||||
})
|
||||
|
||||
it('starts a fresh iterator generation after the table drains', () => {
|
||||
const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
|
||||
const undo = entries.insert('first', 1)
|
||||
const values = entries.values()
|
||||
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
undo()
|
||||
entries.insert('replacement', 2)
|
||||
|
||||
expect(values.next().done).toBe(true)
|
||||
expect([...entries.values()]).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AnonymousEntries', () => {
|
||||
it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => {
|
||||
const entries = new AnonymousEntries<object>()
|
||||
const value = {}
|
||||
const undoFirst = entries.append(value)
|
||||
const values = entries.values()
|
||||
expect(values.next()).toEqual({ value, done: false })
|
||||
const undoSecond = entries.append(value)
|
||||
|
||||
expect([...values]).toEqual([value])
|
||||
expect([...entries.values()]).toEqual([value, value])
|
||||
undoFirst()
|
||||
undoFirst()
|
||||
expect([...entries.values()]).toEqual([value])
|
||||
undoSecond()
|
||||
expect(entries.isEmpty()).toBe(true)
|
||||
})
|
||||
|
||||
it('starts a fresh iterator generation after the table drains', () => {
|
||||
const entries = new AnonymousEntries<number>()
|
||||
const undo = entries.append(1)
|
||||
const values = entries.values()
|
||||
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
undo()
|
||||
entries.append(2)
|
||||
|
||||
expect(values.next().done).toBe(true)
|
||||
expect([...entries.values()]).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScopedLayers', () => {
|
||||
it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => {
|
||||
const created: Array<ScopeKey | undefined> = []
|
||||
const layers = new ScopedLayers(
|
||||
(scope) => {
|
||||
created.push(scope)
|
||||
return new TestLayer(scope)
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
const key = {}
|
||||
layers.global.named.insert('a', 1)
|
||||
layers.global.named.insert('shared', 2)
|
||||
|
||||
expect(created).toEqual([undefined])
|
||||
expect(layers.peek(undefined)).toBeUndefined()
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]])
|
||||
expect(created).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
const changed = vi.fn()
|
||||
const created: Array<ScopeKey | undefined> = []
|
||||
const layers = new ScopedLayers(
|
||||
(selected) => {
|
||||
created.push(selected)
|
||||
return new TestLayer(selected)
|
||||
},
|
||||
changed,
|
||||
)
|
||||
layers.global.named.insert('a', 1)
|
||||
layers.global.named.insert('shared', 1)
|
||||
const removeNamed = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('shared', 2),
|
||||
{ label: 'test.named', notify: false },
|
||||
)
|
||||
const removeTail = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('c', 3),
|
||||
{ label: 'test.tail', notify: false },
|
||||
)
|
||||
const removeAnonymous = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.anonymous.append('kept'),
|
||||
{ label: 'test.anonymous', notify: false },
|
||||
)
|
||||
|
||||
expect(created).toEqual([undefined, key])
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]])
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
removeNamed()
|
||||
expect(layers.peek(key)).toBeDefined()
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]])
|
||||
removeTail()
|
||||
expect(layers.peek(key)).toBeDefined()
|
||||
removeAnonymous()
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => {
|
||||
const ctx = new Context()
|
||||
const events: string[] = []
|
||||
const layers = new ScopedLayers(
|
||||
scope => new TestLayer(scope),
|
||||
() => void events.push('notify'),
|
||||
)
|
||||
const dispose = layers.effect(
|
||||
ctx,
|
||||
(layer) => {
|
||||
events.push('action')
|
||||
const undo = layer.named.insert('x', 1)
|
||||
return () => {
|
||||
events.push('undo')
|
||||
undo()
|
||||
}
|
||||
},
|
||||
{ label: 'store.order' },
|
||||
)
|
||||
|
||||
expect(events).toEqual(['action', 'notify'])
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order')
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events).toEqual(['action', 'notify', 'undo', 'notify'])
|
||||
expect(layers.global.isEmpty()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the exact context effect disposer', () => {
|
||||
const rawDispose = vi.fn()
|
||||
const effect = vi.fn(() => rawDispose)
|
||||
const ctx = { effect } as unknown as Context
|
||||
const action = vi.fn(() => vi.fn())
|
||||
const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn())
|
||||
|
||||
const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false })
|
||||
|
||||
expect(returned).toBe(rawDispose)
|
||||
expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity')
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
let failFactory = true
|
||||
const layers = new ScopedLayers(
|
||||
(selected) => {
|
||||
if (selected !== undefined && failFactory) throw new Error('factory failed')
|
||||
return new TestLayer(selected)
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('never', 1),
|
||||
{ label: 'store.factory', notify: false },
|
||||
)).toThrow('factory failed')
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
|
||||
failFactory = false
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
() => { throw new Error('action failed') },
|
||||
{ label: 'store.action', notify: false },
|
||||
)).toThrow('action failed')
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
|
||||
const dispose = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('kept', 1),
|
||||
{ label: 'store.kept', notify: false },
|
||||
)
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
() => { throw new Error('second action failed') },
|
||||
{ label: 'store.existing-action', notify: false },
|
||||
)).toThrow('second action failed')
|
||||
expect(layers.peek(key)?.named.get('kept')).toBe(1)
|
||||
dispose()
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('rolls back a scoped insertion when notification throws', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
const events: string[] = []
|
||||
let notifications = 0
|
||||
const layers = new ScopedLayers(
|
||||
selected => new TestLayer(selected),
|
||||
() => {
|
||||
events.push('notify')
|
||||
if (++notifications === 1) throw new Error('change failed')
|
||||
},
|
||||
)
|
||||
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
(layer) => {
|
||||
const undo = layer.named.insert('rollback', 1)
|
||||
return () => {
|
||||
events.push('undo')
|
||||
undo()
|
||||
}
|
||||
},
|
||||
{ label: 'store.rollback' },
|
||||
)).toThrow('change failed')
|
||||
|
||||
expect(events).toEqual(['notify', 'undo', 'notify'])
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
await scope.dispose()
|
||||
})
|
||||
})
|
||||
@@ -15,12 +15,21 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./surface": {
|
||||
"types": "./lib/types/surface.d.ts",
|
||||
"default": "./lib/types/surface.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* Surface layer on top of the session event log: an ordered view of events
|
||||
* that produce LLM messages. The append-only log remains the source of truth.
|
||||
*
|
||||
* Browser-safe: web clients consume this subpath export, so it must stay free
|
||||
* of `node:` imports (they break the vite bundle).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/surface
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
|
||||
|
||||
/** Runtime counterpart of the message-producing event union. */
|
||||
@@ -188,6 +190,24 @@ function replacementRange(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep structural equality over the session-event JSON value domain
|
||||
* (null/boolean/number/string, arrays, plain objects). Replaces
|
||||
* `node:util`'s isDeepStrictEqual to keep this module browser-safe.
|
||||
*/
|
||||
function isDeepEqualJson(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
||||
return a.every((item, i) => isDeepEqualJson(item, b[i]))
|
||||
}
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
||||
const aKeys = Object.keys(a)
|
||||
const bRecord = b as Record<string, unknown>
|
||||
if (aKeys.length !== Object.keys(b).length) return false
|
||||
return aKeys.every(key => Object.hasOwn(b, key) && isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]))
|
||||
}
|
||||
|
||||
/** Restrict a tool-result replacement to one current result's content. */
|
||||
function assertToolResultRewrite(
|
||||
event: SessionEvent,
|
||||
@@ -207,7 +227,7 @@ function assertToolResultRewrite(
|
||||
const replacementRest = { ...event.data } as Record<string, unknown>
|
||||
delete originalRest['content']
|
||||
delete replacementRest['content']
|
||||
if (!isDeepStrictEqual(originalRest, replacementRest)) {
|
||||
if (!isDeepEqualJson(originalRest, replacementRest)) {
|
||||
throw new Error('tool/result surface replacement may change only content')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,38 @@ describe('foldSurface tool-result rewrites', () => {
|
||||
]
|
||||
expect(() => foldSurface(events)).toThrow(/may change only content/)
|
||||
})
|
||||
|
||||
it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
|
||||
const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => {
|
||||
const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs)
|
||||
return { ...event, data: { ...(event.data as object), meta } } as SessionEvent
|
||||
}
|
||||
// Structurally equal arrays (fresh references) pass the rest-field equality.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { tags: ['a', { n: 1 }] }),
|
||||
withMeta(1, { tags: ['a', { n: 1 }] }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).not.toThrow()
|
||||
// Same length, drifted element: the array branch must reject.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { tags: ['a'] }),
|
||||
withMeta(1, { tags: ['b'] }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).toThrow(/may change only content/)
|
||||
// Array vs non-array on one side: the mixed-shape guard rejects.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { tags: ['a'] }),
|
||||
withMeta(1, { tags: 'a' }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).toThrow(/may change only content/)
|
||||
// Same key count, different key names: the hasOwn branch rejects.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { left: 1 }),
|
||||
withMeta(1, { right: 1 }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).toThrow(/may change only content/)
|
||||
// Different key counts: the key-length branch rejects.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { one: 1 }),
|
||||
withMeta(1, { one: 1, two: 2 }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).toThrow(/may change only content/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
|
||||
@@ -6,8 +6,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 { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -209,6 +209,39 @@ function interpolate(section: AssembledSection, variables: Record<string, string
|
||||
return result + text.slice(last)
|
||||
}
|
||||
|
||||
/** One tool-schema provider stored in a prompt layer. */
|
||||
type ToolProvider = (context: AssembleContext) => ToolProviderResult
|
||||
|
||||
/** One prompt-variable provider stored in a prompt layer. */
|
||||
type VariableProvider = (context: AssembleContext) => string | undefined
|
||||
|
||||
/** All prompt registrations owned by one global or scoped layer. */
|
||||
class PromptLayer implements ScopeLayer {
|
||||
readonly sections: NamedEntries<PromptSection>
|
||||
readonly toolProviders = new AnonymousEntries<ToolProvider>()
|
||||
readonly variables: NamedEntries<VariableProvider>
|
||||
|
||||
/**
|
||||
* Create one prompt layer with diagnostics specific to its ownership scope.
|
||||
* @param scope - the scoped owner, or `undefined` for global registrations.
|
||||
*/
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.sections = new NamedEntries(name => new Error(scope === undefined
|
||||
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${name}" is already registered in this scope`))
|
||||
this.variables = new NamedEntries(name => 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`))
|
||||
}
|
||||
|
||||
/** @returns whether this layer owns no prompt registrations. */
|
||||
isEmpty(): boolean {
|
||||
return this.sections.isEmpty()
|
||||
&& this.toolProviders.isEmpty()
|
||||
&& this.variables.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Registry service for the prompt inputs assembled before each model step. */
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -217,13 +250,10 @@ export class SystemPrompt extends Service {
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
})
|
||||
|
||||
private sections: PromptSection[] = []
|
||||
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 layers = new ScopedLayers(
|
||||
scope => new PromptLayer(scope),
|
||||
() => { this.ctx.emit('system-prompt/change') },
|
||||
)
|
||||
private readonly toolOrder: string[] | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -255,34 +285,11 @@ export class SystemPrompt extends Service {
|
||||
if (!Number.isFinite(section.order)) {
|
||||
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
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`)
|
||||
}
|
||||
layer.push(section)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
const index = layer.indexOf(section)
|
||||
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
|
||||
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')
|
||||
}.bind(this), 'systemPrompt.section()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.sections.insert(section.name, section),
|
||||
{ label: 'systemPrompt.section()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,29 +300,11 @@ export class SystemPrompt extends Service {
|
||||
* @returns the exact Cordis effect disposer.
|
||||
*/
|
||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
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)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
const index = layer.indexOf(provider)
|
||||
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
|
||||
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')
|
||||
}.bind(this), 'systemPrompt.tools()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.toolProviders.append(provider),
|
||||
{ label: 'systemPrompt.tools()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,32 +319,11 @@ export class SystemPrompt extends Service {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
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`)
|
||||
}
|
||||
layer.set(name, provider)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
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')
|
||||
}.bind(this), 'systemPrompt.variable()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.variables.insert(name, provider),
|
||||
{ label: 'systemPrompt.variable()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,23 +338,19 @@ export class SystemPrompt extends Service {
|
||||
const scope = context.scope
|
||||
// Scoped variables shadow globals.
|
||||
const variables: Record<string, string | undefined> = {}
|
||||
for (const [name, provider] of this.variableProviders) {
|
||||
for (const [name, provider] of this.layers.global.variables.entries()) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
|
||||
for (const [name, provider] of scopedVariables ?? []) {
|
||||
const scopedVariables = this.layers.peek(scope)?.variables
|
||||
for (const [name, provider] of scopedVariables?.entries() ?? []) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
// Scoped sections shadow globals before the stable order 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)
|
||||
}
|
||||
const sectionByName = this.layers.merge(scope, layer => layer.sections)
|
||||
// Validate order against pre-restriction names while collecting visible schemas.
|
||||
const providers = [
|
||||
...this.toolProviders,
|
||||
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
|
||||
...this.layers.global.toolProviders.values(),
|
||||
...(this.layers.peek(scope)?.toolProviders.values() ?? []),
|
||||
]
|
||||
const collected: ToolSchema[] = []
|
||||
const knownNames = new Set<string>()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
@@ -63,6 +63,21 @@ describe('scoped sections', () => {
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it('shadows a global section before evaluating either text provider', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const globalText = vi.fn(() => 'global text')
|
||||
const scopedText = vi.fn(() => 'scoped text')
|
||||
ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText })
|
||||
scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
||||
|
||||
expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text')
|
||||
expect(globalText).not.toHaveBeenCalled()
|
||||
expect(scopedText).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('scoped variables', () => {
|
||||
@@ -86,6 +101,28 @@ describe('scoped variables', () => {
|
||||
const again = await mintScope(ctx, 'child2')
|
||||
again.ctx.systemPrompt.variable('v', () => '3')
|
||||
})
|
||||
|
||||
it('defers a scoped variable that replaces the last provider in its generation', async () => {
|
||||
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const key = scopeKeyOf(scope)
|
||||
const calls: string[] = []
|
||||
scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' })
|
||||
const dispose = scope.ctx.systemPrompt.variable('mode', () => {
|
||||
calls.push('first')
|
||||
dispose()
|
||||
scope.ctx.systemPrompt.variable('mode', () => {
|
||||
calls.push('replacement')
|
||||
return 'replacement'
|
||||
})
|
||||
return 'first'
|
||||
})
|
||||
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.')
|
||||
expect(calls).toEqual(['first'])
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.')
|
||||
expect(calls).toEqual(['first', 'replacement'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped tool providers and toolOrder × restriction', () => {
|
||||
|
||||
@@ -157,6 +157,24 @@ describe('SystemPrompt', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
|
||||
})
|
||||
|
||||
it('snapshots tool-provider membership before evaluating an assembly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let added = false
|
||||
ctx.systemPrompt.tools(() => {
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.systemPrompt.tools(() => ({
|
||||
schemas: [{ name: 'late', description: '', parameters: {} }],
|
||||
}))
|
||||
}
|
||||
return { schemas: [{ name: 'first', description: '', parameters: {} }] }
|
||||
})
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
|
||||
})
|
||||
|
||||
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -314,6 +332,24 @@ describe('SystemPrompt', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
})
|
||||
|
||||
it('live-iterates variables registered by an earlier provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let added = false
|
||||
ctx.systemPrompt.variable('first', () => {
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.systemPrompt.variable('late', () => 'second value')
|
||||
}
|
||||
return 'first value'
|
||||
})
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({
|
||||
first: 'first value',
|
||||
late: 'second value',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a duplicate variable name and an unreferenceable name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./presentation": {
|
||||
"types": "./lib/types/presentation.d.ts",
|
||||
"default": "./lib/types/presentation.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -6,8 +6,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 { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
@@ -561,9 +561,40 @@ interface ToolView {
|
||||
*/
|
||||
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
||||
|
||||
/** One guard registration; the wrapper preserves independent duplicate registrations. */
|
||||
interface ToolGuardRegistration {
|
||||
guard: ToolGuard
|
||||
/** One scope's complete tool-registry contribution. */
|
||||
class ToolLayer implements ScopeLayer {
|
||||
readonly tools: NamedEntries<ToolDefinition>
|
||||
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
|
||||
readonly guards = new AnonymousEntries<ToolGuard>()
|
||||
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.tools = new NamedEntries(name => new Error(scope === undefined
|
||||
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** Whether every contribution table in this aggregate layer is empty. */
|
||||
isEmpty(): boolean {
|
||||
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
|
||||
}
|
||||
|
||||
/** Whether every compiled restriction in this layer admits a global tool name. */
|
||||
admits(name: string): boolean {
|
||||
for (const filter of this.restrictions.values()) {
|
||||
if ((filter.allow !== undefined && !filter.allow.has(name))
|
||||
|| (filter.deny !== undefined && filter.deny.has(name))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** First monotonic denial from this layer's live guard registrations. */
|
||||
guardReason(exec: ToolExecution): string | undefined {
|
||||
for (const guard of this.guards.values()) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Approval decision plus whether the approval channel reported cancellation. */
|
||||
@@ -607,13 +638,10 @@ export class ToolRegistry extends Service {
|
||||
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
|
||||
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
||||
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
||||
private global = new Map<string, ToolDefinition>()
|
||||
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||
/** Compiled restriction filters, per scope (see {@link restrict}). */
|
||||
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
|
||||
/** Monotonic post-policy guards, split into global and per-agent layers. */
|
||||
private globalGuards = new Set<ToolGuardRegistration>()
|
||||
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new ToolLayer(scope),
|
||||
() => { this.ctx.emit('tools/change') },
|
||||
)
|
||||
private readonly mode: ToolPresentationMode
|
||||
/** Reserved presentation transport, kept outside the filterable registration layers. */
|
||||
private readonly codeTransport: ToolDefinition | undefined
|
||||
@@ -691,7 +719,6 @@ export class ToolRegistry extends Service {
|
||||
* @returns the exact disposer that unregisters the tool.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const name = definition.name
|
||||
const output = (definition as Partial<ToolDefinition>).output
|
||||
if (output === undefined || typeof output !== 'object'
|
||||
@@ -708,26 +735,11 @@ export class ToolRegistry extends Service {
|
||||
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
|
||||
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(name, definition)
|
||||
// Install rollback before notifying listeners.
|
||||
yield () => {
|
||||
layer.delete(name)
|
||||
// Drop empty scope layers.
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.register()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.tools.insert(name, definition),
|
||||
{ label: 'tools.register()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -760,22 +772,11 @@ export class ToolRegistry extends Service {
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...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(compiled)
|
||||
yield () => {
|
||||
const index = list.indexOf(compiled)
|
||||
/* v8 ignore next 3 -- defensive: the compiled restriction 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()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.restrictions.append(compiled),
|
||||
{ label: 'tools.restrict()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -789,63 +790,18 @@ export class ToolRegistry extends Service {
|
||||
* @returns the exact disposer that unregisters the guard.
|
||||
*/
|
||||
guard(guard: ToolGuard): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registration = { guard }
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
|
||||
layer.add(registration)
|
||||
yield () => {
|
||||
layer.delete(registration)
|
||||
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
|
||||
}
|
||||
}.bind(this), 'tools.guard()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return 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
|
||||
}
|
||||
|
||||
/** Get or create the guard layer for one agent scope. */
|
||||
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
|
||||
let layer = this.scopedGuards.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Set()
|
||||
this.scopedGuards.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.guards.append(guard),
|
||||
{ label: 'tools.guard()', notify: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** First monotonic denial from the global then matching scoped guard layers. */
|
||||
private guardReason(exec: ToolExecution): string | undefined {
|
||||
for (const { guard } of this.globalGuards) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
if (exec.agent !== undefined) {
|
||||
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 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.has(name))
|
||||
&& (filter.deny === undefined || !filter.deny.has(name)))
|
||||
const globalReason = this.layers.global.guardReason(exec)
|
||||
if (globalReason !== undefined) return globalReason
|
||||
return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -857,18 +813,18 @@ export class ToolRegistry extends Service {
|
||||
* @returns the complete derived view for that scope.
|
||||
*/
|
||||
private view(scope?: ScopeKey): ToolView {
|
||||
const layer = scope === undefined ? undefined : this.scoped.get(scope)
|
||||
const layer = this.layers.peek(scope)
|
||||
const visible = new Map<string, ToolDefinition>()
|
||||
const knownNames = new Set<string>()
|
||||
const restrictableNames = new Set<string>()
|
||||
for (const [name, definition] of this.global) {
|
||||
for (const [name, definition] of this.layers.global.tools.entries()) {
|
||||
knownNames.add(name)
|
||||
restrictableNames.add(name)
|
||||
if (this.admits(scope, name)) visible.set(name, definition)
|
||||
if (layer?.admits(name) ?? true) visible.set(name, definition)
|
||||
}
|
||||
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
|
||||
// and scope-local registrations are never part of the global filter above.
|
||||
for (const [name, definition] of layer ?? []) {
|
||||
for (const [name, definition] of layer?.tools.entries() ?? []) {
|
||||
knownNames.add(name)
|
||||
visible.set(name, definition)
|
||||
}
|
||||
|
||||
@@ -269,6 +269,49 @@ describe('scoped execution dispatch', () => {
|
||||
expect(bodyCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('live-iterates a guard registered by an earlier guard', async () => {
|
||||
const ctx = await mount()
|
||||
const calls: string[] = []
|
||||
let added = false
|
||||
ctx.tools.register(tool('t'))
|
||||
ctx.tools.guard(() => {
|
||||
calls.push('first')
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.tools.guard(() => {
|
||||
calls.push('late')
|
||||
return 'late denial'
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(await run(ctx, 't')).toBe('Error: late denial')
|
||||
expect(calls).toEqual(['first', 'late'])
|
||||
})
|
||||
|
||||
it('defers a scoped guard that replaces the last guard in its generation', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
const calls: string[] = []
|
||||
ctx.tools.register(tool('t'))
|
||||
scope.ctx.tools.register(tool('scope_sibling'))
|
||||
const lift = scope.ctx.tools.guard(() => {
|
||||
calls.push('first')
|
||||
lift()
|
||||
scope.ctx.tools.guard(() => {
|
||||
calls.push('replacement')
|
||||
return 'replacement denial'
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(await run(ctx, 't', key)).toBe('ran:t')
|
||||
expect(calls).toEqual(['first'])
|
||||
expect(await run(ctx, 't', key)).toBe('Error: replacement denial')
|
||||
expect(calls).toEqual(['first', 'replacement'])
|
||||
})
|
||||
|
||||
it('shares one token and materialized argument value across the pipeline', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
|
||||
Reference in New Issue
Block a user