fix(core): enforce agent-scoped ownership boundaries
This commit is contained in:
@@ -11,7 +11,6 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -16,6 +16,51 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
/** Agents whose rollback-covered publication enabled driving. */
|
||||
const driveEnabledAgents = new WeakSet<ReactLoopAgent>()
|
||||
|
||||
/** Sessions already claimed by a concrete driver construction. */
|
||||
const claimedDriverSessions = new WeakSet<Session>()
|
||||
|
||||
/** Module-private driver entry: its symbol is absent from the package surface. */
|
||||
const startDriver = Symbol('dsh.agent-loop.start-driver')
|
||||
|
||||
/** Factory-owned controls that can operate only on the agent created with them. */
|
||||
export interface PreparedReactLoopAgent {
|
||||
/** The unpublished concrete agent. */
|
||||
agent: ReactLoopAgent
|
||||
/** Open its driving verbs at the rollback-covered publication boundary. */
|
||||
enableDrive(): void
|
||||
/** Start its driver after publication and session-start notification. */
|
||||
startDriver(): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct one concrete agent together with unforgeable, instance-bound
|
||||
* lifecycle controls. The package surface deliberately exposes neither source
|
||||
* subpaths nor this helper: setup code may identify the concrete class, but it
|
||||
* cannot enable or start the factory's unpublished instance.
|
||||
* @param ctx - the agent-loop service context used for driving and events.
|
||||
* @param id - the concrete agent identity.
|
||||
* @param options - loop options for the agent.
|
||||
* @param session - the prepared session the agent will own.
|
||||
* @returns the agent and closures bound only to that exact instance.
|
||||
*/
|
||||
export function prepareReactLoopAgent(
|
||||
ctx: Context, id: AgentId, options: AgentOptions, session: Session,
|
||||
): PreparedReactLoopAgent {
|
||||
if (claimedDriverSessions.has(session)) {
|
||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||
}
|
||||
claimedDriverSessions.add(session)
|
||||
const agent = new ReactLoopAgent(ctx, id, options, session)
|
||||
return {
|
||||
agent,
|
||||
enableDrive: () => { driveEnabledAgents.add(agent) },
|
||||
startDriver: () => agent[startDriver](),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
||||
*
|
||||
@@ -24,11 +69,8 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/**
|
||||
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
|
||||
* the driver loop can drain it; {@link cancel} clears it wholesale.
|
||||
*/
|
||||
readonly inbox = new Inbox()
|
||||
/** Queued + steering FIFOs; native-private so setup cannot bypass driving verbs. */
|
||||
readonly #inbox = new Inbox()
|
||||
|
||||
/**
|
||||
* The agent's scope context ({@link Agent.ctx}), wired by the factory right
|
||||
@@ -119,7 +161,7 @@ export class ReactLoopAgent implements Agent {
|
||||
/**
|
||||
* Resolve and clear all pending {@link whenIdle} waiters. Called on a
|
||||
* running→idle transition (from {@link setStatus}) and on disposal (from the
|
||||
* {@link start} disposer, which chains `done` for true loop-exit quiescence).
|
||||
* internal driver disposer, which chains `done` for true loop-exit quiescence).
|
||||
*/
|
||||
private settleIdleWaiters(): void {
|
||||
const waiters = this.idleWaiters
|
||||
@@ -131,22 +173,31 @@ export class ReactLoopAgent implements Agent {
|
||||
return options?.source ?? { kind: 'user' }
|
||||
}
|
||||
|
||||
/** Reject every driving verb while creation setup still owns the agent. */
|
||||
private assertDriveEnabled(action: string): void {
|
||||
if (driveEnabledAgents.has(this)) return
|
||||
throw new Error(`agent "${this.id}" cannot ${action} before creation setup completes`)
|
||||
}
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('send')
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
const source = this.resolveSource(options)
|
||||
this.inbox.enqueue({ content, source })
|
||||
this.#inbox.enqueue({ content, source })
|
||||
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false })
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('steer')
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const source = this.resolveSource(options)
|
||||
this.inbox.steer({ content, source })
|
||||
this.#inbox.steer({ content, source })
|
||||
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true })
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('inject')
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
const source = this.resolveSource(options)
|
||||
if (isTurnOpen(this.session)) {
|
||||
@@ -220,6 +271,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
cancel(reason?: string): void {
|
||||
this.assertDriveEnabled('cancel')
|
||||
// Arm-gate: only mark a cancellation when there is actually work to cancel —
|
||||
// a running turn, an in-flight step, or queued/steering work. An idle cancel
|
||||
// with nothing pending is a true no-op; arming the marker then would wrongly
|
||||
@@ -229,7 +281,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// the pre-step window (a send() queued but the loop not yet flipped to
|
||||
// running) has status `idle` with `hasQueued` true, and the marker exists
|
||||
// precisely to cover it.
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) {
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
|
||||
this.cancelRequested = true
|
||||
// Capture the resolved reason for the marker-only windows (pre-step /
|
||||
// continuation). The mid-step path reads it from abort.signal.reason
|
||||
@@ -240,7 +292,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// cancelled turn's steering is not re-enqueued). Cleared directly even when
|
||||
// the loop is parked in waitForQueued — there is no turn to stop and nothing
|
||||
// left for the parked loop to run, so no wake is needed.
|
||||
this.inbox.clear()
|
||||
this.#inbox.clear()
|
||||
// Interrupt an in-flight step immediately (the running turn observes the
|
||||
// abort and ends `aborted`). The marker covers the windows where no step is
|
||||
// running (pre-step, continuation).
|
||||
@@ -263,7 +315,7 @@ export class ReactLoopAgent implements Agent {
|
||||
*/
|
||||
whenIdle(): Promise<void> {
|
||||
if (this._status === 'disposed') return this.done
|
||||
if (this._status !== 'running' && !this.inbox.hasQueued) return Promise.resolve()
|
||||
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
|
||||
// Register an internal waiter (resolved by settleIdleWaiters on the next
|
||||
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
|
||||
// a concurrent fiber disposal runs this agent's listener disposers, which
|
||||
@@ -287,8 +339,9 @@ export class ReactLoopAgent implements Agent {
|
||||
* @returns the disposer — idempotent and infallible (it runs inside the
|
||||
* fiber's LIFO disposal chain, where a throw would skip later disposers).
|
||||
*/
|
||||
start(): () => void {
|
||||
[startDriver](): () => void {
|
||||
this.done = runLoop(this.loopCtx, this, {
|
||||
inbox: this.#inbox,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
setAbort: controller => void (this.currentAbort = controller),
|
||||
disposed: this.disposed,
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
* @module @deepseek-ai/dsh-agent-loop
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, FiberState, Service } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
@@ -19,11 +20,9 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { ReactLoopAgent } from './agent.ts'
|
||||
import { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
|
||||
|
||||
export { ReactLoopAgent } from './agent.ts'
|
||||
export { Inbox, type InboxMessage } from './inbox.ts'
|
||||
export { runLoop } from './loop.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -70,6 +69,10 @@ export interface Config {
|
||||
export class AgentLoop extends Service implements AgentFactory {
|
||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
|
||||
/** IDs held by unpublished async creation transactions. */
|
||||
private pendingAgentIds = new Set<AgentId>()
|
||||
private pendingSessionIds = new Set<SessionId>()
|
||||
|
||||
// The schema validates plain strings (cordis.yml config values are untyped at
|
||||
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
|
||||
// because the config format is the boundary where an id enters. The brand is a
|
||||
@@ -165,18 +168,28 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle {
|
||||
// Check the agent id BEFORE preparing the session: register() would reject a
|
||||
// duplicate id only AFTER the session enters the store, leaving an orphaned
|
||||
// live session (and lazy persistence state) that blocks reuse of that id.
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const session = this.ctx.sessions.prepare(options.sessionId, {
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
meta: options.meta ?? {},
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
|
||||
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup', options.setup)
|
||||
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
// Snapshot every caller-owned field before the first async setup boundary.
|
||||
// The callback itself is an identity capability; all data fields are
|
||||
// detached so caller mutation cannot drift a reserved/published identity or
|
||||
// the options the accepted agent observes.
|
||||
const agentId = options.agentId
|
||||
const sessionId = options.sessionId
|
||||
const setup = options.setup
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
|
||||
const meta = structuredClone(options.meta ?? {})
|
||||
const release = this.reserve(agentId, sessionId)
|
||||
try {
|
||||
const session = this.ctx.sessions.prepare(sessionId, {
|
||||
...seed !== undefined ? { seed } : {},
|
||||
meta,
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume.
|
||||
return await this.startOwned(agentId, agentOptions, session, 'startup', setup)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,31 +239,76 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* AgentLoop's static inject, so they resolve fine).
|
||||
*/
|
||||
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const { meta, events } = await persistence.load(options.resumeSessionId)
|
||||
// Re-check the agent id AFTER the await: the pre-load check above can go
|
||||
// stale while load() is pending (a concurrent resume/create may register the
|
||||
// same id). Re-checking immediately before prepare()/start keeps the
|
||||
// "no orphaned session on a duplicate id" guarantee under concurrency.
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
// Reconstruct the live session with the FULL persisted header (createdAt,
|
||||
// cwd, lineage) so resume preserves identity, not just the cwd. The seed
|
||||
// events make lastTurnNumber/deriveMessages continue; the backend already
|
||||
// has state (cursor) from the load above, so onCreated is a no-op and the
|
||||
// seed is not re-persisted. prepare() (not create()) so the session
|
||||
// lifecycle folds into the agent's composite effect (ordered teardown).
|
||||
const session = this.ctx.sessions.prepare(options.resumeSessionId, {
|
||||
seed: events,
|
||||
meta: {
|
||||
createdAt: meta.createdAt,
|
||||
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
|
||||
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
||||
// Reconstruct the seed boundary from the persisted header, NOT from
|
||||
// `events.length` (the resume seeds the WHOLE stored log).
|
||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
||||
},
|
||||
})
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
|
||||
// Persistence is an async trust boundary. Reserve, load, reconstruct, and
|
||||
// publish only the identities/options accepted at entry—never fields
|
||||
// reread from a caller-owned object after the await.
|
||||
const agentId = options.agentId
|
||||
const sessionId = options.resumeSessionId
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const setup = options.setup
|
||||
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
|
||||
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
|
||||
let observingOwner = true
|
||||
// Resume must observe its caller from BEFORE persistence I/O begins. The
|
||||
// full agent lifecycle does not exist until load returns, so without this
|
||||
// sentinel a never-settling backend outlives owner disposal and holds both
|
||||
// public identities forever. `this.ctx.effect` retains the traceable caller
|
||||
// ownership used by startOwned's lifecycle effect. Install it before even
|
||||
// reserving the ids: an inactive owner cannot leak a reservation if effect
|
||||
// registration fails.
|
||||
const disposeLoadSentinel = this.ctx.effect(() => () => {
|
||||
if (!observingOwner) return
|
||||
markOwnerDisposed()
|
||||
// Owner-triggered teardown does not reach quiescence until the resume
|
||||
// transaction has observed disposal and released both reservations.
|
||||
return transactionSettled
|
||||
}, `agentLoop.resumeLoad(${agentId})`)
|
||||
try {
|
||||
const release = this.reserve(agentId, sessionId)
|
||||
try {
|
||||
const loadTask = persistence.load(sessionId)
|
||||
const { meta, events } = await Promise.race([
|
||||
loadTask,
|
||||
ownerDisposed.then(() => {
|
||||
throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`)
|
||||
}),
|
||||
])
|
||||
// An out-of-band direct registry/session insertion can still race this
|
||||
// service's reservation, so the public enter primitives re-check exact
|
||||
// liveness at publication.
|
||||
const session = this.ctx.sessions.prepare(sessionId, {
|
||||
seed: events,
|
||||
meta: {
|
||||
createdAt: meta.createdAt,
|
||||
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
|
||||
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
||||
},
|
||||
})
|
||||
// Calling startOwned synchronously installs the complete lifecycle
|
||||
// effect before it reaches its first setup await. Only then disarm the
|
||||
// load sentinel: ownership passes directly from one effect to the other
|
||||
// with no disposal gap.
|
||||
const starting = this.startOwned(agentId, agentOptions, session, 'resume', setup)
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
return await starting
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// Manual handoff/removal must not return transactionSettled: awaiting
|
||||
// that promise from inside this transaction would deadlock it. If the
|
||||
// owner already triggered cleanup, this idempotent second disposal is a
|
||||
// no-op and the owner's first cleanup remains parked on the shared
|
||||
// settlement promise.
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
} finally {
|
||||
markTransactionSettled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,109 +318,136 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* only after the session has already entered the store.
|
||||
*/
|
||||
private assertAgentIdFree(id: AgentId): void {
|
||||
if (this.ctx.agents.get(id) !== undefined) {
|
||||
if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserve both public identities for one unpublished async transaction. */
|
||||
private reserve(agentId: AgentId, sessionId: SessionId): () => void {
|
||||
this.assertAgentIdFree(agentId)
|
||||
if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) {
|
||||
throw new Error(`session "${sessionId}" already exists`)
|
||||
}
|
||||
this.pendingAgentIds.add(agentId)
|
||||
this.pendingSessionIds.add(sessionId)
|
||||
return () => {
|
||||
this.pendingAgentIds.delete(agentId)
|
||||
this.pendingSessionIds.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered)
|
||||
* session, then build the ONE composite effect that owns the whole agent
|
||||
* lifecycle — session entry, registry registration, and the loop. Keeping all
|
||||
* three in a SINGLE effect (not sibling effects) is load-bearing: a fiber
|
||||
* unload disposes sibling effects CONCURRENTLY (`Promise.all`), which would
|
||||
* race the session detach against the loop's closing flush and drop the
|
||||
* closing `turn/end`. Inside one effect the disposers run as an ORDERED LIFO
|
||||
* chain — the runtime awaits each disposer's returned promise before the next:
|
||||
*
|
||||
* yield session-detach (disposed LAST — detach onAppend + remove entry)
|
||||
* yield register (disposed 2nd — unregister)
|
||||
* yield stop-and-drain (disposed FIRST — request loop stop, await agent.done)
|
||||
*
|
||||
* So on teardown: the loop is stopped and AWAITED to exit (its final
|
||||
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
|
||||
* THEN the agent is unregistered, THEN the session is detached — capturing the
|
||||
* closing events before detach, whether the trigger is the handle's `dispose()`
|
||||
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
|
||||
* so a throwing `session/created`/`agent/created` listener unwinds the
|
||||
* already-yielded disposers instead of leaking.
|
||||
*
|
||||
* `source` says why the session began ({@link SessionStartSource}); it is
|
||||
* emitted as `agent/session-start` once, AFTER the agent is registered (so a
|
||||
* listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into
|
||||
* it) and BEFORE the loop starts its first turn. The emit is contained: a
|
||||
* throwing session-start listener must not abort agent construction — it is
|
||||
* logged, and the agent still starts. (Unlike a turn-boundary throw, there is
|
||||
* no open turn here to balance; the durable evidence of a session-start hook
|
||||
* is whatever it `inject()`ed.)
|
||||
*
|
||||
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
|
||||
* Construct an unpublished agent and synchronously install its complete
|
||||
* teardown skeleton before any setup await. The closures are assigned their
|
||||
* session/registry/loop disposers only at publication, while the exact scope
|
||||
* disposer is nested immediately. Therefore owner unload during setup flips
|
||||
* `active`, unwinds the scope, and wins the race without any late Cordis
|
||||
* effect collection.
|
||||
*/
|
||||
private start(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
setup?: (agentCtx: Context) => void,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const agent = new ReactLoopAgent(this.ctx, id, options, session)
|
||||
// The ONE quiescence boundary every disposal path observes. Cordis effect
|
||||
// disposers are single-shot but not await-idempotent: when the OWNING
|
||||
// fiber's unload invokes the raw wrapper first, a concurrent
|
||||
// `handle.dispose()` calling the same wrapper gets an immediate undefined
|
||||
// (epoch already cleared) — so the handle path must await THIS promise,
|
||||
// resolved by the teardown chain's final disposer, not the wrapper's
|
||||
// return. Every disposer in the chain is deliberately infallible (stop()
|
||||
// is infallible by contract, unregister/detach contain their listeners,
|
||||
// the scope unwind is cordis-contained), so the final disposer always
|
||||
// runs — a throwing link would skip the rest of a cordis dispose chain.
|
||||
private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): {
|
||||
agent: ReactLoopAgent
|
||||
active: () => boolean
|
||||
deactivated: Promise<void>
|
||||
publish: (source: SessionStartSource) => void
|
||||
disposeAgent: () => Promise<void>
|
||||
} {
|
||||
// When creation is invoked through an agent scope (subagents), the owner
|
||||
// agent's disposed status flips synchronously at handle teardown—earlier
|
||||
// than Cordis reaches nested scope effects. Include that signal in the
|
||||
// pre-publication liveness check so a same-turn parent dispose cannot race
|
||||
// an already-fulfilled setup promise into briefly publishing a child.
|
||||
const ownerAgent = this.ctx.agent
|
||||
const ownerFiber = this.ctx.fiber
|
||||
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
|
||||
const { agent } = driver
|
||||
const scope: Scope = createScope(this.ctx, agent)
|
||||
agent.ctx = scope.ctx.extend({ agent })
|
||||
|
||||
let active = true
|
||||
let detachSession: (() => void) | undefined
|
||||
let detachAgent: (() => void) | undefined
|
||||
let stop: (() => void) | undefined
|
||||
const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers<void>()
|
||||
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
|
||||
const dispose = this.ctx.effect(function* (this: AgentLoop) {
|
||||
// First-yielded ⇒ disposed LAST: marks true teardown completion.
|
||||
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
// First yielded, disposed last: every preceding teardown stage settled.
|
||||
yield () => { markTorndown() }
|
||||
// Mint the agent's scope (key = the agent) and wire the two-phase
|
||||
// reference: the scope context tags registrations + filters dispatch;
|
||||
// the extend adds the `ctx.agent` DX own-property on top. The raw
|
||||
// disposer is yielded IMMEDIATELY (exact function identity nests the
|
||||
// scope fiber out of the loop fiber's concurrent sibling list), so
|
||||
// there is no window in which a throw leaves the scope un-nested.
|
||||
//
|
||||
// Yield order is the REVERSE of teardown (LIFO). Teardown runs:
|
||||
// stop/drain → unregister → detach session → unwind scope
|
||||
// Detach BEFORE the scope unwind is deliberate: the scope fiber's
|
||||
// unload is asynchronous (fiber inertia), and every disposer chained
|
||||
// after an async one waits for it — detaching first keeps the
|
||||
// store/registry rollback SYNCHRONOUS on every failure path (a caller
|
||||
// that catches a throwing create() observes no half-created agent or
|
||||
// session, and the ids are immediately reusable), at the cost that a
|
||||
// scoped listener's own disposer runs after the session left the store
|
||||
// (it heard the final stop/drain flush while still attached, so
|
||||
// nothing durable is lost).
|
||||
const scope = createScope(this.ctx, agent)
|
||||
agent.ctx = scope.ctx.extend({ agent })
|
||||
// Exact identity moves the scope fiber out of the owner's concurrent
|
||||
// sibling list and into this ordered transaction.
|
||||
yield scope.rawDispose
|
||||
// Enter the session THROUGH agent.ctx so the store captures the agent's
|
||||
// scope as the session's dispatch carrier.
|
||||
yield agent.ctx.sessions.enter(session)
|
||||
yield () => {
|
||||
detachSession?.()
|
||||
detachSession = undefined
|
||||
}
|
||||
yield () => {
|
||||
detachAgent?.()
|
||||
detachAgent = undefined
|
||||
}
|
||||
// Last yielded, disposed first. Keep the pre-publication path
|
||||
// synchronous: returning a Promise only after the loop actually began
|
||||
// lets a failed announcement roll back registry/store before create's
|
||||
// rejection is observed.
|
||||
yield () => {
|
||||
active = false
|
||||
markDeactivated()
|
||||
if (stop === undefined) return
|
||||
stop()
|
||||
return agent.done
|
||||
}
|
||||
}, 'agentLoop.lifecycle()')
|
||||
|
||||
let disposing: Promise<void> | undefined
|
||||
const disposeAgent = (): Promise<void> => (disposing ??= (async () => {
|
||||
await dispose()
|
||||
await torndown
|
||||
})())
|
||||
|
||||
const publish = (source: SessionStartSource): void => {
|
||||
// Publication is one synchronous, rollback-covered sequence. Setup has
|
||||
// already completed, so its scoped listeners observe both announcements.
|
||||
detachSession = agent.ctx.sessions.enter(session)
|
||||
detachAgent = this.ctx.agents.enter(agent)
|
||||
this.ctx.sessions.announce(session)
|
||||
yield this.ctx.agents.register(agent)
|
||||
// The creator's scoped composition, inside the rollback boundary: a
|
||||
// throwing setup unwinds LIFO through register → scope → detach, so a
|
||||
// half-created agent never leaks. Setup REGISTERS (through agent.ctx),
|
||||
// it never drives — see CreateAgentOptions.setup.
|
||||
setup?.(agent.ctx)
|
||||
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
|
||||
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
|
||||
// never aborts construction (no open turn to balance here).
|
||||
this.ctx.agents.announce(agent)
|
||||
// Setup is over and both entries are live. Open the driving surface just
|
||||
// before session-start so its listeners retain their supported ability to
|
||||
// inject/queue, while setup itself can never drive an unpublished agent.
|
||||
driver.enableDrive()
|
||||
try {
|
||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
|
||||
}
|
||||
const stop = agent.start()
|
||||
// Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's
|
||||
// actual exit so its closing flush lands while onAppend (yielded above,
|
||||
// disposed later) is still attached.
|
||||
yield async () => { stop(); await agent.done }
|
||||
}.bind(this), 'agentLoop.start()')
|
||||
return { agent, disposeAgent: async () => { await dispose(); await torndown } }
|
||||
stop = driver.startDriver()
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
active: () => active
|
||||
&& ownerFiber.state !== FiberState.UNLOADING
|
||||
&& ownerFiber.state !== FiberState.DISPOSED
|
||||
&& ownerFiber.state !== FiberState.FAILED
|
||||
&& ownerAgent?.status !== 'disposed',
|
||||
deactivated,
|
||||
publish,
|
||||
disposeAgent,
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a no-setup config agent synchronously. */
|
||||
private start(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session)
|
||||
try {
|
||||
lifecycle.publish(source)
|
||||
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
|
||||
} catch (error: unknown) {
|
||||
void lifecycle.disposeAgent()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,13 +467,37 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||
* helper).
|
||||
*/
|
||||
private startOwned(
|
||||
private async startOwned(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
setup?: (agentCtx: Context) => void,
|
||||
): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, session, source, setup)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { agent, dispose: () => (disposing ??= disposeAgent()) }
|
||||
setup?: (agentCtx: Context) => Promise<void> | void,
|
||||
): Promise<AgentHandle> {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session)
|
||||
try {
|
||||
// The owner-disposal branch makes a never-settling setup unable to hold
|
||||
// the transaction or its ID reservations forever. Promise.race installs
|
||||
// rejection observation on setup even if owner disposal wins first.
|
||||
const setupTask = Promise.resolve(setup?.(lifecycle.agent.ctx))
|
||||
await Promise.race([
|
||||
setupTask,
|
||||
lifecycle.deactivated.then(() => {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}),
|
||||
])
|
||||
// Cordis begins a fiber unload synchronously but invokes nested effect
|
||||
// disposers from its next microtask. Give that already-started unload one
|
||||
// checkpoint to deactivate this lifecycle before publication; otherwise
|
||||
// an immediately fulfilled setup continuation can outrun its owner's
|
||||
// same-turn dispose and briefly publish an already-doomed child.
|
||||
await Promise.resolve()
|
||||
if (!lifecycle.active()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
lifecycle.publish(source)
|
||||
return { agent: lifecycle.agent, dispose: lifecycle.disposeAgent }
|
||||
} catch (error: unknown) {
|
||||
await lifecycle.disposeAgent()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, ContinuationStop, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
@@ -20,6 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
|
||||
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||
type CodedError = Error & { code?: string }
|
||||
@@ -35,6 +36,20 @@ function toError(error: unknown): CodedError {
|
||||
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the runtime result of the terminal-stop serial event. Event types
|
||||
* protect TypeScript listeners, but JavaScript and casts can still return an
|
||||
* arbitrary bail value; accepting one as an implicit stop would hide a broken
|
||||
* policy plugin.
|
||||
*/
|
||||
function assertContinuationStop(value: unknown): asserts value is ContinuationStop | undefined {
|
||||
if (value === undefined) return
|
||||
const candidate = Object(value) as { action?: unknown }
|
||||
if (candidate.action !== 'stop') {
|
||||
throw new Error('agent/turn-stop returned an invalid result; expected { action: \'stop\' } or undefined')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a model-call {@link FinishReason} to the step error it should raise, or
|
||||
* `undefined` when the step completed normally.
|
||||
@@ -108,6 +123,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
* loop testable without a real agent.
|
||||
*/
|
||||
export interface LoopHandle {
|
||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||
readonly inbox: Inbox
|
||||
setStatus(status: 'idle' | 'running'): void
|
||||
setAbort(controller: AbortController | undefined): void
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
@@ -185,6 +202,9 @@ export interface LoopHandle {
|
||||
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
|
||||
* recorded as next-step steering
|
||||
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
|
||||
* terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary
|
||||
* continuation and steering folding
|
||||
* if terminal: discard pending steering and break
|
||||
* if action==stop: break
|
||||
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
|
||||
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
|
||||
@@ -210,7 +230,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
const events = agentEvents(ctx, agent)
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
await agent.inbox.waitForQueued(handle.disposed)
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
|
||||
@@ -228,7 +248,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// resolve before it runs (the quiescence contract).
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!agent.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.settleIdle()
|
||||
continue
|
||||
}
|
||||
@@ -250,7 +270,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// is still queued and unrun (the same early-resolve race window 1 fixes).
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!agent.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
@@ -261,8 +281,9 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// the loop waits above, so the next real turn must continue from whatever
|
||||
// turn number is actually last in the log — a stale counter would collide.
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||
} catch (error: unknown) {
|
||||
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
|
||||
// before turn/start) — no turn/start was appended, so no turn is open and
|
||||
@@ -286,27 +307,30 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// cancelled.
|
||||
handle.clearCancel()
|
||||
|
||||
// Steering that arrived too late to join this turn (turn-end listeners,
|
||||
// flush) becomes a queued message — it must never be stranded. (A cancelled
|
||||
// turn already cleared its steering, so there is nothing to re-enqueue.)
|
||||
for (const message of agent.inbox.drainSteering()) {
|
||||
agent.inbox.enqueue(message)
|
||||
// Steering that arrived too late to join an ordinary turn (turn-end
|
||||
// listeners, flush) becomes queued input so it is never stranded. A
|
||||
// terminal-stop owner is the deliberate exception: discard the steering
|
||||
// again after the close + flush window so terminal policy cannot be undone
|
||||
// after its in-turn drain. Ordinary queued sends live in a separate FIFO and
|
||||
// remain untouched.
|
||||
for (const message of handle.inbox.drainSteering()) {
|
||||
if (!terminalStopped) handle.inbox.enqueue(message)
|
||||
}
|
||||
|
||||
if (!agent.inbox.hasQueued) handle.setStatus('idle')
|
||||
if (!handle.inbox.hasQueued) handle.setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const { session } = agent
|
||||
|
||||
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
|
||||
// turn/start has not been appended — so it propagates to runLoop's backstop
|
||||
// untouched. The queued messages are drained here but appended AFTER
|
||||
// turn/start (below), so every event in the log lives inside a turn.
|
||||
const queued = agent.inbox.drainQueued()
|
||||
const queued = handle.inbox.drainQueued()
|
||||
const first = queued[0]
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
@@ -316,6 +340,7 @@ async function runTurn(
|
||||
let step = 0
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
|
||||
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
||||
// are durable session events only — there is no agent/* step emit to mirror
|
||||
@@ -449,7 +474,7 @@ async function runTurn(
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering(agent, turn)
|
||||
drainSteering(agent, handle.inbox, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
@@ -616,7 +641,7 @@ async function runTurn(
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(agent, turn)
|
||||
const steered = drainSteering(agent, handle.inbox, turn)
|
||||
|
||||
if (closeStep()) break
|
||||
|
||||
@@ -638,14 +663,39 @@ async function runTurn(
|
||||
// iteration drains it before its request — the typed twin of the /goal
|
||||
// step/end-steer pattern.
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
// Steering from step/end session-event or continuation listeners (the
|
||||
// /goal pattern) demands the model see it — it overrides a stop decision;
|
||||
// the next iteration's drain records it.
|
||||
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
|
||||
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
|
||||
|
||||
// Terminal policy runs only AFTER the extensible continuation waterfall,
|
||||
// its optional reason, and late steering have all been folded. Unlike the
|
||||
// waterfall, this serial seam is monotonic: the first stop bail wins, and
|
||||
// no later listener or steering override can resurrect the turn.
|
||||
let terminalStop = false
|
||||
try {
|
||||
const stop = await events.strictSerial('agent/turn-stop', turn)
|
||||
assertContinuationStop(stop)
|
||||
terminalStop = stop !== undefined
|
||||
} catch (error: unknown) {
|
||||
// A broken terminal policy is an ordinary continuation failure: fail
|
||||
// this turn closed while leaving the driver alive for later turns.
|
||||
failTurn(toError(error))
|
||||
break
|
||||
}
|
||||
if (terminalStop) {
|
||||
terminalStopped = true
|
||||
// A continuation reason or listener may have queued steering before the
|
||||
// terminal checkpoint. Discard only steering (never ordinary queued
|
||||
// prompts) so it cannot become a next step or be re-enqueued as a fresh
|
||||
// turn by runLoop's late-steering fallback.
|
||||
handle.inbox.drainSteering()
|
||||
shouldContinue = false
|
||||
}
|
||||
|
||||
// A cancel that landed during the continuation window — after the step's
|
||||
// AbortController was cleared (setAbort(undefined)) but before the next
|
||||
@@ -720,11 +770,12 @@ async function runTurn(
|
||||
// contained: a throwing agent/error listener must not escape the loop.
|
||||
}
|
||||
}
|
||||
return terminalStopped
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
|
||||
const messages = inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
@@ -226,16 +227,19 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('disposer is idempotent (double-stop)', async () => {
|
||||
// Create a bare ReactLoopAgent and call start() directly to get the disposer.
|
||||
// Then call it twice — the second call hits the early-return branch.
|
||||
// Create a bare ReactLoopAgent and start it through the package-internal
|
||||
// test seam. Then call its disposer twice — the second call hits the
|
||||
// early-return branch.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const { agent } = prepared
|
||||
|
||||
// Start the loop to get the disposer; the agent waits for messages
|
||||
// (idle, never-resolving cancel), so it will stay idle.
|
||||
const dispose = agent.start()
|
||||
prepared.enableDrive()
|
||||
const dispose = prepared.startDriver()
|
||||
|
||||
// First dispose
|
||||
dispose()
|
||||
@@ -324,7 +328,7 @@ describe('ReactLoopAgent', () => {
|
||||
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
|
||||
// while running (not the fast path), then the disposer settles it and chains
|
||||
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
|
||||
// start() disposer keeps the emit synchronous.
|
||||
// internal driver disposer keeps the emit synchronous.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -334,8 +338,10 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const session = ctx.sessions.create(SessionId('bare'))
|
||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const dispose = agent.start()
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const { agent } = prepared
|
||||
prepared.enableDrive()
|
||||
const dispose = prepared.startDriver()
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('Agent.cancel()', () => {
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-prefix'),
|
||||
sessionId: SessionId('dispose-prefix-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
@@ -333,7 +333,7 @@ describe('Agent.cancel()', () => {
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-step-start'),
|
||||
sessionId: SessionId('dispose-step-start-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Inbox } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { Inbox } from '../src/inbox.ts'
|
||||
|
||||
function resolverPair() {
|
||||
let r!: () => void
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('agent loop', () => {
|
||||
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-cwd'),
|
||||
sessionId: SessionId('s-cwd'),
|
||||
meta: { cwd: '/work/space' },
|
||||
@@ -243,6 +243,44 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['BigInt', { n: 1n }],
|
||||
['Map', new Map([['key', 'value']])],
|
||||
['class instance', new (class ResultMeta { x = 1 })()],
|
||||
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
|
||||
textResponse('recovered'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bad-meta',
|
||||
description: 'returns invalid durable metadata',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = agent.session.events.find(event => event.type === 'tool/result')
|
||||
expect(result?.type).toBe('tool/result')
|
||||
if (result?.type === 'tool/result') {
|
||||
expect(result.data.callId).toBe('bad-meta-call')
|
||||
expect(result.data.isError).toBe(true)
|
||||
expect(result.data.meta).toBeUndefined()
|
||||
expect(result.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult',
|
||||
}])
|
||||
}
|
||||
// The normalized failure was durably logged and fed back to the model; the
|
||||
// turn continued normally instead of failing after an apparent success.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
|
||||
// The documented escape valve: a deployment that must drop the harness
|
||||
// openers short-circuits the assemble waterfall; the request then carries
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('request stability across the loop', () => {
|
||||
// one's full log (the resume/fork path).
|
||||
const adapter2 = new MockAdapter([textResponse('two')])
|
||||
const ctx2 = await harness(adapter2)
|
||||
const handle = ctx2.agents.create({
|
||||
const handle = await ctx2.agents.create({
|
||||
agentId: AgentId('gen2'),
|
||||
sessionId: SessionId('gen2-session'),
|
||||
seed: [...agent.session.events],
|
||||
|
||||
@@ -19,6 +19,10 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive:
|
||||
async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
|
||||
dirs.push(root)
|
||||
return { ctx: await mountPersistentHarness(root, adapter), root }
|
||||
}
|
||||
|
||||
async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -28,7 +32,22 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, root }
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')]))
|
||||
// Persistence deliberately has no artifact for a truly empty session. A
|
||||
// balanced completed turn is the smallest resumable log and avoids running
|
||||
// the model merely to construct this lifecycle fixture.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const session = ctx.sessions.create(sessionId, { seed })
|
||||
await ctx.sessions.flush(session)
|
||||
await ctx.fiber.dispose()
|
||||
return root
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
@@ -39,11 +58,22 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
|
||||
async function promptly<T>(task: Promise<T>): Promise<T> {
|
||||
const timeout = Promise.withResolvers<never>()
|
||||
const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000)
|
||||
try {
|
||||
return await Promise.race([task, timeout.promise])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
|
||||
const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
|
||||
expect(agent.session.id).toBe('custom-session')
|
||||
expect(agent.session.header.cwd).toBe('/w')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -52,10 +82,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
|
||||
await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
|
||||
// A second create with the SAME agent id but a fresh session id must reject
|
||||
// up front — and must NOT leave an orphaned 'sess-b' session behind.
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/)
|
||||
await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
|
||||
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -63,7 +93,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent works without meta (no cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
|
||||
const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
|
||||
expect(agent.session.id).toBe('nometa-session')
|
||||
expect(agent.session.header.cwd).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -73,7 +103,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: create a no-cwd session and run a turn.
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -100,7 +130,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const sources1: string[] = []
|
||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
|
||||
expect(sources1).toEqual(['startup'])
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
@@ -124,6 +154,224 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => {
|
||||
const sessionId = SessionId('resume-setup-success')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
|
||||
ctx.on('session/created', (session) => {
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session)
|
||||
order.push('session/created')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/)
|
||||
order.push('agent/created')
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
expect(() => { agent.cancel('now live') }).not.toThrow()
|
||||
order.push('agent/session-start')
|
||||
})
|
||||
|
||||
const resuming = ctx.agents.resume({
|
||||
agentId: AgentId('resumed-atomic'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
},
|
||||
})
|
||||
|
||||
await setupStarted.promise
|
||||
expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
expect(order).toEqual(['setup:start'])
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await resuming
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
'setup-listener:agent/created',
|
||||
'agent/session-start',
|
||||
])
|
||||
await handle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
|
||||
const sessionId = SessionId('resume-setup-reject')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
agentId: AgentId('resume-reject'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
await Promise.resolve()
|
||||
throw new Error('resume setup failed')
|
||||
},
|
||||
})).rejects.toThrow('resume setup failed')
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const retry = await ctx.agents.resume({
|
||||
agentId: AgentId('resume-reject'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
|
||||
const sessionId = SessionId('resume-setup-owner-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({
|
||||
agentId: AgentId('resume-owner-race'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await setupStarted.promise
|
||||
|
||||
await owner.dispose()
|
||||
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
gate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => {
|
||||
const sessionId = SessionId('resume-load-owner-unload')
|
||||
const agentId = AgentId('resume-load-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
let loads = 0
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loads += 1
|
||||
if (loads === 1) {
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
}
|
||||
return Promise.resolve(structuredClone(snapshot))
|
||||
}
|
||||
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
await loadStarted.promise
|
||||
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
|
||||
await promptly(owner.dispose())
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
// owner.dispose() itself awaited transaction settlement and reservation
|
||||
// release: reuse the same identities BEFORE awaiting the resume rejection.
|
||||
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
|
||||
await rejection
|
||||
expect(loads).toBe(2)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
// Settlement of the abandoned backend promise cannot resume the old
|
||||
// transaction or emit a second publication after the retry owns the ids.
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(ctx.agents.get(agentId)).toBe(retry.agent)
|
||||
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('snapshots resume identities and agent options before persistence load', async () => {
|
||||
const sessionId = SessionId('resume-snapshot-source')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const loaded = await ctx.sessionPersistence.load(sessionId)
|
||||
const loadGate = Promise.withResolvers<typeof loaded>()
|
||||
ctx.sessionPersistence.load = () => loadGate.promise
|
||||
|
||||
const occupied = await ctx.agents.create({
|
||||
agentId: AgentId('occupied-agent'),
|
||||
sessionId: SessionId('occupied-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const options = {
|
||||
agentId: AgentId('accepted-agent'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
}
|
||||
const resuming = ctx.agents.resume(options)
|
||||
|
||||
options.agentId = AgentId('occupied-agent')
|
||||
options.resumeSessionId = SessionId('occupied-session')
|
||||
options.agentOptions.model = 'mutated-model'
|
||||
loadGate.resolve(structuredClone(loaded))
|
||||
|
||||
const resumed = await resuming
|
||||
expect(resumed.agent.id).toBe(AgentId('accepted-agent'))
|
||||
expect(resumed.agent.session.id).toBe(sessionId)
|
||||
expect(resumed.agent.options.model).toBe('mock')
|
||||
expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent)
|
||||
expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session)
|
||||
|
||||
await resumed.dispose()
|
||||
await occupied.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
@@ -170,7 +418,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// disk, since a crash before the next turn would otherwise lose it.
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -195,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// drop it on reload (the bug this guards).
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -223,7 +471,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: run one full turn, persisting it.
|
||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
const events1 = [...a1.session.events]
|
||||
|
||||
@@ -6,6 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -458,8 +459,10 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
|
||||
ctx2.llm.registerAdapter(['mock'], second)
|
||||
|
||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
||||
const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
ctx2.effect(() => forked.start())
|
||||
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
const forked = prepared.agent
|
||||
prepared.enableDrive()
|
||||
ctx2.effect(() => prepared.startDriver())
|
||||
|
||||
const turns: number[] = []
|
||||
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
||||
|
||||
@@ -8,6 +8,7 @@ import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepse
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as concreteAgentModule from '../src/agent.ts'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -49,7 +50,7 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
it('scoped registrations live in the agent world and die with the agent', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
const { agent } = handle
|
||||
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
||||
agent.ctx.tools.register({
|
||||
@@ -104,12 +105,13 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('child'),
|
||||
sessionId: SessionId('child-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
setup: async (agentCtx) => {
|
||||
order.push('setup')
|
||||
await Promise.resolve()
|
||||
agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
|
||||
},
|
||||
})
|
||||
@@ -118,42 +120,233 @@ describe('agent scope lifecycle', () => {
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('a throwing setup unwinds the half-created agent completely', async () => {
|
||||
it('keeps both identities unpublished until async setup completes, then announces in order', async () => {
|
||||
const ctx = await harness()
|
||||
expect(() => ctx.agents.create({
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/created', (session) => {
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session)
|
||||
order.push('session/created')
|
||||
})
|
||||
ctx.on('agent/created', () => void order.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void order.push('agent/session-start'))
|
||||
const acceptedOptions = { model: 'mock' }
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('atomic'),
|
||||
sessionId: SessionId('atomic-s'),
|
||||
agentOptions: acceptedOptions,
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(AgentId('atomic'))
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
},
|
||||
})
|
||||
await setupStarted.promise
|
||||
expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
|
||||
expect(order).toEqual(['setup:start'])
|
||||
acceptedOptions.model = 'mutated while setup was pending'
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await creating
|
||||
expect(handle.agent.options.model).toBe('mock')
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
'setup-listener:agent/created',
|
||||
'agent/session-start',
|
||||
])
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('reserves agent and session ids across concurrent async setup', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const first = ctx.agents.create({
|
||||
agentId: AgentId('reserved'),
|
||||
sessionId: SessionId('reserved-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => gate.promise,
|
||||
})
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('reserved'),
|
||||
sessionId: SessionId('other-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow(/already registered/)
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('other'),
|
||||
sessionId: SessionId('reserved-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow(/already exists/)
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(ctx.sessions.list()).toEqual([])
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await first
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('structurally rejects every driving verb during setup', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('no-drive'),
|
||||
sessionId: SessionId('no-drive-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
const agent = agentCtx.agent!
|
||||
// Even JavaScript or a cast to the exported concrete class cannot name
|
||||
// a public start method. Driver startup is behind a module-private
|
||||
// symbol used only by AgentLoop after rollback-covered publication.
|
||||
expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined()
|
||||
expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined()
|
||||
expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined()
|
||||
expect(() => concreteAgentModule.prepareReactLoopAgent(
|
||||
agentCtx, agent.id, agent.options, agent.session,
|
||||
)).toThrow(/already has a concrete agent driver/)
|
||||
expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined()
|
||||
expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/)
|
||||
expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/)
|
||||
expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/)
|
||||
expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/)
|
||||
expect(agent.session.events).toEqual([])
|
||||
},
|
||||
})
|
||||
expect(handle.agent.session.events).toEqual([])
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts a pending setup and publishes nothing', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('owner-race'),
|
||||
sessionId: SessionId('owner-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await setupStarted.promise
|
||||
|
||||
await owner.dispose()
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
|
||||
// Let the losing callback settle; Promise.race already observes it.
|
||||
gate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
|
||||
// The other ordering in the same race: setup resolves first (its reaction
|
||||
// is queued), then owner disposal flips active before that continuation can
|
||||
// publish. The post-race active check must still reject.
|
||||
const gate2 = Promise.withResolvers<undefined>()
|
||||
const setupStarted2 = Promise.withResolvers<undefined>()
|
||||
let creating2!: ReturnType<typeof ctx.agents.create>
|
||||
const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating2 = inner.agents.create({
|
||||
agentId: AgentId('owner-race-2'),
|
||||
sessionId: SessionId('owner-race-s-2'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted2.resolve(undefined)
|
||||
await gate2.promise
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await setupStarted2.promise
|
||||
gate2.resolve(undefined)
|
||||
const unload2 = owner2.dispose()
|
||||
await expect(creating2).rejects.toThrow(/owner disposed during setup/)
|
||||
await unload2
|
||||
expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
|
||||
const ctx = await harness()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('bad'),
|
||||
sessionId: SessionId('bad-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => { throw new Error('boom setup') },
|
||||
})).toThrow('boom setup')
|
||||
setup: async () => {
|
||||
await Promise.resolve()
|
||||
throw new Error('boom setup')
|
||||
},
|
||||
})).rejects.toThrow('boom setup')
|
||||
|
||||
// Nothing leaked: no agent, no session, and the ids are reusable.
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||
const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
|
||||
const ctx = await harness()
|
||||
let boom = true
|
||||
const disposed: string[] = []
|
||||
ctx.on('agent/disposed', agent => void disposed.push(agent.id))
|
||||
ctx.on('session/created', () => {
|
||||
if (boom) { boom = false; throw new Error('boom created') }
|
||||
})
|
||||
expect(() => ctx.agents.create({
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' },
|
||||
})).toThrow('boom created')
|
||||
})).rejects.toThrow('boom created')
|
||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||
expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
|
||||
// The rollback also disposed the scope fiber: re-creating works cleanly.
|
||||
const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('the synchronous config helper rolls back when publication throws', async () => {
|
||||
const ctx = await harness()
|
||||
const sessionsBefore = ctx.sessions.list().length
|
||||
let boom = true
|
||||
ctx.on('session/created', () => {
|
||||
if (boom) {
|
||||
boom = false
|
||||
throw new Error('config publish failed')
|
||||
}
|
||||
})
|
||||
|
||||
expect(() => ctx.agentLoop.create(AgentId('config-bad'), { model: 'mock' }))
|
||||
.toThrow('config publish failed')
|
||||
expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
})
|
||||
|
||||
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
await handle.dispose()
|
||||
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
@@ -195,9 +388,9 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
|
||||
const ctx = await harness()
|
||||
let handle!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
handle = inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
const { agent } = handle
|
||||
|
||||
@@ -229,9 +422,9 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
|
||||
const ctx = await harness()
|
||||
let handle!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
handle = inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
const teardownDone: string[] = []
|
||||
|
||||
199
packages/core/agent-loop/tests/turn-stop.spec.ts
Normal file
199
packages/core/agent-loop/tests/turn-stop.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
|
||||
agent.send([{ type: 'text', text }])
|
||||
return agent.whenIdle()
|
||||
}
|
||||
|
||||
function registerEcho(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
describe('agent/turn-stop', () => {
|
||||
it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('the ordinary decision is stop'),
|
||||
textResponse('must not be requested'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let steered = false
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
|
||||
const downstream = await next()
|
||||
if (subject === agent && !steered) {
|
||||
steered = true
|
||||
subject.steer([{ type: 'text', text: 'late continuation steering' }])
|
||||
}
|
||||
return downstream
|
||||
}, { prepend: true })
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('discards steering that arrives from session/flush after the terminal checkpoint', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('terminal answer'),
|
||||
textResponse('must not become a late-steering turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let injected = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || injected) return
|
||||
injected = true
|
||||
agent.steer([{ type: 'text', text: 'steering from flush' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(injected).toBe(true)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves an ordinary queued send that arrives during terminal flush', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('first terminal answer'),
|
||||
textResponse('queued follow-up answer'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let queued = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || queued) return
|
||||
queued = true
|
||||
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters a scoped terminal listener to its own agent', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('a1', 'echo', { text: 'a' }),
|
||||
toolCallResponse('b1', 'echo', { text: 'b' }),
|
||||
textResponse('b continues normally'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' })
|
||||
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' })
|
||||
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(stopped)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await send(ordinary)
|
||||
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('unregisters with its scoped owner disposer', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('first', 'echo', { text: 'first' }),
|
||||
toolCallResponse('second', 'echo', { text: 'second' }),
|
||||
textResponse('continued after listener disposal'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' })
|
||||
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(agent, 'first turn')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
disposeStop()
|
||||
await send(agent, 'second turn')
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('fails throwing and malformed terminal policies closed while the driver survives', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('throwing policy'),
|
||||
textResponse('malformed continue policy'),
|
||||
textResponse('malformed false policy'),
|
||||
textResponse('malformed null policy'),
|
||||
textResponse('healthy later turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' })
|
||||
const reasons: TurnEndReason[] = []
|
||||
const errors: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) })
|
||||
|
||||
const disposeThrowing = agent.ctx.on('agent/turn-stop', () => {
|
||||
throw new Error('terminal policy exploded')
|
||||
})
|
||||
await send(agent, 'first')
|
||||
disposeThrowing()
|
||||
|
||||
for (const [index, malformed] of [
|
||||
{ action: 'continue' },
|
||||
false,
|
||||
null,
|
||||
].entries()) {
|
||||
const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop)
|
||||
await send(agent, `malformed ${index}`)
|
||||
disposeMalformed()
|
||||
}
|
||||
|
||||
await send(agent, 'healthy')
|
||||
|
||||
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed'])
|
||||
expect(errors).toContain('terminal policy exploded')
|
||||
expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined")
|
||||
expect(adapter.requests).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,16 @@ export interface AgentEventDispatch {
|
||||
* @returns the serial chain's result (the first bail value, if any).
|
||||
*/
|
||||
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
|
||||
/**
|
||||
* Await listeners in order and return the first value other than `undefined`.
|
||||
* Unlike Cordis `serial`, this does not silently treat `null` or `false` as
|
||||
* abstentions. Use it for a runtime-validated public boundary whose declared
|
||||
* abstention is exactly `undefined` (currently `agent/turn-stop`).
|
||||
* @param name - the agent-subject event to dispatch.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
* @returns the first non-undefined listener result, or undefined.
|
||||
*/
|
||||
strictSerial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
|
||||
/**
|
||||
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
|
||||
* declared event parameters already end with the `next` callback, so `rest`
|
||||
@@ -79,7 +89,7 @@ export interface AgentEventDispatch {
|
||||
*/
|
||||
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
|
||||
// The three dispatch methods forward through cordis' variadic mixins. The
|
||||
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
|
||||
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
|
||||
// list for the matching thisArg overload, but TypeScript cannot relate the
|
||||
// generic Tail<K> spread back to that overload's conditional parameter
|
||||
@@ -95,6 +105,22 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
|
||||
return await serial(carrier, name, agent, ...rest)
|
||||
},
|
||||
strictSerial(name, ...rest) {
|
||||
return (async (): Promise<unknown> => {
|
||||
// EventsService.dispatch applies the carrier filter and emits the same
|
||||
// internal/dispatch instrumentation as ctx.serial, then mutates `args`
|
||||
// down to the actual listener parameters. Invoke those callbacks in order
|
||||
// ourselves so every non-undefined value reaches the caller's validator;
|
||||
// Cordis serial would discard null/false before validation could see them.
|
||||
const args: unknown[] = [carrier, name, agent, ...rest]
|
||||
const callbacks = ctx.events.dispatch('serial', args)
|
||||
for (const callback of callbacks) {
|
||||
const result: unknown = await callback(...args)
|
||||
if (result !== undefined) return result
|
||||
}
|
||||
return undefined
|
||||
})() as Promise<Awaited<Return<Events[typeof name]>>>
|
||||
},
|
||||
waterfall(name, ...rest) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
||||
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
|
||||
|
||||
@@ -18,14 +18,13 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
agents: AgentRegistry
|
||||
/**
|
||||
* The agent whose scope this context belongs to, or `undefined` on any
|
||||
* context not derived from an agent scope. Pure DX sugar over the
|
||||
* `dsh-scope` tag: the agent loop sets it as an own property on each
|
||||
* `Agent.ctx`, and {@link AgentRegistry} registers a root accessor
|
||||
* defaulting to `undefined` so the read is safe on every context (a plain
|
||||
* plugin context answers `undefined` instead of throwing the Cordis
|
||||
* unknown-property error). Core packages below the agent layer read the
|
||||
* `dsh-scope` tag (`scopeOf`) instead, never this field.
|
||||
* The agent association installed as an own property on `Agent.ctx`, or
|
||||
* `undefined` on a plain context. Contexts derived from `Agent.ctx` inherit
|
||||
* the association; a deliberately nested scope may carry a nearer
|
||||
* `dsh-scope` tag while retaining it, so this field is DX context rather
|
||||
* than the scope resolver. {@link AgentRegistry} registers a root accessor
|
||||
* defaulting to `undefined`, and core packages below the agent layer use
|
||||
* `scopeOf()` for layer selection instead of reading this field.
|
||||
*/
|
||||
agent?: Agent
|
||||
}
|
||||
@@ -66,19 +65,20 @@ export interface CreateAgentOptions {
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Creation-time composition of the agent's scoped world. The factory runs it
|
||||
* inside the agent's composite lifecycle effect — after the scope is minted
|
||||
* and the agent registered, before `agent/session-start` fires and the loop
|
||||
* starts — so everything it registers through `agentCtx` (scoped tools,
|
||||
* prompt sections/variables, `restrict()`, listeners, `agentCtx.plugin(…)`
|
||||
* profiles) exists before the first prompt assembly, and a THROWING setup
|
||||
* unwinds inside the rollback boundary instead of leaking a half-created
|
||||
* agent. **Setup registers, it never drives**: calling
|
||||
* `send`/`steer`/`inject` here would open a turn before `agent/session-start`
|
||||
* (the dev invariants flag a `turn/start` logged before session-start as a
|
||||
* teaching error) — drive the agent after creation returns.
|
||||
* Creation-time composition of the agent's scoped world. The factory awaits
|
||||
* setup after minting `agentCtx` but BEFORE inserting or announcing either
|
||||
* the session or agent, so observers can never see a partially configured
|
||||
* world. Everything registered through `agentCtx` (scoped tools, prompt
|
||||
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
|
||||
* before `session/created`, `agent/created`, `agent/session-start`, and the
|
||||
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
|
||||
* back without publishing either id.
|
||||
*
|
||||
* **Setup composes, it never drives**: calling `send`/`steer`/`inject` here
|
||||
* would run an unpublished agent and violate the session-start boundary.
|
||||
* Drive the agent only after the creation promise resolves.
|
||||
*/
|
||||
setup?: (agentCtx: Context) => void
|
||||
setup?: (agentCtx: Context) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,15 +92,26 @@ export interface ResumeAgentOptions {
|
||||
resumeSessionId: SessionId
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Resume-time composition of the agent's fresh scoped world. Persistence is
|
||||
* loaded first; the factory then mints `agentCtx` and awaits setup while the
|
||||
* reconstructed session and agent remain unpublished. The callback has the
|
||||
* same composition-only contract as {@link CreateAgentOptions.setup}: all
|
||||
* registrations exist before either creation announcement, driving verbs are
|
||||
* unavailable until the session-start boundary, and rejection or owner
|
||||
* disposal rolls the transaction back without publishing either id.
|
||||
*/
|
||||
setup?: (agentCtx: Context) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
|
||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
|
||||
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
|
||||
* awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and
|
||||
* removes the agent's session from the store, in an order that captures the
|
||||
* loop's final `session/flush` before the session is detached.
|
||||
* can tear this agent down. `dispose()` stops the loop, awaits its exit
|
||||
* (quiescence — NOT just the `disposed` status flip), unregisters the agent,
|
||||
* removes its session from the store, and finally unwinds its scoped world.
|
||||
* This order captures the loop's final `session/flush` before the session is
|
||||
* detached and keeps scoped listeners alive through that flush.
|
||||
*
|
||||
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
|
||||
* for the OWNER that created it. Config-created agents (the loop's own startup)
|
||||
@@ -119,15 +130,27 @@ export interface AgentHandle {
|
||||
*/
|
||||
export interface AgentFactory {
|
||||
/**
|
||||
* Create, start, and register a new agent on a caller-supplied session id.
|
||||
* Returns an {@link AgentHandle} — the owner disposes it to tear down exactly
|
||||
* this agent (unregister + stop loop + await quiescence + remove session).
|
||||
* Create a new agent on a caller-supplied session id. Async because creation
|
||||
* awaits unpublished setup, inserts both session and agent, emits their
|
||||
* creation notifications in order, unlocks driving at
|
||||
* `agent/session-start`, and only then starts the loop. The sequence is
|
||||
* rollback-covered, but notifications delivered before a later listener
|
||||
* failure remain observable; if agent announcement began, rollback emits
|
||||
* `agent/disposed`, while the session entry is removed without a separate
|
||||
* disposal event. The owner disposes the resolved handle to stop/drain,
|
||||
* unregister, remove the session, and unwind the scope.
|
||||
* @param options - agent/session identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle
|
||||
createAgent(options: CreateAgentOptions): Promise<AgentHandle>
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it. Async because it awaits
|
||||
* `ctx.sessionPersistence.load`; must be called after that service exists
|
||||
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
|
||||
* both `ctx.sessionPersistence.load` and the optional unpublished setup
|
||||
* transaction; must be called after that service exists (consumers inject
|
||||
* `sessionPersistence`). Publication and drive unlocking follow the same
|
||||
* ordered boundary as {@link createAgent}.
|
||||
* @param options - persisted identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
*/
|
||||
resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
}
|
||||
@@ -139,11 +162,13 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
|
||||
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
|
||||
* orchestrator plugins can find them without depending on the concrete loop
|
||||
* package. Agent *creation* is provided by whichever plugin implements the
|
||||
* {@link AgentFactory} (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via
|
||||
* {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via
|
||||
* {@link setFactory}.
|
||||
*/
|
||||
export class AgentRegistry extends Service {
|
||||
private store = new Map<AgentId, Agent>()
|
||||
/** Entries whose `agent/created` announcement phase began. */
|
||||
private announced = new WeakSet<Agent>()
|
||||
private factory: AgentFactory | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
@@ -180,15 +205,15 @@ export class AgentRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create, start, and register a new agent through the registered factory.
|
||||
* Create and publish a new agent through the registered factory.
|
||||
* Distinct from {@link register} (which records an already-constructed
|
||||
* agent): this constructs the agent and its session. Throws if no factory is
|
||||
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
|
||||
* down exactly this agent.
|
||||
* agent): this constructs the agent and its session. Rejects if no factory is
|
||||
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
|
||||
* the owner tear down exactly this agent.
|
||||
* @param options - agent id, session id/seed/metadata, and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||
*/
|
||||
create(options: CreateAgentOptions): AgentHandle {
|
||||
async create(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory.createAgent(options)
|
||||
}
|
||||
@@ -196,9 +221,9 @@ export class AgentRegistry extends Service {
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it through the registered
|
||||
* factory. Rejects if no factory is registered; the factory rejects if
|
||||
* session persistence is not configured. Returns an {@link AgentHandle}.
|
||||
* @param options - the persisted session id plus agent id and options.
|
||||
* @returns the handle for the resumed agent.
|
||||
* session persistence is not configured or persistence/setup fails.
|
||||
* @param options - persisted identity, configuration, and optional setup.
|
||||
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
@@ -225,39 +250,58 @@ export class AgentRegistry extends Service {
|
||||
*/
|
||||
register(agent: Agent): () => Promise<void> | void {
|
||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||
if (this.store.has(agent.id)) {
|
||||
throw new Error(`agent "${agent.id}" is already registered`)
|
||||
}
|
||||
this.store.set(agent.id, agent)
|
||||
// Yield the rollback BEFORE emitting `agent/created`: a generator effect
|
||||
// collects each yielded disposer before the next step runs, so a
|
||||
// throwing `agent/created` listener rolls the entry back instead of
|
||||
// leaking it (a leak would wedge the duplicate-id check until restart).
|
||||
// The duplicate throw above fires before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
this.store.delete(agent.id)
|
||||
// CONTAIN a throwing `agent/disposed` listener: this disposer runs as
|
||||
// one link in the owning fiber/effect's disposal chain, and Cordis
|
||||
// chains later disposers with `task.then(next)` — so an UNCAUGHT throw
|
||||
// here rejects the chain and SKIPS every later disposer. When this
|
||||
// registration shares a composite effect with a session (the agent
|
||||
// factory's `AgentLoop.start`, where the session-detach disposer runs
|
||||
// AFTER this one), a swallowed-less throw would strand the session in
|
||||
// the store with `onAppend` attached — a leak AND a durability hole.
|
||||
// The store entry is already removed above (the useful state), so
|
||||
// logging the listener bug and continuing is correct (mirrors the
|
||||
// guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent).
|
||||
try {
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent)
|
||||
yield this.enter(agent)
|
||||
this.announce(agent)
|
||||
}.bind(this), 'agents.register()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an already-constructed agent without announcing it. This is the
|
||||
* advanced ordered-lifecycle primitive used by the async agent factory: it
|
||||
* first completes setup while the agent is unpublished, then assigns the
|
||||
* returned detach closure into its pre-installed composite teardown before
|
||||
* calling {@link announce}. Ordinary callers use {@link register}.
|
||||
* @param agent - the prepared, unpublished agent.
|
||||
* @returns an idempotent closure that removes this exact entry and emits
|
||||
* `agent/disposed` with listener failures contained.
|
||||
*/
|
||||
enter(agent: Agent): () => void {
|
||||
if (this.store.has(agent.id)) {
|
||||
throw new Error(`agent "${agent.id}" is already registered`)
|
||||
}
|
||||
this.store.set(agent.id, agent)
|
||||
let entered = true
|
||||
return () => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
this.store.delete(agent.id)
|
||||
// An insertion rolled back before announce was never externally created,
|
||||
// so emitting disposed would invent an impossible lifecycle edge. Marking
|
||||
// happens before the created emit: if a later created listener throws,
|
||||
// earlier listeners may already have observed it and must see disposal.
|
||||
if (!this.announced.delete(agent)) return
|
||||
try {
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce an agent previously inserted with {@link enter}.
|
||||
* @param agent - the live inserted agent to announce.
|
||||
* @throws if `agent` is not the exact live registry entry for its id.
|
||||
*/
|
||||
announce(agent: Agent): void {
|
||||
if (this.store.get(agent.id) !== agent) {
|
||||
throw new Error(`agent "${agent.id}" is not live in this registry`)
|
||||
}
|
||||
this.announced.add(agent)
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a live agent.
|
||||
* @param id - the agent id to look up.
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
||||
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
||||
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
|
||||
* `agent/turn-continuation` waterfalls and
|
||||
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
|
||||
* `agent/turn-continuation` waterfalls and the serial `agent/pre-step` /
|
||||
* `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits
|
||||
* (`agent/status`, `agent/error`, `agent/created`/
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
|
||||
@@ -37,8 +37,9 @@
|
||||
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
|
||||
*
|
||||
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
|
||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision —
|
||||
* the convention pinned by
|
||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision;
|
||||
* the terminal serial `agent/turn-stop` returns the stop-only subset. The
|
||||
* convention is pinned by
|
||||
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/types
|
||||
@@ -161,6 +162,13 @@ export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
|
||||
/**
|
||||
* The terminal subset of {@link ContinuationDecision}. A listener on
|
||||
* `agent/turn-stop` returns this to make the already-composed continuation
|
||||
* outcome terminal; `undefined` abstains.
|
||||
*/
|
||||
export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
|
||||
/**
|
||||
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
|
||||
* bridge keys its SessionStart hook's matcher on this (Claude Code's
|
||||
@@ -274,9 +282,12 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
// ---- lifecycle (emit) ----
|
||||
/**
|
||||
* An agent was registered in the {@link AgentRegistry} and is ready to
|
||||
* receive messages.
|
||||
* @param agent - the newly registered agent, already resolvable in the registry.
|
||||
* An agent's fully composed scoped world was published in the
|
||||
* {@link AgentRegistry}. Its session is already live in the session store,
|
||||
* but concrete factories may keep driving verbs locked until the subsequent
|
||||
* `agent/session-start` boundary; that event is the first supported place
|
||||
* to inject or queue work during startup.
|
||||
* @param agent - the newly registered agent with its live session and completed setup.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
@@ -286,9 +297,11 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* An agent was disposed and removed from the registry; its fiber and any
|
||||
* in-flight turn have been torn down.
|
||||
* @param agent - the agent that was torn down; its handle is now inert.
|
||||
* An agent was removed from the registry after its driver and any in-flight
|
||||
* turn reached quiescence. Ordered teardown may still be detaching the
|
||||
* session and unwinding the agent's scoped registrations when this
|
||||
* notification runs.
|
||||
* @param agent - the deregistered agent; its driving handle is now inert.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
@@ -534,6 +547,25 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
/**
|
||||
* Serial terminal-stop checkpoint after the ordinary
|
||||
* `agent/turn-continuation` waterfall, any `continue.reason`, and the
|
||||
* pending-steering continuation override have been folded. A listener
|
||||
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
|
||||
* to abstain. Terminal stop is monotonic: listener order and steering
|
||||
* cannot resume the turn, and pending steering is discarded rather than
|
||||
* becoming another step or turn. A malformed non-undefined result fails
|
||||
* the turn closed.
|
||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||
* @param turn - the turn at its terminal-stop checkpoint.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,36 @@ describe('AgentRegistry', () => {
|
||||
await dispose()
|
||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('splits insertion from announcement and makes the detach exact/idempotent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const created: Agent[] = []
|
||||
const disposed: Agent[] = []
|
||||
ctx.on('agent/created', agent => void created.push(agent))
|
||||
ctx.on('agent/disposed', agent => void disposed.push(agent))
|
||||
|
||||
const first = stubAgent('split')
|
||||
const detachFirst = ctx.agents.enter(first)
|
||||
expect(ctx.agents.get(first.id)).toBe(first)
|
||||
expect(created).toEqual([])
|
||||
ctx.agents.announce(first)
|
||||
expect(created).toEqual([first])
|
||||
detachFirst()
|
||||
detachFirst()
|
||||
expect(disposed).toEqual([first])
|
||||
|
||||
const replacement = stubAgent('split')
|
||||
const detachReplacement = ctx.agents.enter(replacement)
|
||||
// A stale repeated detach cannot remove the replacement.
|
||||
detachFirst()
|
||||
expect(ctx.agents.get(replacement.id)).toBe(replacement)
|
||||
expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
|
||||
detachReplacement()
|
||||
// The replacement was inserted but never announced, so rollback produces
|
||||
// no disposed-without-created notification.
|
||||
expect(disposed).toEqual([first])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry factory seam', () => {
|
||||
@@ -84,7 +114,7 @@ describe('AgentRegistry factory seam', () => {
|
||||
function stubFactory() {
|
||||
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
|
||||
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
|
||||
createAgent(options) {
|
||||
async createAgent(options) {
|
||||
calls.create.push(options)
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
},
|
||||
@@ -99,7 +129,7 @@ describe('AgentRegistry factory seam', () => {
|
||||
it('create()/resume() throw when no factory is registered', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/)
|
||||
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
|
||||
await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
|
||||
})
|
||||
|
||||
@@ -109,7 +139,7 @@ describe('AgentRegistry factory seam', () => {
|
||||
const { factory, calls } = stubFactory()
|
||||
ctx.agents.setFactory(factory)
|
||||
|
||||
const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
|
||||
const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
|
||||
expect(created.agent.id).toBe('c1')
|
||||
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
|
||||
|
||||
@@ -132,10 +162,10 @@ describe('AgentRegistry factory seam', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
dispose = inner.agents.setFactory(stubFactory().factory)
|
||||
}, { inject: ['agents'] }))
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow()
|
||||
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).resolves.toBeDefined()
|
||||
void dispose
|
||||
await fiber.dispose()
|
||||
// factory slot cleared → create throws again
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/)
|
||||
await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* @module @deepseek-ai/dsh-scope
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
/**
|
||||
@@ -78,21 +78,30 @@ export interface Scope {
|
||||
rawDispose: () => Promise<void> | void
|
||||
/**
|
||||
* Unwind the scope: dispose the backing fiber, running every collected
|
||||
* registration disposer. Idempotent and always awaitable — a repeat call
|
||||
* resolves immediately (the underlying Cordis disposer is single-shot and
|
||||
* returns undefined the second time; this wrapper Promise-normalizes it).
|
||||
* registration disposer. Idempotent and always awaitable: repeat and racing
|
||||
* calls share one completion even though the underlying Cordis disposer is
|
||||
* single-shot and returns undefined after its first invocation.
|
||||
* After disposal the scoped context is inert — a further registration
|
||||
* through it throws Cordis's INACTIVE_EFFECT.
|
||||
* @returns for the call that initiates teardown: resolves when every
|
||||
* registration's disposer has settled. A repeat/racing call resolves
|
||||
* immediately WITHOUT awaiting the in-flight teardown (the underlying
|
||||
* Cordis disposer is single-shot) — a caller needing a shared quiescence
|
||||
* boundary across racing disposers keeps its own completion promise (the
|
||||
* agent factory's pattern).
|
||||
* registration's disposer has settled. Every repeat/racing call awaits
|
||||
* that same quiescence boundary, including when {@link rawDispose} claimed
|
||||
* the underlying single-shot Cordis disposer first.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose a Cordis fiber and await its lifecycle inertia even when some other
|
||||
* caller claimed the single-shot raw disposer first. `Fiber.dispose()` returns
|
||||
* `undefined` on a repeat call, but the fiber's `inertia` remains the
|
||||
* authoritative promise while its async unload is running.
|
||||
*/
|
||||
async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
await Promise.resolve(fiber.dispose())
|
||||
while (fiber.inertia !== undefined) await fiber.inertia
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared no-op plugin every scope fiber mounts: named so diagnostics read
|
||||
* `scope` and shared so all scopes join ONE plugin runtime (Cordis deletes the
|
||||
@@ -127,21 +136,22 @@ export function createScope(ctx: Context, key: ScopeKey): Scope {
|
||||
// Runtime guard behind the ScopeKey type: callers outside the typechecker
|
||||
// (yml-configured plugins, JS consumers) can still pass a primitive.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (typeof key !== 'object' || key === null) {
|
||||
throw new TypeError('createScope: key must be an object (scope keys are identity-compared)')
|
||||
if ((typeof key !== 'object' && typeof key !== 'function') || key === null) {
|
||||
throw new TypeError('createScope: key must be a non-null object or function (scope keys are identity-compared)')
|
||||
}
|
||||
const fiber = ctx.plugin(scope)
|
||||
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
|
||||
let disposing: Promise<void> | undefined
|
||||
return {
|
||||
ctx: scoped,
|
||||
// fiber.dispose IS the disposer Cordis pushed onto the minting fiber's
|
||||
// disposable list — the identity a composite effect must yield (see
|
||||
// Scope.rawDispose).
|
||||
rawDispose: fiber.dispose,
|
||||
// Promise.resolve-normalized: a cordis fiber's dispose returns undefined
|
||||
// on a repeat call (the epoch is already cleared), and Scope.dispose
|
||||
// promises an awaitable on every call.
|
||||
dispose: () => Promise.resolve(fiber.dispose()),
|
||||
// Memoize the public boundary and explicitly follow fiber inertia: the raw
|
||||
// disposer must remain the exact Cordis function for ordered composition,
|
||||
// so it cannot itself be wrapped to record a raw-first invocation.
|
||||
dispose: () => (disposing ??= quiesceFiber(fiber)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,8 +303,10 @@ export interface ScopeHost {
|
||||
mint(key: ScopeKey): Scope
|
||||
/**
|
||||
* Dispose the host fiber and with it every scope minted through it.
|
||||
* @returns resolves when all collected disposers have settled (first call;
|
||||
* a repeat call resolves immediately — single-shot, like Scope.dispose).
|
||||
* Every racing/repeat caller observes the same completion, including when a
|
||||
* child's raw disposer started before host disposal.
|
||||
* @returns resolves when the host and every minted scope have reached
|
||||
* quiescence.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
@@ -334,8 +346,33 @@ export async function scopeHost(ctx: Context, services: string[]): Promise<Scope
|
||||
throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`)
|
||||
}
|
||||
const host = hostCtx
|
||||
const scopes = new Set<Scope>()
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = async (): Promise<void> => {
|
||||
// Start every boundary before awaiting any one of them. A child whose raw
|
||||
// disposer already ran is still followed through Scope.dispose(); a child
|
||||
// the host unload claims first is followed through the same fiber inertia.
|
||||
const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())]
|
||||
const results = await Promise.allSettled(tasks)
|
||||
scopes.clear()
|
||||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
|
||||
if (errors.length === 1) throw errors[0]
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'scopeHost: disposal failed')
|
||||
}
|
||||
return {
|
||||
mint: (key: ScopeKey) => createScope(host, key),
|
||||
dispose: () => Promise.resolve(fiber.dispose()),
|
||||
mint: (key: ScopeKey) => {
|
||||
const minted = createScope(host, key)
|
||||
let disposing: Promise<void> | undefined
|
||||
const tracked: Scope = {
|
||||
ctx: minted.ctx,
|
||||
// Preserve the exact Cordis identity: only the public shared boundary
|
||||
// is wrapped to retire this child from the host's tracking set.
|
||||
rawDispose: minted.rawDispose,
|
||||
dispose: () => (disposing ??= minted.dispose().finally(() => { scopes.delete(tracked) })),
|
||||
}
|
||||
scopes.add(tracked)
|
||||
return tracked
|
||||
},
|
||||
dispose: () => (disposing ??= dispose()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,19 @@ async function mintScope(ctx: Context, key: object): Promise<Scope> {
|
||||
}
|
||||
|
||||
describe('createScope', () => {
|
||||
it('rejects a primitive key at runtime (identity-compared keys must be objects)', () => {
|
||||
it('rejects primitive keys but accepts callable objects (matching ScopeKey)', async () => {
|
||||
const ctx = new Context()
|
||||
// Typed through `unknown` so the ScopeKey type cannot argue the assertion
|
||||
// away: this test exercises exactly the callers the typechecker misses.
|
||||
const badKeys: unknown[] = ['k', null]
|
||||
for (const bad of badKeys) {
|
||||
expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be an object/)
|
||||
expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be a non-null object or function/)
|
||||
}
|
||||
|
||||
const callable = Object.assign(() => {}, { nameForTest: 'callable-key' })
|
||||
const scope = await mintScope(ctx, callable)
|
||||
expect(scopeOf(scope.ctx)).toBe(callable)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('tags the scoped context, readable through derivations (nearest tag wins)', async () => {
|
||||
@@ -91,6 +96,29 @@ describe('createScope', () => {
|
||||
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
|
||||
it('dispose() follows a rawDispose-first race through async quiescence', async () => {
|
||||
const ctx = new Context()
|
||||
const scope = await mintScope(ctx, { name: 'raw-first' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
scope.ctx.effect(() => async () => {
|
||||
await gate.promise
|
||||
cleanupFinished = true
|
||||
})
|
||||
|
||||
const raw = Promise.resolve(scope.rawDispose())
|
||||
let publicSettled = false
|
||||
const publicDispose = scope.dispose().then(() => { publicSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(publicSettled).toBe(false)
|
||||
expect(cleanupFinished).toBe(false)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([raw, publicDispose])
|
||||
expect(cleanupFinished).toBe(true)
|
||||
await expect(scope.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => {
|
||||
const ctx = new Context()
|
||||
const order: string[] = []
|
||||
@@ -287,6 +315,52 @@ describe('scopeHost', () => {
|
||||
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
|
||||
it('dispose waits for a child whose raw disposer won the race', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('answers', { value: 42 })
|
||||
const host = await scopeHost(ctx, ['answers'])
|
||||
const scope = host.mint({ name: 'raw-first-child' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
scope.ctx.effect(() => async () => {
|
||||
await gate.promise
|
||||
cleanupFinished = true
|
||||
})
|
||||
|
||||
const raw = Promise.resolve(scope.rawDispose())
|
||||
let hostSettled = false
|
||||
const hostDispose = host.dispose().then(() => { hostSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(hostSettled).toBe(false)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([raw, hostDispose])
|
||||
expect(cleanupFinished).toBe(true)
|
||||
await expect(host.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('reaches every child before surfacing one or multiple disposal failures', async () => {
|
||||
const oneCtx = new Context()
|
||||
oneCtx.provide('answers', { value: 42 })
|
||||
const oneHost = await scopeHost(oneCtx, ['answers'])
|
||||
const one = oneHost.mint({ name: 'one' })
|
||||
one.dispose = () => Promise.reject(new Error('one failed'))
|
||||
await expect(oneHost.dispose()).rejects.toThrow('one failed')
|
||||
|
||||
const manyCtx = new Context()
|
||||
manyCtx.provide('answers', { value: 42 })
|
||||
const manyHost = await scopeHost(manyCtx, ['answers'])
|
||||
const a = manyHost.mint({ name: 'a' })
|
||||
const b = manyHost.mint({ name: 'b' })
|
||||
a.dispose = () => Promise.reject(new Error('a failed'))
|
||||
b.dispose = () => Promise.reject(new Error('b failed'))
|
||||
await expect(manyHost.dispose()).rejects.toMatchObject({
|
||||
name: 'AggregateError',
|
||||
message: 'scopeHost: disposal failed',
|
||||
errors: [expect.objectContaining({ message: 'a failed' }), expect.objectContaining({ message: 'b failed' })],
|
||||
})
|
||||
})
|
||||
|
||||
it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
|
||||
|
||||
@@ -538,8 +538,12 @@ export class SessionStore extends Service {
|
||||
const emitCtx = this.ctx
|
||||
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
|
||||
this.store.set(session.id, session)
|
||||
let entered = true
|
||||
return () => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
session.onAppend = undefined
|
||||
this.carriers.delete(session)
|
||||
this.store.delete(session.id)
|
||||
}
|
||||
}
|
||||
@@ -549,7 +553,7 @@ export class SessionStore extends Service {
|
||||
* 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(this.carrierFor(session), 'session/created', session)
|
||||
this.ctx.emit(this.liveCarrierFor(session), 'session/created', session)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,13 +567,23 @@ export class SessionStore extends Service {
|
||||
* @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)
|
||||
await this.ctx.parallel(this.liveCarrierFor(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)
|
||||
/** Return the exact live session's carrier; detached/prepared objects reject. */
|
||||
private liveCarrierFor(session: Session): Scoped<Session> {
|
||||
if (this.store.get(session.id) !== session) {
|
||||
throw new Error(`session "${session.id}" is not live in this store`)
|
||||
}
|
||||
const carrier = this.carriers.get(session)
|
||||
// enter() installs store + carrier in one synchronous sequence; a live
|
||||
// session without one is an internal invariant violation, never fallback
|
||||
// to subject-less dispatch (that would silently cross scope boundaries).
|
||||
/* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
|
||||
if (carrier === undefined) {
|
||||
throw new Error(`session "${session.id}" has no dispatch carrier`)
|
||||
}
|
||||
return carrier
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,16 +91,33 @@ describe('sessions.flush()', () => {
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
||||
})
|
||||
|
||||
it('flushes a never-entered session with a subject-less carrier (defensive path)', async () => {
|
||||
it('rejects a never-entered session instead of inventing a carrier', 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}`])
|
||||
const prepared = ctx.sessions.prepare()
|
||||
await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/)
|
||||
expect(flushed).toEqual([])
|
||||
})
|
||||
|
||||
it('clears a detached carrier and rejects stale flushes', 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 session = scope.ctx.sessions.prepare()
|
||||
const detach = scope.ctx.sessions.enter(session)
|
||||
await ctx.sessions.flush(session)
|
||||
expect(flushed.sort()).toEqual([`global:${session.id}`, `owner:${session.id}`])
|
||||
|
||||
detach()
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow(/not live/)
|
||||
expect(flushed).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keyOf sanity: distinct scopes carry distinct keys', async () => {
|
||||
|
||||
@@ -289,6 +289,7 @@ describe('SessionStore', () => {
|
||||
expect(created).toEqual([session])
|
||||
// The detach disposer removes the entry + stops notification.
|
||||
detach()
|
||||
detach() // idempotent: cannot disturb a later same-id lifecycle
|
||||
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections,
|
||||
* tool schema providers, and named prompt variables; `assemble(context)`
|
||||
* collates them through a waterfall that runs once per step, and
|
||||
* `renderPrompt` interpolates `{{variable}}` references into the final text.
|
||||
* tool schema providers, named prompt variables, and authoritative named
|
||||
* protections; `assemble(context)` collates them through a waterfall that
|
||||
* runs once per step, restores protected contributions, and `renderPrompt`
|
||||
* interpolates `{{variable}}` references into the final text.
|
||||
*
|
||||
* The harness-owned prompt openers live here too: this plugin registers the
|
||||
* static `harness:identity` section (order −100) and the deployment's
|
||||
@@ -43,8 +44,8 @@ declare module 'cordis' {
|
||||
*/
|
||||
'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 — possibly for one scope
|
||||
* A section, tool provider, variable provider, or protection was registered
|
||||
* or 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
|
||||
@@ -121,6 +122,27 @@ export interface ToolProviderResult {
|
||||
knownNames?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical prompt contributions that survive the assembly waterfall.
|
||||
*
|
||||
* Protection is declarative by contribution name rather than an ordered
|
||||
* callback: after every `system-prompt/assemble` listener has finished, the
|
||||
* service restores each protected name to the exact presence and definition
|
||||
* produced by its registries before the waterfall. Restored entries keep
|
||||
* canonical order with one another and anchor before their first surviving
|
||||
* later unprotected canonical neighbor (or at the end); the service does not
|
||||
* undo a listener's reordering of unprotected entries. A name absent from that
|
||||
* canonical assembly is removed from the result. This makes mode-dependent
|
||||
* absence protectable too (for example, a native tool that intentionally stays
|
||||
* off the wire in Code Mode).
|
||||
*/
|
||||
export interface PromptProtection {
|
||||
/** Section names whose canonical registry output is authoritative. */
|
||||
sections?: readonly string[]
|
||||
/** Tool names whose canonical provider output is authoritative. */
|
||||
tools?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The assembled prompt.
|
||||
*
|
||||
@@ -211,6 +233,28 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
|
||||
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
|
||||
}
|
||||
|
||||
/** Restore protected named entries from `canonical`, anchored before their next unprotected canonical neighbor. */
|
||||
function restoreProtected<T extends { name: string }>(
|
||||
canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet<string>,
|
||||
): T[] {
|
||||
const restored = result.filter(entry => !protectedNames.has(entry.name))
|
||||
for (const [index, entry] of canonical.entries()) {
|
||||
if (!protectedNames.has(entry.name)) continue
|
||||
// Protected entries are inserted in canonical order. Anchor each one
|
||||
// before the first later UNPROTECTED canonical neighbor that survived the
|
||||
// waterfall; if none survived, it belongs at the end. Looking only at
|
||||
// unprotected neighbors avoids reversing adjacent protected entries.
|
||||
const following = new Set(
|
||||
canonical.slice(index + 1)
|
||||
.filter(candidate => !protectedNames.has(candidate.name))
|
||||
.map(candidate => candidate.name),
|
||||
)
|
||||
const next = restored.findIndex(candidate => following.has(candidate.name))
|
||||
restored.splice(next < 0 ? restored.length : next, 0, structuredClone(entry))
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
|
||||
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
||||
@@ -327,10 +371,10 @@ function interpolate(section: AssembledSection, variables: Record<string, string
|
||||
|
||||
/**
|
||||
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
|
||||
* sections, tool-schema providers, and named prompt variables; the agent loop
|
||||
* calls `assemble(context)` once per step. Registers the harness-owned
|
||||
* `harness:identity` and `deployment:persona` sections itself (see
|
||||
* {@link Config.persona}).
|
||||
* sections, tool-schema providers, named prompt variables, and authoritative
|
||||
* contribution protections; the agent loop calls `assemble(context)` once per
|
||||
* step. Registers the harness-owned `harness:identity` and
|
||||
* `deployment:persona` sections itself (see {@link Config.persona}).
|
||||
*/
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -347,10 +391,12 @@ export class SystemPrompt extends Service {
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
private protections: PromptProtection[] = []
|
||||
/** 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 scopedProtections = new Map<ScopeKey, PromptProtection[]>()
|
||||
private readonly toolOrder: string[] | undefined
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
@@ -383,7 +429,12 @@ export class SystemPrompt extends Service {
|
||||
* 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
|
||||
* `deployment:persona`) unless that global name is protected: global
|
||||
* protection reserves its section name against scoped shadows so the
|
||||
* registration owner—not a later scope—defines the canonical value. The
|
||||
* registry snapshots `name`, `order`, and `text` before checking/storing, so
|
||||
* later caller-object mutation cannot rename a contribution. 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
|
||||
@@ -395,6 +446,14 @@ export class SystemPrompt extends Service {
|
||||
*/
|
||||
section(section: PromptSection): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptSection = {
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
text: section.text,
|
||||
}
|
||||
if (scope !== undefined && this.protections.some(record => record.sections?.includes(snapshot.name))) {
|
||||
throw new Error(`prompt section "${snapshot.name}" is globally protected and cannot be shadowed in an agent scope`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.sections
|
||||
@@ -403,18 +462,18 @@ export class SystemPrompt extends Service {
|
||||
this.scopedSections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.some(existing => existing.name === section.name)) {
|
||||
if (layer.some(existing => existing.name === snapshot.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`)
|
||||
? `prompt section "${snapshot.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${snapshot.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.push(section)
|
||||
layer.push(snapshot)
|
||||
// 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 = layer.indexOf(section)
|
||||
const index = layer.indexOf(snapshot)
|
||||
/* 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)
|
||||
@@ -531,6 +590,73 @@ export class SystemPrompt extends Service {
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect named section/tool contributions from the assembly waterfall.
|
||||
* The layer is decided by the calling context: a global protection applies
|
||||
* to every assembly, while one registered through `agent.ctx` applies only
|
||||
* to that agent's scope. The name's canonical registry/provider output is
|
||||
* restored AFTER the whole waterfall, so listener registration order cannot
|
||||
* strip, replace, duplicate, or fabricate it. Canonical absence is restored
|
||||
* too: if the protected name is intentionally absent for an assembly, a
|
||||
* listener-injected entry with that name is removed. The input arrays are
|
||||
* snapshotted; an empty protection throws because it cannot affect output.
|
||||
* Removed with the calling fiber and emits `system-prompt/change` on
|
||||
* registration/unregistration. A global section protection also reserves the
|
||||
* name against scoped section shadows; registering protection when such a
|
||||
* shadow already exists fails loudly instead of protecting the wrong owner.
|
||||
* @param protection - section and/or tool names whose canonical presence and definitions are authoritative.
|
||||
* @returns the exact Cordis effect disposer that removes the protection.
|
||||
*/
|
||||
protect(protection: PromptProtection): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptProtection = {
|
||||
...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {},
|
||||
...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {},
|
||||
}
|
||||
if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) {
|
||||
throw new Error('systemPrompt.protect() requires at least one section or tool name')
|
||||
}
|
||||
if (scope === undefined && snapshot.sections !== undefined) {
|
||||
const protectedSections = new Set(snapshot.sections)
|
||||
const conflicts = [...this.scopedSections.values()]
|
||||
.flatMap(layer => layer.filter(section => protectedSections.has(section.name)).map(section => section.name))
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(`systemPrompt.protect() cannot globally protect section${conflicts.length > 1 ? 's' : ''} ${[...new Set(conflicts)].map(name => `"${name}"`).join(', ')} while scoped shadows are registered`)
|
||||
}
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.protections
|
||||
: this.scopedProtections.get(scope) ?? (() => {
|
||||
const created: PromptProtection[] = []
|
||||
this.scopedProtections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
layer.push(snapshot)
|
||||
yield () => {
|
||||
const index = layer.indexOf(snapshot)
|
||||
/* v8 ignore next 3 -- defensive: protection was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedProtections.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.protect()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** Resolve the authoritative names registered for one assembly scope. */
|
||||
private protectedNames(scope: ScopeKey | undefined): { sections: Set<string>; tools: Set<string> } {
|
||||
const records = [
|
||||
...this.protections,
|
||||
...(scope === undefined ? [] : this.scopedProtections.get(scope)) ?? [],
|
||||
]
|
||||
return {
|
||||
sections: new Set(records.flatMap(record => record.sections ?? [])),
|
||||
tools: new Set(records.flatMap(record => record.tools ?? [])),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the current prompt for one caller: the global layer merged with
|
||||
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
|
||||
@@ -546,10 +672,11 @@ export class SystemPrompt extends Service {
|
||||
* 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
|
||||
* assembly before it reaches the model — like the sections' `order` sort,
|
||||
* tool canonicalization happens on the initial assembly, and a listener
|
||||
* owns the determinism of whatever it emits. Await the result before
|
||||
* reading the assembly values — waterfall listeners may be async.
|
||||
* assembly, then restores every visible {@link PromptProtection} from the
|
||||
* pre-waterfall canonical assembly. Like the sections' `order` sort, tool
|
||||
* canonicalization happens on the initial assembly; unprotected listener
|
||||
* output owns its own determinism. Await the result before reading the
|
||||
* assembly values — waterfall listeners may be async.
|
||||
* Interpolation happens later, in {@link renderPrompt}.
|
||||
* @param context - what this assembly is for (defaults to an empty context;
|
||||
* see {@link AssembleContext}).
|
||||
@@ -560,6 +687,10 @@ export class SystemPrompt extends Service {
|
||||
// (`assemble().catch(...)` would miss it).
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||
const scope = context.scope
|
||||
// Protection is a registry input too: snapshot which names are protected
|
||||
// at assembly start. Registrations that land while an async waterfall is
|
||||
// in flight affect the NEXT assembly, matching the other registries.
|
||||
const protectedNames = this.protectedNames(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> = {}
|
||||
@@ -611,7 +742,27 @@ export class SystemPrompt extends Service {
|
||||
tools: orderTools(collected, this.toolOrder, knownNames),
|
||||
variables,
|
||||
}
|
||||
return this.ctx.waterfall(scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
|
||||
// Snapshot only the fields protection can restore. The waterfall receives
|
||||
// `assembly` by reference and may mutate it or return a replacement; these
|
||||
// independent snapshots remain the authoritative registry product.
|
||||
const canonicalSections = protectedNames.sections.size > 0 ? structuredClone(assembly.sections) : undefined
|
||||
const canonicalTools = protectedNames.tools.size > 0 ? structuredClone(assembly.tools) : undefined
|
||||
const result = await this.ctx.waterfall(
|
||||
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
|
||||
() => Promise.resolve(assembly),
|
||||
)
|
||||
// Build a replacement instead of mutating the waterfall result: a
|
||||
// listener may legitimately return a frozen assembly. Merge-extensible
|
||||
// fields ride through the spread untouched.
|
||||
return {
|
||||
...result,
|
||||
...canonicalSections !== undefined
|
||||
? { sections: restoreProtected(canonicalSections, result.sections, protectedNames.sections) }
|
||||
: {},
|
||||
...canonicalTools !== undefined
|
||||
? { tools: restoreProtected(canonicalTools, result.tools, protectedNames.tools) }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,21 @@ describe('scoped sections', () => {
|
||||
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/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['reserved'], 'section "reserved"'],
|
||||
[['first', 'second'], 'sections "first", "second"'],
|
||||
])('rejects global protection added after scoped shadows (%j)', async (names, message) => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
for (const name of names) {
|
||||
scope.ctx.systemPrompt.section({ name, order: 1, text: `scoped ${name}` })
|
||||
}
|
||||
|
||||
expect(() => ctx.systemPrompt.protect({ sections: names })).toThrow(message)
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
|
||||
.toContain(`scoped ${names[0]}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped variables', () => {
|
||||
@@ -148,4 +163,31 @@ describe('scoped assemble dispatch', () => {
|
||||
expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
|
||||
expect(shaped).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a scoped protection finalizes only its own assemblies and disappears with the scope', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const key = scopeKeyOf(scope)
|
||||
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
|
||||
scope.ctx.systemPrompt.protect({ sections: ['required'], tools: ['required'] })
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = result.sections.filter(section => section.name !== 'required')
|
||||
result.tools = result.tools.filter(tool => tool.name !== 'required')
|
||||
return result
|
||||
}, { prepend: true })
|
||||
|
||||
const scoped = await ctx.systemPrompt.assemble({ scope: key })
|
||||
const global = await ctx.systemPrompt.assemble()
|
||||
expect(scoped.sections.some(section => section.name === 'required')).toBe(true)
|
||||
expect(scoped.tools.some(tool => tool.name === 'required')).toBe(true)
|
||||
expect(global.sections.some(section => section.name === 'required')).toBe(false)
|
||||
expect(global.tools.some(tool => tool.name === 'required')).toBe(false)
|
||||
|
||||
await scope.dispose()
|
||||
const disposed = await ctx.systemPrompt.assemble({ scope: key })
|
||||
expect(disposed.sections.some(section => section.name === 'required')).toBe(false)
|
||||
expect(disposed.tools.some(tool => tool.name === 'required')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -205,6 +205,103 @@ describe('SystemPrompt', () => {
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('canonical contribution protection', () => {
|
||||
it('restores exact protected definitions after every listener, in canonical relative order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section' })
|
||||
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [
|
||||
{ name: 'alpha', description: 'alpha', parameters: {} },
|
||||
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
|
||||
{ name: 'zulu', description: 'zulu', parameters: {} },
|
||||
] }))
|
||||
const protection = { sections: ['protected'], tools: ['protected'] }
|
||||
ctx.systemPrompt.protect(protection)
|
||||
// Registration snapshots its arrays; caller mutation cannot change what
|
||||
// the service makes authoritative.
|
||||
protection.sections[0] = 'after'
|
||||
protection.tools[0] = 'zulu'
|
||||
|
||||
// Registered AFTER the protection and prepended: it is outside every
|
||||
// ordinary listener that existed when protect() ran, but service-level
|
||||
// finalization still restores the canonical entries after it returns.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
return Object.freeze({
|
||||
sections: [
|
||||
...result.sections.filter(section => section.name !== 'protected'),
|
||||
{ name: 'protected', order: -999, text: 'wrong section' },
|
||||
{ name: 'protected', order: 999, text: 'duplicate section' },
|
||||
],
|
||||
tools: [
|
||||
...result.tools.filter(tool => tool.name !== 'protected'),
|
||||
{ name: 'protected', description: 'wrong tool', parameters: {} },
|
||||
{ name: 'protected', description: 'duplicate tool', parameters: {} },
|
||||
],
|
||||
variables: result.variables,
|
||||
})
|
||||
}, { prepend: true })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const protectedSections = assembly.sections.filter(section => section.name === 'protected')
|
||||
const protectedTools = assembly.tools.filter(tool => tool.name === 'protected')
|
||||
expect(protectedSections).toEqual([{ name: 'protected', order: 20, text: 'canonical section' }])
|
||||
expect(protectedTools).toEqual([{
|
||||
name: 'protected',
|
||||
description: 'canonical tool',
|
||||
parameters: { type: 'object', properties: { answer: { type: 'number' } } },
|
||||
}])
|
||||
expect(assembly.sections.map(section => section.name).indexOf('protected'))
|
||||
.toBeLessThan(assembly.sections.map(section => section.name).indexOf('after'))
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
|
||||
})
|
||||
|
||||
it('protects canonical absence and rejects an empty protection', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
// Separate registrations exercise the set-union contract: protections
|
||||
// may name only sections or only tools and still compose.
|
||||
ctx.systemPrompt.protect({ sections: ['mode-hidden'] })
|
||||
ctx.systemPrompt.protect({ tools: ['mode-hidden'] })
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections.push({ name: 'mode-hidden', order: 100, text: 'fabricated' })
|
||||
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
|
||||
return result
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.some(section => section.name === 'mode-hidden')).toBe(false)
|
||||
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
|
||||
expect(() => ctx.systemPrompt.protect({})).toThrow(/at least one section or tool name/)
|
||||
expect(() => ctx.systemPrompt.protect({ sections: [], tools: [] })).toThrow(/at least one section or tool name/)
|
||||
})
|
||||
|
||||
it('removes a protection with its contributing fiber (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = result.sections.filter(section => section.name !== 'protected')
|
||||
return result
|
||||
})
|
||||
let changes = 0
|
||||
ctx.on('system-prompt/change', () => { changes++ })
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.protect({ sections: ['protected'] })
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(true)
|
||||
expect(changes).toBe(1)
|
||||
await fiber.dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(false)
|
||||
expect(changes).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
|
||||
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one
|
||||
* async binding per registered tool, serializes every binding call through a
|
||||
* per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` /
|
||||
* `tools/post-execute` gate sub-calls exactly like native ones), logs each
|
||||
* sub-dispatch as a `tool/code-dispatch` session event, and returns only the
|
||||
* program's curated output. The registry itself decides WHEN this tool
|
||||
* exists (its `mode` config); this module owns only the tool and the bridge.
|
||||
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async
|
||||
* binding per end capability visible to the calling agent, then serializes
|
||||
* every binding call through a per-run queue onto `ToolRegistry.execute()`.
|
||||
* Sub-calls therefore traverse the complete pre/guard/around/post/final-result
|
||||
* pipeline exactly like native calls and carry the outer execution's opaque
|
||||
* token for correlation. The bridge logs each sub-dispatch as a
|
||||
* `tool/code-dispatch` session event and returns only the program's curated
|
||||
* output. The registry itself decides WHEN this tool exists (its `mode`
|
||||
* config); this module owns only the tool and the bridge.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tools/src/code-mode
|
||||
*/
|
||||
@@ -205,6 +207,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
name,
|
||||
arguments: normalized.dispatched,
|
||||
...exec.agent ? { agent: exec.agent } : {},
|
||||
parent: exec.token,
|
||||
signal: runController.signal,
|
||||
})
|
||||
const text = textOf(result.content)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Tool registry and execution pipeline. Plugins register tools; the registry
|
||||
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
||||
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
|
||||
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
|
||||
* (inspect/replace the result, attach context) for sandbox, permission, and hook
|
||||
* plugins to gate or transform a call.
|
||||
* through `tools/pre-execute` (the extensible allow/deny gate) → monotonic
|
||||
* registered guards → `tools/execute` (an around-dispatch wrapper for
|
||||
* timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the
|
||||
* result, attach context) → the observe-only `tools/result` notification.
|
||||
*
|
||||
* The registry also owns HOW its tools are presented to the model — its
|
||||
* `mode` config: `'native'` (every tool as a wire function definition,
|
||||
* today's behavior and the default), `'code'` (the wire carries exactly one
|
||||
* tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
||||
* today's behavior and the default), `'code'` (the registry's canonical wire
|
||||
* contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
||||
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
|
||||
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
|
||||
*
|
||||
@@ -21,8 +21,9 @@ import z from 'schemastery'
|
||||
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 { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
@@ -105,10 +106,13 @@ declare module 'cordis' {
|
||||
* unknown tool) is already normalized to an `isError` result by the time a
|
||||
* listener's `await next()` returns, so a wrapper never sees a raw throw from
|
||||
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
|
||||
* mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE
|
||||
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
|
||||
* arguments and re-invokes downstream with the shared payload, so a wrapper
|
||||
* mutates `exec` in place rather than passing a new object to `next()`.)
|
||||
* set or replace the one mutable field, `exec.signal` (e.g. with a per-call
|
||||
* deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity
|
||||
* (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the
|
||||
* pipeline so a wrapper cannot change which capability or scope was
|
||||
* authorized. (Cordis `next()` ignores passed arguments and re-invokes
|
||||
* downstream with the shared payload, so a wrapper changes `exec.signal` in
|
||||
* place rather than passing a new object to `next()`.)
|
||||
* Multiple listeners compose by registration order — an outer one wraps the
|
||||
* inner ones plus dispatch.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
@@ -139,6 +143,21 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
/**
|
||||
* Awaited notification of the authoritative FINAL tool outcome, after the
|
||||
* complete pre/execute/post pipeline, final lossless-JSON validation, and
|
||||
* outer error normalization.
|
||||
* Unlike the three waterfalls, this seam cannot transform the result: each
|
||||
* listener receives the now-frozen execution object and a deep-frozen result
|
||||
* snapshot; listener failures are contained and logged, and
|
||||
* {@link ToolRegistry.execute} still returns the outcome.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by
|
||||
* `exec.agent`, using the same carrier as the pipeline.
|
||||
* @param exec - the execution object that traversed the pipeline.
|
||||
* @param result - a deep-frozen snapshot of the final returned result.
|
||||
* @mode parallel
|
||||
*/
|
||||
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void
|
||||
/**
|
||||
* A tool was registered or unregistered, or a scoped restriction changed
|
||||
* (the available tool set changed — possibly for one scope only). An
|
||||
@@ -152,10 +171,8 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(review): revisit these shapes when the first real tools and
|
||||
// sandbox/permission plugins land (e.g. a concurrency-safety hint for
|
||||
// parallel execution — Claude Code partitions read-only tools; phase 1
|
||||
// executes sequentially).
|
||||
// TODO(review): revisit these shapes when concurrency metadata becomes useful
|
||||
// (for example, a read-only hint that would permit safe parallel execution).
|
||||
|
||||
/**
|
||||
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
|
||||
@@ -214,17 +231,54 @@ export interface ToolResult {
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
|
||||
export interface ToolExecution {
|
||||
callId: CallId
|
||||
name: string
|
||||
/** Parsed JSON arguments (unknown — tools validate their own input). */
|
||||
arguments: unknown
|
||||
declare const toolExecutionTokenBrand: unique symbol
|
||||
|
||||
/** Tokens minted in this module; a cast or JavaScript object cannot forge membership. */
|
||||
const executionTokens = new WeakSet<object>()
|
||||
|
||||
/**
|
||||
* Opaque, immutable identity for one trip through the tool pipeline. Nested
|
||||
* transports carry the enclosing execution's token instead of its live object,
|
||||
* so observe-only result listeners can correlate calls without gaining a
|
||||
* mutation path into an outer around-dispatch wrapper.
|
||||
*/
|
||||
export interface ToolExecutionToken {
|
||||
readonly [toolExecutionTokenBrand]: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-supplied description of one tool call. {@link ToolRegistry.execute}
|
||||
* snapshots this input into a pipeline-owned {@link ToolExecution}; callers do
|
||||
* not choose the execution token.
|
||||
*/
|
||||
export interface ToolExecutionInput {
|
||||
readonly callId: CallId
|
||||
readonly name: string
|
||||
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
|
||||
readonly arguments: unknown
|
||||
/** The agent on whose behalf the call runs (set by the agent loop). */
|
||||
agent?: Agent
|
||||
readonly agent?: Agent
|
||||
/**
|
||||
* Opaque token of the enclosing transport execution, when one exists. Code
|
||||
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
*/
|
||||
readonly parent?: ToolExecutionToken
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* One pending tool call inside the registry pipeline. Call identity, the
|
||||
* registry-assigned {@link token}, and a lossless-JSON-validated, deep-frozen
|
||||
* clone of the parsed arguments are immutable from the first policy listener onward, while an
|
||||
* around-dispatch wrapper may set, replace, or remove only `signal`. The
|
||||
* registry freezes the complete object before `tools/result` observers run.
|
||||
*/
|
||||
export interface ToolExecution extends ToolExecutionInput {
|
||||
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
|
||||
readonly token: ToolExecutionToken
|
||||
}
|
||||
|
||||
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
|
||||
export interface ToolErrorInfo {
|
||||
name: string
|
||||
@@ -255,7 +309,6 @@ export interface ToolExecutionResult {
|
||||
* text in `content` is always present; this is extra structure for code.
|
||||
*/
|
||||
error?: ToolErrorInfo
|
||||
/**
|
||||
/**
|
||||
* Extra model-facing context a `tools/post-execute` listener attached for the
|
||||
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
|
||||
@@ -318,17 +371,28 @@ export type PostToolDecision =
|
||||
* is stringified.
|
||||
*/
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null
|
||||
&& 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
try {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null
|
||||
&& 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
} catch {
|
||||
// A hostile thrown value can trap `instanceof`, property access, or string
|
||||
// coercion. Error normalization is the outermost safety boundary, so its
|
||||
// fallback must itself be total.
|
||||
return '<unprintable thrown value>'
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
|
||||
function errorInfo(error: unknown): ToolErrorInfo | undefined {
|
||||
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
|
||||
try {
|
||||
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** How the registry presents its tools to the model (see {@link Config.mode}). */
|
||||
@@ -338,9 +402,9 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
export interface Config {
|
||||
/**
|
||||
* The presentation mode. `'native'` (the default) contributes every
|
||||
* registered tool as a wire function definition — byte-for-byte today's
|
||||
* behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
|
||||
* the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* visible end capability as a native wire function definition. Under
|
||||
* `'code'` this registry contributes exactly ONE wire tool,
|
||||
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* TypeScript API the program calls. `'both'` contributes every native
|
||||
* definition AND `run_code` + the SDK section. Non-native modes require a
|
||||
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
|
||||
@@ -371,11 +435,27 @@ export interface ToolRestriction {
|
||||
deny?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A monotonic execution guard evaluated after every `tools/pre-execute`
|
||||
* listener and before the tool body. Returning a reason denies the call;
|
||||
* returning `undefined` leaves it unchanged. Because guards have no allow
|
||||
* result, listener ordering cannot turn a denial back into permission.
|
||||
* @param execution - the identity-protected call after extensible pre-execute policy completed.
|
||||
* @returns a final denial reason, or `undefined` to leave the call allowed.
|
||||
*/
|
||||
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
||||
|
||||
/** One guard registration; the wrapper preserves independent duplicate registrations. */
|
||||
interface ToolGuardRegistration {
|
||||
guard: ToolGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
|
||||
* loop executes calls through the `tools/pre-execute` → `tools/execute` →
|
||||
* `tools/post-execute` pipeline. The registry contributes its schemas into the
|
||||
* system-prompt assembly — WHICH schemas is governed by its `mode` config
|
||||
* loop executes calls through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The
|
||||
* registry contributes its schemas into the system-prompt assembly — WHICH
|
||||
* schemas is governed by its `mode` config
|
||||
* (see {@link Config.mode}); under a non-native mode it also owns the reserved
|
||||
* `run_code` presentation transport and the `tools:sdk` prompt section.
|
||||
*
|
||||
@@ -402,6 +482,9 @@ export class ToolRegistry extends Service {
|
||||
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||
/** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
|
||||
private restrictions = new Map<ScopeKey, ToolRestriction[]>()
|
||||
/** 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 mode: ToolPresentationMode
|
||||
/** Reserved presentation transport, kept outside the filterable registration layers. */
|
||||
private readonly codeTransport: ToolDefinition | undefined
|
||||
@@ -418,7 +501,7 @@ export class ToolRegistry extends Service {
|
||||
// the filterable global/scoped capability layers.
|
||||
this.codeTransport = this.mode === 'native'
|
||||
? undefined
|
||||
: createRunCodeTool(this, () => this.requireCodeRuntime())
|
||||
: deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime()))
|
||||
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
|
||||
if (this.mode !== 'native') {
|
||||
ctx.systemPrompt.section({
|
||||
@@ -436,6 +519,11 @@ export class ToolRegistry extends Service {
|
||||
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
|
||||
},
|
||||
})
|
||||
// These are presentation infrastructure, not optional end capabilities.
|
||||
// Protect them at their owner: assembly listeners may still transform
|
||||
// ordinary tools and prose, but cannot silently leave Code Mode without
|
||||
// its only wire transport or the SDK that tells the model how to use it.
|
||||
ctx.systemPrompt.protect({ sections: ['tools:sdk'], tools: [RUN_CODE_NAME] })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,8 +583,12 @@ export class ToolRegistry extends Service {
|
||||
* the shadowing feature, not an error; the global-duplicate message names
|
||||
* `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
|
||||
* the `run_code` name for its presentation transport. The visible schema set
|
||||
* flows into prompt assembly automatically. Disposed with the calling
|
||||
* fiber. Emits `tools/change` on register/unregister.
|
||||
* flows into prompt assembly automatically. Registration validates and
|
||||
* clones the JSON parameters, copies scalar fields, binds each callback once
|
||||
* to the caller's definition as its method receiver, and freezes the stored
|
||||
* snapshot; later mutation or callback replacement on the input object does
|
||||
* not rewrite the registry. 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. The exact
|
||||
@@ -505,24 +597,52 @@ export class ToolRegistry extends Service {
|
||||
*/
|
||||
register(definition: ToolDefinition): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
if (this.codeTransport !== undefined && definition.name === RUN_CODE_NAME) {
|
||||
// A schema crosses the same model/log boundary as execution arguments.
|
||||
// Validate BEFORE cloning because structuredClone silently turns some
|
||||
// forbidden values (for example class instances) into plain records, then
|
||||
// validate the detached value again to contain hostile getters that change
|
||||
// between inspection and snapshotting. A frozen Map is still mutable, so
|
||||
// deepFreeze alone is not a sufficient registration boundary.
|
||||
if (!isJsonValue(definition.parameters)) {
|
||||
throw new TypeError('tool parameters must be losslessly JSON-serializable')
|
||||
}
|
||||
const parameters = structuredClone(definition.parameters)
|
||||
if (!isJsonValue(parameters)) {
|
||||
throw new TypeError('tool parameters must be stable losslessly JSON-serializable data')
|
||||
}
|
||||
// Bind once so replacing a callback on the caller-owned definition after
|
||||
// registration cannot change dispatch, while preserving the historical
|
||||
// method receiver (`this === definition`) for callbacks that use it.
|
||||
const execute = definition.execute.bind(definition)
|
||||
const presentCall = definition.presentCall?.bind(definition)
|
||||
const presentResult = definition.presentResult?.bind(definition)
|
||||
const snapshot: ToolDefinition = deepFreeze({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
parameters,
|
||||
execute,
|
||||
...definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {},
|
||||
...presentCall !== undefined ? { presentCall } : {},
|
||||
...presentResult !== undefined ? { presentResult } : {},
|
||||
})
|
||||
if (this.codeTransport !== undefined && snapshot.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(definition.name)) {
|
||||
if (layer.has(snapshot.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`)
|
||||
? `tool "${snapshot.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${snapshot.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(definition.name, definition)
|
||||
layer.set(snapshot.name, snapshot)
|
||||
// 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 () => {
|
||||
layer.delete(definition.name)
|
||||
layer.delete(snapshot.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)
|
||||
@@ -604,6 +724,30 @@ export class ToolRegistry extends Service {
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a monotonic guard after the extensible `tools/pre-execute`
|
||||
* waterfall. A plain-context guard applies globally; one registered through
|
||||
* `agent.ctx` applies only to that agent. Any matching guard may deny by
|
||||
* returning a reason, while no guard can force-allow a call another guard
|
||||
* denied. The exact effect disposer is returned for ordered ownership and
|
||||
* HMR cleanup.
|
||||
* @param guard - synchronous check; a returned string denies the execution.
|
||||
* @returns the exact disposer that unregisters the guard.
|
||||
*/
|
||||
guard(guard: ToolGuard): () => Promise<void> | 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()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** The (created-on-demand) scoped layer for `scope`. */
|
||||
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
|
||||
let layer = this.scoped.get(scope)
|
||||
@@ -614,6 +758,43 @@ export class ToolRegistry extends Service {
|
||||
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
|
||||
}
|
||||
|
||||
/** First monotonic denial from the global then matching scoped guard layers. */
|
||||
private guardReason(exec: ToolExecution): string | undefined {
|
||||
// Guards are policy, not another transform seam. The pipeline execution's
|
||||
// identity and arguments are already protected; freeze a detached view so
|
||||
// an untyped guard cannot replace the wrapper-mutable signal either.
|
||||
const view: Readonly<ToolExecution> = Object.freeze({ ...exec })
|
||||
for (const { guard } of this.globalGuards) {
|
||||
const reason = guard(view)
|
||||
if (reason !== undefined) return this.assertGuardReason(reason)
|
||||
}
|
||||
if (exec.agent !== undefined) {
|
||||
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
|
||||
const reason = guard(view)
|
||||
if (reason !== undefined) return this.assertGuardReason(reason)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Runtime boundary for JavaScript/casted guards: only strings can deny. */
|
||||
private assertGuardReason(reason: unknown): string {
|
||||
if (typeof reason !== 'string') {
|
||||
throw new TypeError(`tools.guard() must return a denial string or undefined, got ${typeof reason}`)
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -707,8 +888,9 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
|
||||
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
|
||||
* Execute one tool call through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result`
|
||||
* pipeline. `pre-execute` is the extensible gate
|
||||
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
|
||||
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
|
||||
@@ -719,74 +901,177 @@ export class ToolRegistry extends Service {
|
||||
* tool is not registered (or not visible to the calling agent — a
|
||||
* restricted-away global is exactly as absent as a nonexistent one), the
|
||||
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result.
|
||||
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
|
||||
* the final observe-only notification, the authoritative outcome must survive
|
||||
* a lossless JSON round trip; an invalid outcome is normalized to an error.
|
||||
* Caller-owned arguments must survive lossless-JSON validation before and
|
||||
* after cloning; a violation normalizes to an error before policy or dispatch.
|
||||
* @param exec - the single-use call input; its identity is snapshotted and
|
||||
* protected before policy runs.
|
||||
* @returns the final result after every waterfall; failures resolve as
|
||||
* `isError` results, never rejections.
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
|
||||
let execution: ToolExecution
|
||||
try {
|
||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
||||
// 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(
|
||||
carrier, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (decision.kind !== 'allow') {
|
||||
// deny → isError. ask has no permission UI yet, so degrade to deny
|
||||
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
|
||||
// a real prompt; today it is the conservative "not allowed".
|
||||
const reason = decision.kind === 'deny'
|
||||
? decision.reason
|
||||
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${reason}` }],
|
||||
isError: true,
|
||||
}
|
||||
return await this.postExecute(exec, denied)
|
||||
}
|
||||
|
||||
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
|
||||
// with-normalization thunk — the tool body's own try/catch turns a throw
|
||||
// into an isError result so a wrapper (and post-execute) can inspect it;
|
||||
// an unknown tool routes through the same catch. A `tools/execute` listener
|
||||
// (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before
|
||||
// delegating and inspect the normalized result after. Dispatched with the
|
||||
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
|
||||
// agent's calls. ---
|
||||
const result = await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
// 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
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
execution = this.prepareExecution(exec)
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
|
||||
// machinery) becomes an isError result, never a turn failure.
|
||||
return toolErrorResult(exec.callId, error)
|
||||
// Contract-violating non-JSON or non-cloneable arguments cannot enter a
|
||||
// pipeline whose logged and executed forms must agree. Still publish one
|
||||
// scoped final outcome, using an immutable identity shell, so result
|
||||
// observers retain their every-call guarantee without seeing the invalid
|
||||
// value.
|
||||
execution = Object.freeze({
|
||||
token: createExecutionToken(),
|
||||
callId: exec.callId,
|
||||
name: exec.name,
|
||||
arguments: undefined,
|
||||
...exec.agent !== undefined ? { agent: exec.agent } : {},
|
||||
...isExecutionToken(exec.parent) ? { parent: exec.parent } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
})
|
||||
const result = toolErrorResult(execution.callId, error)
|
||||
await this.notifyResult(execution, result)
|
||||
return result
|
||||
}
|
||||
let result: ToolExecutionResult
|
||||
try {
|
||||
// Validate the authoritative FINAL result, not merely the tool body's
|
||||
// intermediate return. Post-policy may replace content or attach context,
|
||||
// and every one of these fields is session-bound. Reject anything that
|
||||
// cannot round-trip losslessly through the durable JSON log before the
|
||||
// observe-only `tools/result` commit point sees success.
|
||||
result = this.snapshotExecutionResult(execution, await this.executePipeline(execution))
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener, guard, or the
|
||||
// waterfall machinery becomes an isError result, never a turn failure.
|
||||
result = toolErrorResult(execution.callId, error)
|
||||
}
|
||||
await this.notifyResult(execution, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */
|
||||
private prepareExecution(input: ToolExecutionInput): ToolExecution {
|
||||
if (input.parent !== undefined && !isExecutionToken(input.parent)) {
|
||||
throw new TypeError('tool execution parent must be a registry-minted opaque token')
|
||||
}
|
||||
if (!isJsonValue(input.arguments)) {
|
||||
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
|
||||
}
|
||||
const args = structuredClone(input.arguments)
|
||||
if (!isJsonValue(args)) {
|
||||
throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data')
|
||||
}
|
||||
const execution: ToolExecution = {
|
||||
token: createExecutionToken(),
|
||||
callId: input.callId,
|
||||
name: input.name,
|
||||
arguments: deepFreeze(args),
|
||||
...input.agent !== undefined ? { agent: input.agent } : {},
|
||||
...input.parent !== undefined ? { parent: input.parent } : {},
|
||||
...input.signal !== undefined ? { signal: input.signal } : {},
|
||||
}
|
||||
Object.defineProperties(execution, {
|
||||
token: { value: execution.token, enumerable: true, writable: false, configurable: false },
|
||||
callId: { value: execution.callId, enumerable: true, writable: false, configurable: false },
|
||||
name: { value: execution.name, enumerable: true, writable: false, configurable: false },
|
||||
arguments: { value: execution.arguments, enumerable: true, writable: false, configurable: false },
|
||||
agent: { value: input.agent, enumerable: true, writable: false, configurable: false },
|
||||
parent: { value: input.parent, enumerable: true, writable: false, configurable: false },
|
||||
})
|
||||
if (input.signal !== undefined) {
|
||||
Object.defineProperty(execution, 'signal', {
|
||||
value: input.signal,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
return execution
|
||||
}
|
||||
|
||||
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
|
||||
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
||||
// 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(
|
||||
carrier, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
const denialReason = decision.kind === 'allow'
|
||||
? this.guardReason(exec)
|
||||
: decision.kind === 'deny'
|
||||
? decision.reason
|
||||
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
|
||||
if (denialReason !== undefined) {
|
||||
// deny → isError. ask has no permission UI yet, so degrade to deny
|
||||
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
|
||||
// a real prompt; today it is the conservative "not allowed".
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${denialReason}` }],
|
||||
isError: true,
|
||||
}
|
||||
return await this.postExecute(exec, denied)
|
||||
}
|
||||
|
||||
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
|
||||
// with-normalization thunk — the tool body's own try/catch turns a throw
|
||||
// into an isError result so a wrapper (and post-execute) can inspect it;
|
||||
// an unknown tool routes through the same catch. A `tools/execute` listener
|
||||
// (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal`
|
||||
// before delegating and inspect the normalized result after. Dispatched with the
|
||||
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
|
||||
// agent's calls. ---
|
||||
const result = this.snapshotExecutionResult(exec, await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
// 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
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
},
|
||||
))
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
}
|
||||
|
||||
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
||||
private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void> {
|
||||
// The pipeline is over: freeze the remaining mutable signal slot so every
|
||||
// observer sees the SAME WeakMap-keyable execution without a mutation race.
|
||||
Object.freeze(exec)
|
||||
// postExecute clones every accepted result/decision before rebuilding the
|
||||
// outcome; all error paths construct plain data. The final result is thus
|
||||
// structurally cloneable before it reaches this observe-only boundary.
|
||||
const snapshot = deepFreeze(structuredClone(result))
|
||||
const callbacks = this.ctx.events.dispatch('parallel', [
|
||||
scopeTarget(this, exec.agent), 'tools/result', exec, snapshot,
|
||||
])
|
||||
await Promise.all(callbacks.map(async (callback) => {
|
||||
try {
|
||||
await callback(exec, snapshot)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -804,21 +1089,14 @@ export class ToolRegistry extends Service {
|
||||
// authoritative-call-id requirement and the "preserve the dispatched
|
||||
// isError/error" contract. The decision is the ONLY sanctioned channel for a
|
||||
// listener to change the outcome (block, or accept-with-replacement); the
|
||||
// call id is always the authoritative `exec.callId`. `content` is copied into
|
||||
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
|
||||
// cannot leak into the returned content either (the elements are the same
|
||||
// references — the snapshot guards the array structure, not deep immutability).
|
||||
const dispatched = {
|
||||
callId: exec.callId,
|
||||
content: [...result.content],
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}
|
||||
const decision = await this.ctx.waterfall(
|
||||
// call id is always the authoritative `exec.callId`. Deep cloning protects
|
||||
// nested content, error, and meta data from in-place listener mutation.
|
||||
const dispatched = this.snapshotExecutionResult(exec, result)
|
||||
const decision = structuredClone(await this.ctx.waterfall(
|
||||
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
||||
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
||||
)
|
||||
))
|
||||
this.assertPostDecision(decision)
|
||||
const additionalContext = decision.additionalContext
|
||||
if (decision.kind === 'block') {
|
||||
return {
|
||||
@@ -835,6 +1113,74 @@ export class ToolRegistry extends Service {
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and detach an around-dispatch result before policy can observe or mutate it. */
|
||||
private snapshotExecutionResult(exec: ToolExecution, value: unknown): ToolExecutionResult {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new TypeError('tools/execute must return a ToolExecutionResult object')
|
||||
}
|
||||
const result = value as Partial<ToolExecutionResult>
|
||||
if (!Array.isArray(result.content) || typeof result.isError !== 'boolean') {
|
||||
throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError')
|
||||
}
|
||||
if (result.callId !== exec.callId) {
|
||||
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
|
||||
}
|
||||
const candidate = {
|
||||
callId: exec.callId,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error !== undefined ? { error: result.error } : {},
|
||||
...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {},
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}
|
||||
// Validate BEFORE cloning: structuredClone turns some forbidden exotic or
|
||||
// class instances into plain objects, which would hide a lossy JSON
|
||||
// boundary violation. Validate the detached clone again to contain hostile
|
||||
// getters whose value changes between inspection and snapshotting.
|
||||
if (!isJsonValue(candidate)) {
|
||||
throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult')
|
||||
}
|
||||
const snapshot = structuredClone(candidate)
|
||||
if (!isJsonValue(snapshot)) {
|
||||
throw new TypeError('tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult')
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** Reject malformed JavaScript/casted post decisions at the public event boundary. */
|
||||
private assertPostDecision(value: unknown): asserts value is PostToolDecision {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new TypeError('tools/post-execute must return a PostToolDecision object')
|
||||
}
|
||||
const decision = value as Partial<PostToolDecision>
|
||||
switch (decision.kind) {
|
||||
case 'accept':
|
||||
if (decision.content !== undefined && !Array.isArray(decision.content)) {
|
||||
throw new TypeError('tools/post-execute accept content must be an array')
|
||||
}
|
||||
return
|
||||
case 'block':
|
||||
if (!Array.isArray(decision.feedback)) {
|
||||
throw new TypeError('tools/post-execute block feedback must be an array')
|
||||
}
|
||||
return
|
||||
default:
|
||||
throw new TypeError('tools/post-execute must return an accept or block decision')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint a frozen, property-free correlation token whose identity is its value. */
|
||||
function createExecutionToken(): ToolExecutionToken {
|
||||
const token = Object.freeze(Object.create(null)) as ToolExecutionToken
|
||||
executionTokens.add(token)
|
||||
return token
|
||||
}
|
||||
|
||||
/** Runtime counterpart of the opaque token type, including `undefined` input. */
|
||||
function isExecutionToken(value: unknown): value is ToolExecutionToken {
|
||||
return typeof value === 'object' && value !== null && executionTokens.has(value)
|
||||
}
|
||||
|
||||
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
|
||||
|
||||
@@ -123,6 +123,23 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(sdk?.text).not.toContain('run_code(args:')
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('restores Code Mode infrastructure after assembly listeners in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt } = await setup({ mode })
|
||||
registerEcho(ctx)
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const assembly = await next()
|
||||
return {
|
||||
...assembly,
|
||||
sections: assembly.sections.filter(section => section.name !== 'tools:sdk'),
|
||||
tools: assembly.tools.filter(tool => tool.name !== RUN_CODE_NAME),
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
const assembly = await systemPrompt.assemble()
|
||||
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true)
|
||||
expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(true)
|
||||
})
|
||||
|
||||
it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'both' })
|
||||
registerEcho(ctx)
|
||||
@@ -197,13 +214,40 @@ describe('mode-aware wire contribution', () => {
|
||||
|
||||
expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
|
||||
expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: -999, text: 'malicious SDK' }))
|
||||
.toThrow(/globally protected and cannot be shadowed/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
const transport = ctx.tools.get(RUN_CODE_NAME)!
|
||||
expect(Object.isFrozen(transport)).toBe(true)
|
||||
expect(Object.isFrozen(transport.parameters)).toBe(true)
|
||||
expect(() => { transport.name = 'mutated_transport' }).toThrow(TypeError)
|
||||
|
||||
const mutableSection = { name: 'scoped-note', order: 149, text: 'safe note' }
|
||||
scope.ctx.systemPrompt.section(mutableSection)
|
||||
mutableSection.name = 'tools:sdk'
|
||||
mutableSection.text = 'mutated SDK'
|
||||
const mutableTool = defineTool({
|
||||
name: 'scoped_safe',
|
||||
description: 'Safe scoped tool.',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text' as const, text: 'safe' }]),
|
||||
})
|
||||
scope.ctx.tools.register(mutableTool)
|
||||
mutableTool.name = RUN_CODE_NAME
|
||||
mutableTool.description = 'Mutated transport impostor.'
|
||||
const stored = ctx.tools.get('scoped_safe', agent)!
|
||||
expect(Object.isFrozen(stored)).toBe(true)
|
||||
expect(Object.isFrozen(stored.parameters)).toBe(true)
|
||||
expect(() => { stored.name = RUN_CODE_NAME }).toThrow(TypeError)
|
||||
|
||||
const assembly = await systemPrompt.assemble({ scope: agent })
|
||||
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(transports).toHaveLength(1)
|
||||
expect(transports[0]?.description).toContain('Execute a TypeScript program')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).not.toContain('mutated SDK')
|
||||
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
|
||||
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
|
||||
expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
|
||||
const result = await runCode(ctx, 'return 1', { agent })
|
||||
@@ -306,6 +350,36 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
|
||||
})
|
||||
|
||||
it('exposes only an opaque parent token to nested result observers', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
registerEcho(ctx)
|
||||
runtime.behavior = async (request) => {
|
||||
await request.bindings[0]!.functions.echo!({ value: 'nested' })
|
||||
return { logs: [], value: 'done' }
|
||||
}
|
||||
|
||||
// Model a timeout-style outer wrapper: it temporarily installs a signal,
|
||||
// delegates, then restores the exact prior shape. A nested result observer
|
||||
// is observe-only and must not receive the live outer execution object;
|
||||
// freezing the correlation value it sees therefore cannot break restore.
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
if (exec.name !== RUN_CODE_NAME) return next()
|
||||
const previous = exec.signal
|
||||
exec.signal = new AbortController().signal
|
||||
const result = await next()
|
||||
if (previous === undefined) delete exec.signal
|
||||
else exec.signal = previous
|
||||
return result
|
||||
})
|
||||
ctx.on('tools/result', (exec) => {
|
||||
if (exec.parent !== undefined) Object.freeze(exec.parent)
|
||||
})
|
||||
|
||||
const result = await runCode(ctx, 'await tools.echo({ value: "nested" })')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'done' }])
|
||||
})
|
||||
|
||||
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const intervals: [string, string][] = []
|
||||
@@ -639,16 +713,17 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
|
||||
it('logs the value the tool RECEIVED even when the tool mutates its arguments', async () => {
|
||||
it('gives the tool and durable log the same immutable argument value', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const { agent, events } = fakeAgent()
|
||||
let mutationSucceeded: boolean | undefined
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'mutator',
|
||||
description: 'Mutates its own args object.',
|
||||
description: 'Attempts to mutate its args object.',
|
||||
parameters: { list: { type: 'array', required: true } },
|
||||
execute(args) {
|
||||
args.list.push('injected-by-tool')
|
||||
return Promise.resolve([{ type: 'text' as const, text: 'mutated' }])
|
||||
mutationSucceeded = Reflect.set(args.list, 1, 'injected-by-tool')
|
||||
return Promise.resolve([{ type: 'text' as const, text: 'protected' }])
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
@@ -657,6 +732,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(mutationSucceeded).toBe(false)
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||
expect(dispatch.arguments).toEqual({ list: ['original'] })
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } 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 { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } 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'
|
||||
@@ -149,6 +149,11 @@ describe('restrict()', () => {
|
||||
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/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/)
|
||||
|
||||
const emptyCtx = await mount()
|
||||
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
|
||||
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
|
||||
.toThrow(/known tools for this scope: \(none\)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,4 +175,296 @@ describe('scoped execution dispatch', () => {
|
||||
expect(await run(ctx, 't')).toBe('ran:t')
|
||||
expect(seen).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
const other = { id: 'other' as AgentId } as Agent
|
||||
let bodyCalls = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
|
||||
},
|
||||
})
|
||||
let guardViewFrozen = false
|
||||
const guard = (execution: Readonly<ToolExecution>): string => {
|
||||
guardViewFrozen = Object.isFrozen(execution) && Object.isFrozen(execution.arguments)
|
||||
return 'terminal policy'
|
||||
}
|
||||
const liftFirst = scope.ctx.tools.guard(guard)
|
||||
scope.ctx.tools.guard(guard)
|
||||
// Registered later and prepended outside every existing waterfall listener:
|
||||
// it can force the extensible pre decision to allow, but cannot bypass the
|
||||
// owner-level monotonic guard that runs after the waterfall.
|
||||
scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
|
||||
|
||||
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
|
||||
expect(guardViewFrozen).toBe(true)
|
||||
expect(await run(ctx, 't', other)).toBe('ran:t')
|
||||
expect(bodyCalls).toBe(1)
|
||||
|
||||
await liftFirst()
|
||||
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
|
||||
await scope.dispose()
|
||||
expect(await run(ctx, 't', key)).toBe('ran:t')
|
||||
expect(bodyCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('composes global guards monotonically when one abstains and a later one denies', async () => {
|
||||
const ctx = await mount()
|
||||
let bodyCalls = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.tools.guard(() => undefined)
|
||||
ctx.tools.guard(() => 'global denial')
|
||||
|
||||
expect(await run(ctx, 't')).toBe('Error: global denial')
|
||||
expect(bodyCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('protects call identity before policy and dispatch while leaving only signal mutable', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
let safeCalls = 0
|
||||
let dangerCalls = 0
|
||||
let scopedResults = 0
|
||||
let safeArguments: unknown
|
||||
ctx.tools.register({
|
||||
...tool('safe'),
|
||||
execute: (args) => {
|
||||
safeCalls += 1
|
||||
safeArguments = args
|
||||
return Promise.resolve([{ type: 'text', text: 'safe' }])
|
||||
},
|
||||
})
|
||||
ctx.tools.register({
|
||||
...tool('danger'),
|
||||
execute: () => {
|
||||
dangerCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'danger' }])
|
||||
},
|
||||
})
|
||||
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
|
||||
expect(Reflect.set(exec, 'name', 'safe')).toBe(false)
|
||||
expect(Reflect.set(exec.arguments as object, 'injected', true)).toBe(false)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/execute', (exec, next) => {
|
||||
expect(Reflect.set(exec, 'name', 'danger')).toBe(false)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
|
||||
return next()
|
||||
})
|
||||
scope.ctx.on('tools/result', () => { scopedResults += 1 })
|
||||
|
||||
expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
|
||||
const callerArguments = { source: true }
|
||||
const safeResult = await ctx.tools.execute({
|
||||
callId: CallId('safe-call'),
|
||||
name: 'safe',
|
||||
arguments: callerArguments,
|
||||
agent: key,
|
||||
})
|
||||
expect(safeResult.content[0]).toMatchObject({ text: 'safe' })
|
||||
expect(Object.isFrozen(callerArguments)).toBe(false)
|
||||
expect(safeArguments).not.toBe(callerArguments)
|
||||
expect(Object.isFrozen(safeArguments)).toBe(true)
|
||||
expect(callerArguments).toEqual({ source: true })
|
||||
expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
|
||||
safeCalls: 1,
|
||||
dangerCalls: 0,
|
||||
scopedResults: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
let policyCalls = 0
|
||||
let bodyCalls = 0
|
||||
let scopedObserved = 0
|
||||
let globalObserved = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
policyCalls += 1
|
||||
return next()
|
||||
})
|
||||
let parent!: ToolExecutionToken
|
||||
ctx.tools.register(tool('parent'))
|
||||
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
|
||||
if (exec.name === 'parent') parent = exec.token
|
||||
return next()
|
||||
})
|
||||
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
|
||||
stopCapture()
|
||||
policyCalls = 0
|
||||
const signal = new AbortController().signal
|
||||
scope.ctx.on('tools/result', (exec, result) => {
|
||||
scopedObserved += 1
|
||||
expect(exec.arguments).toBeUndefined()
|
||||
expect(exec.parent).toBe(parent)
|
||||
expect(exec.signal).toBe(signal)
|
||||
expect(Object.isFrozen(exec)).toBe(true)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
ctx.on('tools/result', () => { globalObserved += 1 })
|
||||
const callerArguments = { invalid: () => undefined }
|
||||
|
||||
const scopedResult = await ctx.tools.execute({
|
||||
callId: CallId('non-cloneable'),
|
||||
name: 't',
|
||||
arguments: callerArguments,
|
||||
agent: key,
|
||||
parent,
|
||||
signal,
|
||||
})
|
||||
const subjectlessResult = await ctx.tools.execute({
|
||||
callId: CallId('non-cloneable-subjectless'),
|
||||
name: 't',
|
||||
arguments: { invalid: () => undefined },
|
||||
})
|
||||
expect(scopedResult.isError).toBe(true)
|
||||
expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable')
|
||||
expect(subjectlessResult.isError).toBe(true)
|
||||
expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({
|
||||
policyCalls: 0,
|
||||
bodyCalls: 0,
|
||||
scopedObserved: 1,
|
||||
globalObserved: 2,
|
||||
})
|
||||
expect(Object.isFrozen(callerArguments)).toBe(false)
|
||||
expect(callerArguments.invalid).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('rejects a forged mutable parent token without exposing it to final observers', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.tools.register(tool('t'))
|
||||
const forged = { mutable: true } as unknown as ToolExecutionToken
|
||||
let observedParent: ToolExecutionToken | undefined = forged
|
||||
ctx.on('tools/result', (exec) => { observedParent = exec.parent })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('forged-parent'), name: 't', arguments: {}, parent: forged,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text', text: 'Error: tool execution parent must be a registry-minted opaque token',
|
||||
}])
|
||||
expect(observedParent).toBeUndefined()
|
||||
expect(Object.isFrozen(forged)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Map', new Map([['mutable', true]])],
|
||||
['class instance', new (class Arguments { value = 1 })()],
|
||||
])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => {
|
||||
const ctx = await mount()
|
||||
let policyCalls = 0
|
||||
let bodyCalls = 0
|
||||
let observed = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
policyCalls += 1
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/result', (exec, result) => {
|
||||
observed += 1
|
||||
expect(exec.arguments).toBeUndefined()
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable',
|
||||
}])
|
||||
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
|
||||
})
|
||||
|
||||
it('rejects arguments that change to non-JSON data while being snapshotted', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.tools.register(tool('t'))
|
||||
let reads = 0
|
||||
const argumentsValue = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
callId: CallId('unstable-arguments'),
|
||||
content: [{
|
||||
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data',
|
||||
}],
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('t'))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
|
||||
const seen: boolean[] = []
|
||||
const dispatchModes: string[] = []
|
||||
ctx.on('internal/dispatch', (mode, name) => {
|
||||
if (name === 'tools/result') dispatchModes.push(mode)
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
await next()
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'outer failure' }],
|
||||
isError: true,
|
||||
}
|
||||
}, { prepend: true })
|
||||
scope.ctx.on('tools/result', (_exec, result) => {
|
||||
expect(Object.isFrozen(_exec)).toBe(true)
|
||||
expect(Object.isFrozen(_exec.arguments)).toBe(true)
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(Object.isFrozen(result.content)).toBe(true)
|
||||
seen.push(result.isError)
|
||||
})
|
||||
ctx.on('tools/result', () => {
|
||||
throw { toString: () => { throw new Error('coercion trap') } }
|
||||
})
|
||||
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
|
||||
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
|
||||
expect(seen).toEqual([true, true])
|
||||
expect(dispatchModes).toEqual(['parallel'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, {
|
||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||
type ToolExecution, type ToolExecutionResult,
|
||||
type ToolExecution, type ToolExecutionResult, type ToolGuard,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
async function setup() {
|
||||
@@ -113,6 +113,53 @@ describe('ToolRegistry', () => {
|
||||
expect('meta' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes a contract-violating non-cloneable result before final notification', async () => {
|
||||
const ctx = await setup()
|
||||
let observedError: boolean | undefined
|
||||
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'bad-meta',
|
||||
async execute() {
|
||||
return { content: [], meta: () => undefined }
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
|
||||
expect(observedError).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes a result that changes to non-JSON data while being snapshotted', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
let reads = 0
|
||||
const hostileBlock = Object.defineProperty({ type: 'text' }, 'text', {
|
||||
enumerable: true,
|
||||
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
|
||||
})
|
||||
ctx.on('tools/execute', async exec => ({
|
||||
callId: exec.callId,
|
||||
content: [hostileBlock],
|
||||
isError: false,
|
||||
}) as unknown as ToolExecutionResult)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('unstable-result'), name: 'echo', arguments: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
callId: CallId('unstable-result'),
|
||||
content: [{
|
||||
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult',
|
||||
}],
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns isError results for unknown tools and throwing tools', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
@@ -134,6 +181,28 @@ describe('ToolRegistry', () => {
|
||||
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
|
||||
})
|
||||
|
||||
it('normalizes a hostile thrown value whose inspection and coercion both throw', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'hostile-throw',
|
||||
async execute() {
|
||||
throw new Proxy({}, {
|
||||
getPrototypeOf: () => { throw new Error('prototype trap') },
|
||||
has: () => { throw new Error('has trap') },
|
||||
get: () => { throw new Error('get trap') },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.tools.execute({
|
||||
callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
|
||||
})).resolves.toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error: <unprintable thrown value>' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('ToolNotFoundError carries the tool name and a stable code', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const err = new ToolNotFoundError('ghost')
|
||||
@@ -158,6 +227,25 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
|
||||
})
|
||||
|
||||
it('rejects a JavaScript guard that returns an async/non-string decision', async () => {
|
||||
const ctx = await setup()
|
||||
let bodyCalls = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
async execute() {
|
||||
bodyCalls += 1
|
||||
return []
|
||||
},
|
||||
})
|
||||
ctx.tools.guard((() => Promise.resolve('late denial')) as unknown as ToolGuard)
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('bad-guard'), name: 'echo', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type === 'text' && result.content[0].text)
|
||||
.toContain('tools.guard() must return')
|
||||
expect(bodyCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('an ask decision degrades to deny until the permission system lands', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -233,7 +321,7 @@ describe('ToolRegistry', () => {
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
|
||||
it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => {
|
||||
// The decision is the ONLY sanctioned channel to change the outcome. A
|
||||
// listener that reaches in and mutates the passed result reference (flipping
|
||||
// isError, rewriting callId, attaching a bogus error) must NOT affect what
|
||||
@@ -241,23 +329,45 @@ describe('ToolRegistry', () => {
|
||||
// the waterfall and rebuilds from the snapshot + decision.
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
await next()
|
||||
return {
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: true,
|
||||
error: { name: 'OriginalError', code: 'ORIGINAL' },
|
||||
meta: { nested: { label: 'original' } },
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, result, next) => {
|
||||
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
|
||||
const mutable = result as {
|
||||
callId: string
|
||||
isError: boolean
|
||||
error?: { name: string; code: string }
|
||||
content: { type: 'text'; text: string }[]
|
||||
meta?: { nested: { label: string } }
|
||||
}
|
||||
mutable.callId = 'hijacked'
|
||||
mutable.isError = true
|
||||
mutable.error = { name: 'Evil', code: 'EVIL' }
|
||||
mutable.isError = false
|
||||
if (mutable.error) {
|
||||
mutable.error.name = 'Evil'
|
||||
mutable.error.code = 'EVIL'
|
||||
}
|
||||
mutable.content[0]!.text = 'MUTATED'
|
||||
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
|
||||
if (mutable.meta) mutable.meta.nested.label = 'MUTATED'
|
||||
return next() // delegate to the default accept — no decision-level override
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
|
||||
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
|
||||
expect(result.error).toBeUndefined() // no listener-injected error
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'OriginalError', code: 'ORIGINAL' })
|
||||
expect(result.content).toHaveLength(1) // the in-place push did not leak in
|
||||
expect(result.content[0]).toMatchObject({ text: 'hi' })
|
||||
expect(result.content[0]).toMatchObject({ text: 'original' })
|
||||
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
|
||||
expect(result.meta).toEqual({ nested: { label: 'original' } })
|
||||
})
|
||||
|
||||
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
|
||||
@@ -416,6 +526,109 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
|
||||
})
|
||||
|
||||
it('preserves additionalContext supplied by an around-dispatch result', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async exec => ({
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'short-circuited with context' }],
|
||||
isError: false,
|
||||
additionalContext: {
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
},
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('around-context'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.additionalContext).toEqual({
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes malformed tools/execute results instead of treating them as success', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
let observedError: boolean | undefined
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
await next()
|
||||
return {} as ToolExecutionResult
|
||||
})
|
||||
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('malformed'), name: 'echo', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: 'Error: tools/execute must return a ToolExecutionResult with content[] and boolean isError',
|
||||
})
|
||||
expect(observedError).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'non-object result',
|
||||
replacement: null,
|
||||
message: 'tools/execute must return a ToolExecutionResult object',
|
||||
},
|
||||
{
|
||||
name: 'wrong call id',
|
||||
replacement: { callId: CallId('other'), content: [], isError: false },
|
||||
message: 'tools/execute returned callId "other" for authoritative call "malformed-shape"',
|
||||
},
|
||||
])('normalizes a tools/execute $name', async ({ replacement, message }) => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async () => replacement as ToolExecutionResult)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
|
||||
})
|
||||
|
||||
it('normalizes malformed tools/post-execute decisions', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/post-execute', async () => ({ kind: 'accept', content: 'not blocks' }) as unknown as PostToolDecision)
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('malformed-post'), name: 'echo', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: 'Error: tools/post-execute accept content must be an array',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'non-object decision',
|
||||
replacement: null,
|
||||
message: 'tools/post-execute must return a PostToolDecision object',
|
||||
},
|
||||
{
|
||||
name: 'block without feedback blocks',
|
||||
replacement: { kind: 'block', feedback: 'not blocks' },
|
||||
message: 'tools/post-execute block feedback must be an array',
|
||||
},
|
||||
{
|
||||
name: 'unknown decision kind',
|
||||
replacement: { kind: 'defer' },
|
||||
message: 'tools/post-execute must return an accept or block decision',
|
||||
},
|
||||
])('normalizes a tools/post-execute $name', async ({ replacement, message }) => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/post-execute', async () => replacement as unknown as PostToolDecision)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('malformed-post-shape'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
|
||||
})
|
||||
|
||||
it('returns an isError result when a tools/execute listener throws', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -493,6 +706,61 @@ describe('ToolRegistry', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Map', new Map([['mutable', true]])],
|
||||
['class instance', new (class Parameters { value = 1 })()],
|
||||
])('rejects cloneable non-JSON tool parameters (%s) without registry residue', async (_kind, parameters) => {
|
||||
const ctx = await setup()
|
||||
const definition = {
|
||||
...echoTool,
|
||||
name: 'invalid-parameters',
|
||||
parameters,
|
||||
} as unknown as typeof echoTool
|
||||
|
||||
expect(() => ctx.tools.register(definition)).toThrow(
|
||||
'tool parameters must be losslessly JSON-serializable',
|
||||
)
|
||||
expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => {
|
||||
const ctx = await setup()
|
||||
let reads = 0
|
||||
const parameters = Object.defineProperty({}, 'properties', {
|
||||
enumerable: true,
|
||||
get: () => ++reads === 1 ? {} : new Map([['mutable', true]]),
|
||||
})
|
||||
|
||||
expect(() => ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'unstable-parameters',
|
||||
parameters,
|
||||
})).toThrow('tool parameters must be stable losslessly JSON-serializable data')
|
||||
expect(ctx.tools.get('unstable-parameters')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('snapshots callbacks while preserving their registration-time method receiver', async () => {
|
||||
const ctx = await setup()
|
||||
const receivers: object[] = []
|
||||
const definition = {
|
||||
...echoTool,
|
||||
name: 'callback-snapshot',
|
||||
async execute() {
|
||||
receivers.push(this)
|
||||
return [{ type: 'text' as const, text: 'original' }]
|
||||
},
|
||||
}
|
||||
ctx.tools.register(definition)
|
||||
definition.execute = async () => [{ type: 'text' as const, text: 'replacement' }]
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('callback-snapshot'), name: definition.name, arguments: {},
|
||||
})
|
||||
|
||||
expect(receivers).toEqual([definition])
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'original' }])
|
||||
})
|
||||
|
||||
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
Reference in New Issue
Block a user