refactor(core): simplify scoped agent lifecycles

This commit is contained in:
Tianyi Cui
2026-07-12 22:36:04 +08:00
parent e8fed4fb66
commit 28e04ff4fb
24 changed files with 1080 additions and 4097 deletions

View File

@@ -8,20 +8,20 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair deliberately reuses the stable carrier captured before entry commit and applies the same per-listener containment directly. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while registry/store-owned reservation capabilities keep both identities unpublished; creation awaits setup and a same-turn owner-unload checkpoint before either creation notification or the first assembly. Setup composes, it never drives or publishes: driving verbs and ordinary agent/session insertion both reject until the owning publication boundary.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
- `ctx.agents.register(agent: Agent): () => Promise<void> | void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `reserve(id)` returns an opaque unpublished-identity capability whose `release` is the exact owner effect disposer, allowing the factory to place ID release after scope quiescence instead of racing owner unload as a sibling. `enter(agent, reservation?): () => void` claims the ID across runtime pinning and stable lifecycle-carrier construction, then inserts without announcing; a Proxy trap or filter getter cannot reentrantly overwrite the commit. `announce(agent)` reuses that carrier and emits `agent/created` exactly once for the exact live entry, rejecting repeat or reentrant announcement. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach is exact-object guarded, so a later listener cannot observe inverted lifecycle edges and a stale capability cannot delete a replacement. While reserved, bare `register`/`enter` calls for the id reject, including from setup. The factory uses this split; ordinary plugins use `register()`.
- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
- `ctx.agents.get(id: AgentId): Agent | undefined`
- `ctx.agents.list(): Agent[]`
#### Factory seam (creation)
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target, captures and validates the factory's `createAgent` and `resume` callbacks once at registration, retains that target as their intentional receiver, and passes each call an explicit caller-bound `ownerCtx`; later method replacement cannot redirect a transaction, double tracing cannot break raw-identity state, and a plain non-Cordis factory receives enough context to implement caller ownership.
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
- `ctx.agents.setFactory(factory: AgentFactory): () => Promise<void> | void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`snapshot caller-owned IDs/options/metadata and hand the one-read raw seed synchronously to the session boundary for one-pass lossless-JSON materialization, construct and await optional setup while unpublished, insert both session and agent, then recheck caller and factory liveness before the first creation announcement and after each later notification boundary. Only a still-live transaction opens `agent/session-start` and starts a new loop on the caller-supplied `sessionId`. Registry/store reservation capabilities block every competing public insertion across setup; seed rejection, setup rejection, caller unload, factory unload, or cancellation from a creation listener publishes no drivable agent. Publication is rollback-covered: if a creation listener throws, entries and scope unwind but effects of already-delivered notifications remain observable; any creation announcement that began is paired by `agent/disposed` or `session/disposed`. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` snapshot caller-owned IDs/options, load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh agent scope, await optional setup while unpublished, then follow the same insert both → pre-announcement liveness check → session announcement → liveness check → agent announcement → liveness check → session-start → final liveness check → loop-start boundary. The IDs are reserved across persistence load, setup, and teardown quiescence; load/setup rejection, caller unload, or factory unload leaves no drivable or live publication, while any creation edge that already began is paired during rollback. Rejects if no factory is registered or session persistence is unconfigured.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
@@ -29,7 +29,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
`dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events.
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist, but concrete driving remains locked until the immediately following `agent/session-start`; that non-vetoing notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the owner-final exception: it runs after ordinary continuation and steering folding, and its terminal state remains through turn close and flush so steering from those later listeners cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#owner-final-policy-four-narrow-boundaries).

View File

@@ -6,8 +6,8 @@
* the first argument in one move, so a site cannot name a different subject.
* The registry lifecycle pair is the deliberate exception: `enter()` captures
* one stable carrier before commit and `announce()`/detach dispatch through it
* directly, preventing a mutable filter getter from changing or reentering the
* paired edges. The dev scoped-dispatch invariant checks both shapes.
* directly, so both lifecycle edges use the same routing identity. The dev
* scoped-dispatch invariant checks both shapes.
*
* @module @deepseek-ai/dsh-agent/dispatch
*/
@@ -61,16 +61,6 @@ 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`
@@ -110,10 +100,10 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
ctx.logger.warn(`agent event "${name}" listener rejected: ${renderThrown(error)}`)
ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`)
})
} catch (error: unknown) {
ctx.logger.warn(`agent event "${name}" listener threw: ${renderThrown(error)}`)
ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`)
}
}
},
@@ -122,22 +112,6 @@ 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
@@ -146,15 +120,6 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
}
}
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
}
/**
* The assembly context for one agent's prompt: the typed `agent` DX field and
* the `scope` layer selector, set together (setting `agent` without `scope`

View File

@@ -40,21 +40,20 @@ declare module 'cordis' {
*/
export interface CreateAgentOptions {
/** The agent's id (the registry handle). */
agentId: AgentId
readonly agentId: AgentId
/** The live session's id (NOT derived from agentId). */
sessionId: SessionId
readonly sessionId: SessionId
/**
* Session creation metadata: validated absolute `cwd`, `parentSession`
* fork lineage, and the `seedLength` seed boundary. Mirrors the
* `cwd`/`parentSession`/`seedLength` fields of
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
* `createdAt`, used when reconstructing a persisted session, is deliberately
* excluded — a factory caller never sets it). The factory reads this raw
* reference once and hands it synchronously to the session boundary, which
* rejects an exotic shell and captures each accepted field once before any
* asynchronous setup.
* excluded — a factory caller never sets it). This is durable session data,
* so the session boundary validates and snapshots it before asynchronous
* setup begins.
*/
meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number }
readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number }
/**
* Seed events to reconstruct the child session's log from (the fork lineage
* primitive). When present, the factory creates the session with this event
@@ -64,12 +63,14 @@ export interface CreateAgentOptions {
* from seq 0, carry only lossless-JSON data, and be balanced (no open
* turn/step, no dangling tool-call), or the session constructor (and the
* dev-mode invariants replay) reject it. The factory passes the raw seed to
* the synchronous one-pass validator/copier; it never pre-clones and thereby
* sanitizes exotic prototypes. Absent for a fresh (spawn) child.
* the session's durable validator/snapshot boundary. Absent for a fresh
* (spawn) child.
*/
seed?: SessionEvent[]
readonly seed?: readonly SessionEvent[]
/** Per-agent options (model, …). */
agentOptions?: AgentOptions
readonly agentOptions?: AgentOptions
/** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */
readonly signal?: AbortSignal
/**
* Creation-time composition of the agent's scoped world. The factory awaits
* setup after minting `agentCtx` but BEFORE inserting or announcing either
@@ -80,11 +81,11 @@ export interface CreateAgentOptions {
* 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 composes, it never drives**: the callback is trusted same-process
* code and receives the full scoped context, so this is a contract rather
* than a runtime restriction. Drive the agent only after creation resolves.
*/
setup?: (agentCtx: Context) => Promise<void> | void
readonly setup?: (agentCtx: Context) => Promise<void> | void
}
/**
@@ -93,21 +94,23 @@ export interface CreateAgentOptions {
*/
export interface ResumeAgentOptions {
/** The agent's id (the registry handle). */
agentId: AgentId
readonly agentId: AgentId
/** The persisted session id to load and resume on. */
resumeSessionId: SessionId
readonly resumeSessionId: SessionId
/** Per-agent options (model, …). */
agentOptions?: AgentOptions
readonly agentOptions?: AgentOptions
/** Optional creation-only cancellation signal for persistence load/setup; detached before return. */
readonly signal?: AbortSignal
/**
* 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.
* same trusted composition-only contract as
* {@link CreateAgentOptions.setup}: all registrations exist before either
* creation announcement, and rejection or owner disposal rolls the
* transaction back without publishing either id.
*/
setup?: (agentCtx: Context) => Promise<void> | void
readonly setup?: (agentCtx: Context) => Promise<void> | void
}
/**
@@ -143,8 +146,8 @@ export interface AgentFactory {
/**
* 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
* creation notifications in order, emits `agent/session-start`, and only
* then starts the loop. The sequence is
* rollback-covered, but notifications delivered before a later listener
* failure remain observable; every agent or session creation announcement
* that began is paired by `agent/disposed` or `session/disposed` during
@@ -163,8 +166,8 @@ export interface AgentFactory {
* Load a persisted session and resume an agent on it. Async because it awaits
* 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}.
* `sessionPersistence`). Publication follows the same ordered boundary as
* {@link createAgent}.
* @param ownerCtx - caller-bound context that owns load, setup, and the live handle.
* @param options - persisted identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.
@@ -172,72 +175,22 @@ export interface AgentFactory {
resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
}
/** One accepted factory target plus the callback identities captured at registration. */
interface AcceptedAgentFactory {
target: AgentFactory
createAgent: AgentFactory['createAgent']
resume: AgentFactory['resume']
}
/** Slot reservation while callback accessors are being captured. */
const ACCEPTING_FACTORY = Symbol('accepting agent factory')
/** Capture and validate the complete factory contract exactly once. */
function acceptAgentFactory(factory: unknown): AcceptedAgentFactory {
if ((typeof factory !== 'object' && typeof factory !== 'function') || factory === null) {
throw new TypeError('agent factory must be a non-null object or function')
}
// A service read through ctx is already a Cordis trace proxy. Retaining that
// proxy and tracing it again for each create() caller produces two shadow
// layers; raw-identity state (AgentLoop's private ownership controller is
// one example) then unwraps only to the inner proxy instead of its service.
// Canonicalize the one framework-produced layer at acceptance and capture
// callbacks from the concrete target. Plain objects expose no original.
const original: unknown = Reflect.get(factory, symbols.original)
const target = ((typeof original === 'object' || typeof original === 'function') && original !== null)
? original
: factory
const createAgent: unknown = Reflect.get(target, 'createAgent')
const resume: unknown = Reflect.get(target, 'resume')
if (typeof createAgent !== 'function') throw new TypeError('agent factory createAgent must be a function')
if (typeof resume !== 'function') throw new TypeError('agent factory resume must be a function')
return Object.freeze({
target: target as AgentFactory,
createAgent: createAgent as AgentFactory['createAgent'],
resume: resume as AgentFactory['resume'],
})
}
/** Thrown when create/resume is called before an agent factory is registered. */
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
/** Render an arbitrary thrown value without allowing coercion to throw again. */
function renderThrown(value: unknown): string {
try {
return value instanceof Error ? `${value.name}: ${value.message}` : String(value)
} catch {
return '<unrenderable thrown value>'
}
/** All mutable lifecycle state for one exact registry entry. */
interface AgentEntry {
readonly id: AgentId
readonly agent: Agent
readonly carrier: Scoped<Agent>
announced: boolean
announcing: boolean
detachRequested: boolean
}
/**
* Unforgeable ownership handle for one unpublished agent id. The factory holds
* this object across asynchronous setup; while it is live, ordinary public
* registration of that id fails, so setup cannot publish the factory's agent
* (or a replacement with the same id) ahead of the transaction. Callers obtain
* handles only from {@link AgentRegistry.reserve}.
*/
export interface AgentRegistrationReservation {
/** The reserved registry id. */
readonly id: AgentId
/**
* Release the unpublished reservation; idempotent. The registry also
* releases it automatically when the fiber that called `reserve` disposes.
* This function is that exact Cordis effect disposer, so an ordered
* lifecycle may yield it by identity and place release after quiescence.
* @returns nothing.
*/
release(): void
/** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */
interface FactorySlot {
readonly target: AgentFactory
}
/**
@@ -248,22 +201,9 @@ export interface AgentRegistrationReservation {
* {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map<AgentId, Agent>()
/** Ids claimed across caller-code boundaries before their exact entry commits. */
private enteringIds = new Set<AgentId>()
/** The one accepted registry key for each live agent; never reread caller state. */
private acceptedIds = new WeakMap<Agent, AgentId>()
/** Unpublished identities held across factory setup/load transactions. */
private reservations = new Map<AgentId, AgentRegistrationReservation>()
/** Entries whose `agent/created` announcement phase began. */
private announced = new WeakSet<Agent>()
/** Entries currently dispatching `agent/created`; detach waits for that dispatch to unwind. */
private announcing = new WeakSet<Agent>()
/** A detach requested reentrantly from `agent/created`. */
private pendingDetach = new WeakSet<Agent>()
/** Stable lifecycle dispatch carrier captured before an entry commits. */
private carriers = new WeakMap<Agent, Scoped<Agent>>()
private factory: AcceptedAgentFactory | typeof ACCEPTING_FACTORY | undefined
private store = new Map<AgentId, AgentEntry>()
private entries = new WeakMap<Agent, AgentEntry>()
private factory: FactorySlot | undefined
constructor(ctx: Context) {
super(ctx, 'agents')
@@ -276,41 +216,13 @@ export class AgentRegistry extends Service {
ctx.accessor('agent', { get: () => undefined })
}
/**
* Reserve an unpublished agent id. Registration through {@link register} or
* bare {@link enter} fails until the returned capability is released; the
* owning factory passes the exact capability back to `enter` at publication.
* This makes “setup cannot publish” structural rather than a cooperative
* convention, including attempts to register a different object under the
* reserved id. The reservation belongs to the calling fiber and is released
* automatically if that owner unloads before the transaction settles.
* @param id - the id the factory transaction will publish.
* @returns the opaque reservation capability.
* @throws if the id is malformed, live, or already reserved.
*/
reserve(id: AgentId): AgentRegistrationReservation {
if (typeof id !== 'string') throw new TypeError('agent id must be a string')
if (this.store.has(id) || this.reservations.has(id) || this.enteringIds.has(id)) {
throw new Error(`agent "${id}" is already registered or reserved`)
}
const rawRelease = (): void => {
this.reservations.delete(id)
}
// `release` is the exact effect disposer. A composite lifecycle can yield
// it by identity, moving automatic owner cleanup from a racing sibling to
// the transaction's final ordered position.
const release = this.ctx.effect(() => rawRelease, `agents.reserve(${id})`)
const reservation: AgentRegistrationReservation = Object.freeze({ id, release })
this.reservations.set(id, reservation)
return reservation
}
/**
* Register the agent-creation factory (the loop calls this on construction,
* effect-scoped). The registry captures both callback identities once and
* later invokes them against the retained target receiver. Throws if a
* factory is already registered. Returns the disposer; on dispose the
* factory slot is cleared.
* effect-scoped). A traced Cordis service is canonicalized to its concrete
* target; each create/resume call is then traced through that caller's
* context so ownership follows the caller without stacking proxy layers.
* Throws if a factory is already registered. Returns the disposer; on
* dispose the factory slot is cleared.
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
* @returns the disposer that clears the factory slot. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
@@ -319,17 +231,11 @@ export class AgentRegistry extends Service {
setFactory(factory: AgentFactory): () => Promise<void> | void {
const dispose = this.ctx.effect(() => {
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
// Claim the slot before reading caller-controlled method accessors. A
// getter may synchronously re-enter setFactory(); it must observe the
// registration in progress instead of installing a nested factory that
// the outer call would silently overwrite.
this.factory = ACCEPTING_FACTORY
try {
this.factory = acceptAgentFactory(factory)
} catch (error: unknown) {
this.factory = undefined
throw error
}
// Avoid stacking two Cordis shadow layers when a caller passes a Service
// already read through a context. Calls are re-traced through their
// actual owner context below.
const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory
this.factory = { target }
return () => { this.factory = undefined }
}, 'agents.setFactory()')
// The exact cordis effect disposer (the agents.register() convention): a
@@ -339,11 +245,10 @@ export class AgentRegistry extends Service {
return dispose
}
/** Return the accepted factory, excluding absence and reentrant acceptance. */
private requireFactory(): AcceptedAgentFactory {
const accepted = this.factory
if (accepted === undefined || accepted === ACCEPTING_FACTORY) throw new Error(NO_FACTORY_MESSAGE)
return accepted
/** Return the active creation factory. */
private requireFactory(): FactorySlot {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory
}
/**
@@ -356,14 +261,15 @@ export class AgentRegistry extends Service {
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async create(options: CreateAgentOptions): Promise<AgentHandle> {
const accepted = this.requireFactory()
const ownerCtx = this.ctx
// Re-trace a Service-backed factory through the accessing context
// explicitly. This preserves AgentLoop's dependency origin while binding
// its effects to ownerCtx; plain factories receive ownerCtx as an explicit
// capability and need no Cordis tracker magic.
const receiver = getTraceable(ownerCtx, accepted.target)
return Reflect.apply(accepted.createAgent, receiver, [ownerCtx, options])
const { target } = this.requireFactory()
const receiver = getTraceable(ownerCtx, target)
// eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
return Reflect.apply(target.createAgent, receiver, [ownerCtx, options])
}
/**
@@ -374,10 +280,11 @@ export class AgentRegistry extends Service {
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
const accepted = this.requireFactory()
const ownerCtx = this.ctx
const receiver = getTraceable(ownerCtx, accepted.target)
return Reflect.apply(accepted.resume, receiver, [ownerCtx, options])
const { target } = this.requireFactory()
const receiver = getTraceable(ownerCtx, target)
// eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
return Reflect.apply(target.resume, receiver, [ownerCtx, options])
}
/**
@@ -413,74 +320,27 @@ export class AgentRegistry extends Service {
* returned detach closure into its pre-installed composite teardown before
* calling {@link announce}. Ordinary callers use {@link register}.
* @param agent - the prepared, unpublished agent.
* @param reservation - the exact unpublished-id capability, when a factory
* reserved this id across setup.
* @returns an idempotent closure that removes this exact entry and emits
* `agent/disposed` with listener failures contained. When called from a
* synchronous `agent/created` listener, removal and disposal wait until
* that creation dispatch unwinds.
*/
enter(agent: Agent, reservation?: AgentRegistrationReservation): () => void {
enter(agent: Agent): () => void {
const id = agent.id
if (typeof id !== 'string') throw new TypeError('agent id must be a string')
const held = this.reservations.get(id)
if (reservation === undefined) {
if (held !== undefined) throw new Error(`agent "${id}" is reserved for unpublished creation`)
} else if (reservation.id !== id || held !== reservation) {
throw new Error(`agent "${id}" registration reservation is not active for this id`)
const carrier = scopeTarget(agent, agent)
// This is the authoritative collision boundary. Concurrent create/resume
// operations may both prepare, but only one exact entry can publish.
if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`)
const entry: AgentEntry = {
id,
agent,
carrier,
announced: false,
announcing: false,
detachRequested: false,
}
if (this.acceptedIds.has(agent)) {
throw new Error(`agent "${id}" is already registered`)
}
if (this.store.has(id) || this.enteringIds.has(id)) {
throw new Error(`agent "${id}" is already registered`)
}
this.enteringIds.add(id)
let carrier: Scoped<Agent>
try {
// Registration accepts ownership of the public identity contract. Pin an
// own data slot from the one captured value so a custom JavaScript Agent
// with a getter or writable field cannot later present a different id to
// event listeners while the registry still owns the accepted key.
try {
Object.defineProperty(agent, 'id', {
value: id,
enumerable: true,
writable: false,
configurable: false,
})
} catch {
// Only the engine's property-definition failure is normalized; filter
// construction below retains its own precise failure.
throw new TypeError('agent id must be installable as a stable own property')
}
// Capture one carrier for the paired lifecycle edges. Constructing it
// reads a custom Agent's Context.filter and is therefore caller code;
// the id claim above makes a same-id reentrant enter lose deterministically.
carrier = scopeTarget(agent, agent)
} finally {
// Kept through the entire caller-code window; the final commit below is
// synchronous and callback-free.
this.enteringIds.delete(id)
}
const currentReservation = this.reservations.get(id)
if (reservation === undefined) {
/* v8 ignore next 2 -- reserve() rejects enteringIds, so no callback in
* carrier construction can install a new same-id reservation */
if (currentReservation !== undefined) {
throw new Error(`agent "${id}" is reserved for unpublished creation`)
}
} else if (currentReservation !== reservation) {
throw new Error(`agent "${id}" registration reservation is not active for this id`)
}
/* v8 ignore next 2 -- the enteringIds claim blocks every public same-id
* commit until this callback-free final check has completed */
if (this.acceptedIds.has(agent) || this.store.has(id)) {
throw new Error(`agent "${id}" is already registered`)
}
this.store.set(id, agent)
this.acceptedIds.set(agent, id)
this.carriers.set(agent, carrier)
this.store.set(id, entry)
this.entries.set(agent, entry)
let entered = true
const detach = (): void => {
if (!entered) return
@@ -490,49 +350,42 @@ export class AgentRegistry extends Service {
// the advanced detach capability, so make that ordering structural:
// visibility and the paired disposal are deferred until announce()'s
// synchronous dispatch has unwound.
if (this.announcing.has(agent)) {
this.pendingDetach.add(agent)
if (entry.announcing) {
entry.detachRequested = true
return
}
this.detachEntered(agent, id)
this.detachEntered(entry)
}
return detach
}
/** Remove one exact entered agent and emit its paired disposal when announced. */
private detachEntered(agent: Agent, id: AgentId): void {
this.pendingDetach.delete(agent)
private detachEntered(entry: AgentEntry): void {
entry.detachRequested = false
// A stale capability can never delete a later same-id lifecycle. The
// commit claim prevents this mismatch in normal operation; retain the
// exact-object guard as the final identity boundary.
/* v8 ignore next 1 -- the commit claim makes replacement impossible; this
* remains the exact-identity backstop against future mutation paths */
if (this.store.get(id) !== agent || this.acceptedIds.get(agent) !== id) return
this.store.delete(id)
this.acceptedIds.delete(agent)
const carrier = this.carriers.get(agent)
this.carriers.delete(agent)
// captured entry identity is the final boundary.
if (this.store.get(entry.id) !== entry) return
this.store.delete(entry.id)
this.entries.delete(entry.agent)
// 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
/* v8 ignore next -- enter commits the carrier with the exact store entry */
if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`)
this.emitDisposed(agent, carrier, id)
if (!entry.announced) return
this.emitDisposed(entry)
}
/** Emit the paired disposal edge through the entry's stable carrier. */
private emitDisposed(agent: Agent, carrier: Scoped<Agent>, id: AgentId): void {
const args: unknown[] = [carrier, 'agent/disposed', agent]
private emitDisposed(entry: AgentEntry): void {
const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`agent "${id}": agent/disposed listener rejected: ${renderThrown(error)}`)
this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener rejected: ${String(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/disposed listener threw: ${renderThrown(error)}`)
this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener threw: ${String(error)}`)
}
}
}
@@ -545,21 +398,18 @@ export class AgentRegistry extends Service {
* creation listener).
*/
announce(agent: Agent): void {
const id = this.acceptedIds.get(agent)
if (id === undefined || this.store.get(id) !== agent) {
throw new Error(`agent "${id ?? '<unknown>'}" is not live in this registry`)
const entry = this.entries.get(agent)
if (entry === undefined || this.store.get(entry.id) !== entry) {
throw new Error(`agent "${agent.id}" is not live in this registry`)
}
if (this.announced.has(agent) || this.announcing.has(agent)) {
throw new Error(`agent "${id}" was already announced`)
if (entry.announced || entry.announcing) {
throw new Error(`agent "${entry.id}" was already announced`)
}
const carrier = this.carriers.get(agent)
/* v8 ignore next -- enter commits the carrier with the exact store entry */
if (carrier === undefined) throw new Error(`agent "${id}" has no dispatch carrier`)
// Mark before dispatch so a listener cannot recursively create a second
// lifecycle edge; detach still pairs a partially delivered first edge.
this.announcing.add(agent)
this.announced.add(agent)
const args: unknown[] = [carrier, 'agent/created', agent]
entry.announcing = true
entry.announced = true
const args: unknown[] = [entry.carrier, 'agent/created', entry.agent]
try {
for (const callback of this.ctx.events.dispatch('emit', args)) {
// A synchronous creation failure vetoes publication and rolls back.
@@ -567,12 +417,12 @@ export class AgentRegistry extends Service {
// observe and report it instead of leaking an unhandled rejection.
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`agent "${id}": agent/created listener rejected: ${renderThrown(error)}`)
this.ctx.logger.warn(`agent "${entry.id}": agent/created listener rejected: ${String(error)}`)
})
}
} finally {
this.announcing.delete(agent)
if (this.pendingDetach.has(agent)) this.detachEntered(agent, id)
entry.announcing = false
if (entry.detachRequested) this.detachEntered(entry)
}
}
@@ -582,7 +432,7 @@ export class AgentRegistry extends Service {
* @returns the agent, or undefined when no live agent has that id.
*/
get(id: AgentId): Agent | undefined {
return this.store.get(id)
return this.store.get(id)?.agent
}
/**
@@ -590,7 +440,7 @@ export class AgentRegistry extends Service {
* @returns a fresh array; mutating it does not affect the registry.
*/
list(): Agent[] {
return [...this.store.values()]
return [...this.store.values()].map(entry => entry.agent)
}
}

View File

@@ -294,10 +294,10 @@ declare module 'cordis' {
// ---- lifecycle (emit) ----
/**
* 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. A synchronous listener throw
* {@link AgentRegistry}. Its session is already live in the session store.
* Setup is composition-only by contract; the subsequent
* `agent/session-start` boundary is the first supported place to inject or
* queue startup work. A synchronous listener throw
* vetoes publication and rollback emits the matching disposal edges;
* returned-promise rejection is observed and logged but cannot
* retroactively veto this synchronous boundary. A synchronous listener
@@ -346,9 +346,7 @@ declare module 'cordis' {
/**
* A message entered the agent's inbox (queued or steering). Content and the
* resolved source are the detached, deeply-frozen values retained by the
* inbox; the `info` wrapper is frozen too, so one listener cannot rewrite
* what another listener observes. `source` has defaults applied and is not
* the caller's raw options.
* inbox. `source` has defaults applied and is not the caller's raw options.
* @param agent - the agent whose inbox received the message.
* @param content - the accepted content blocks retained by the inbox.
* @param info - the accepted source plus whether it entered as steering.

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Context, Service, symbols } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Agent, AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
const id = AgentId(rawId)
@@ -11,8 +11,6 @@ function stubAgent(rawId: string): Agent {
options: {},
session: new Session(SessionId(`${id}-session`)),
status: 'idle',
// A bare context stands in for the agent scope: registry tests never
// register through it, they only need the field present.
ctx: new Context(),
send() {},
steer() {},
@@ -23,382 +21,126 @@ function stubAgent(rawId: string): Agent {
}
describe('AgentRegistry', () => {
it('registers agents and emits created/disposed events', async () => {
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const created: string[] = []
const disposed: string[] = []
ctx.on('agent/created', agent => void created.push(agent.id))
ctx.on('agent/disposed', agent => void disposed.push(agent.id))
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
const agent = stubAgent('a1')
const dispose = ctx.agents.register(agent)
expect(created).toEqual(['a1'])
expect(ctx.agents.get(AgentId('a1'))).toBe(agent)
expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.agents.list()).toEqual([agent])
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
await dispose()
expect(disposed).toEqual(['a1'])
expect(ctx.agents.get(AgentId('a1'))).toBeUndefined()
expect(ctx.agents.get(agent.id)).toBeUndefined()
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
})
it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => {
it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
ctx.agents.register(stubAgent('main'))
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('already registered')
const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/created', () => { throw new Error('creation veto') })
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.agents.register(stubAgent('scoped'))
}, { inject: ['agents'] }))
expect(ctx.agents.list().map(a => a.id)).toEqual(['main', 'scoped'])
await fiber.dispose()
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined()
expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
})
it('rolls back the agent entry when an agent/created listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let threw = false
ctx.on('agent/created', () => {
if (!threw) { threw = true; throw new Error('boom created listener') }
})
// The throwing emit must roll the entry back, not leak it.
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener')
expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked
// A subsequent listener-free register of the SAME id succeeds and is
// tracked exactly once (the duplicate-id check is not wedged).
const dispose = ctx.agents.register(stubAgent('main'))
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
await dispose()
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
})
it('observes async agent/created rejection without rolling back or starving peers', async () => {
it('contains asynchronous creation rejection and every disposal-listener failure', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } }
const heard: string[] = []
ctx.on('agent/created', () => Promise.reject(new Error('ordinary async failure')) as never)
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- hostile thrown values are the boundary under test
ctx.on('agent/created', () => Promise.reject(hostile) as never)
ctx.on('agent/created', (agent) => { heard.push(agent.id) })
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
ctx.on('agent/disposed', agent => void heard.push(agent.id))
const agent = stubAgent('async-created')
const dispose = ctx.agents.register(agent)
const dispose = ctx.agents.register(stubAgent('contained'))
await Promise.resolve()
await Promise.resolve()
expect(ctx.agents.get(agent.id)).toBe(agent)
expect(heard).toEqual(['async-created'])
expect(warnings).toEqual([
'agent "async-created": agent/created listener rejected: Error: ordinary async failure',
'agent "async-created": agent/created listener rejected: <unrenderable thrown value>',
])
await dispose()
await Promise.resolve()
expect(heard).toEqual(['contained'])
expect(warnings).toEqual([
'agent "contained": agent/created listener rejected: Error: created async',
'agent "contained": agent/disposed listener threw: Error: disposed sync',
'agent "contained": agent/disposed listener rejected: Error: disposed async',
])
})
it('splits insertion from announcement and makes the detach exact/idempotent', async () => {
it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', 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 lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
const first = stubAgent('split')
const detachFirst = ctx.agents.enter(first)
expect(ctx.agents.get(first.id)).toBe(first)
expect(created).toEqual([])
expect(lifecycle).toEqual([])
ctx.agents.announce(first)
expect(created).toEqual([first])
expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
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])
expect(lifecycle).toEqual(['created:split', 'disposed:split'])
})
it('captures and pins one runtime id before insertion, announcement, and detach', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const existing = stubAgent('occupied')
const disposeExisting = ctx.agents.register(existing)
const candidate = stubAgent('placeholder')
let reads = 0
Object.defineProperty(candidate, 'id', {
configurable: true,
get() {
reads += 1
return reads === 1 ? AgentId('accepted') : AgentId('occupied')
},
})
const detach = ctx.agents.enter(candidate)
expect(reads).toBe(1)
expect(candidate.id).toBe('accepted')
expect(reads).toBe(1)
expect(Object.getOwnPropertyDescriptor(candidate, 'id')).toMatchObject({
configurable: false,
writable: false,
value: 'accepted',
})
expect(ctx.agents.get(AgentId('accepted'))).toBe(candidate)
expect(ctx.agents.get(AgentId('occupied'))).toBe(existing)
expect(() => ctx.agents.enter(candidate)).toThrow(/already registered/)
ctx.agents.announce(candidate)
detach()
expect(ctx.agents.get(AgentId('accepted'))).toBeUndefined()
expect(ctx.agents.get(AgentId('occupied'))).toBe(existing)
await disposeExisting()
expect(() => ctx.agents.enter({ ...stubAgent('bad'), id: 42 } as unknown as Agent))
.toThrow(/id must be a string/)
const pinnedAccessor = stubAgent('pinned')
Object.defineProperty(pinnedAccessor, 'id', {
configurable: false,
get: () => AgentId('pinned'),
})
expect(() => ctx.agents.enter(pinnedAccessor)).toThrow(/installable as a stable own property/)
})
it('claims an id across a Proxy defineProperty trap before committing the exact entry', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const id = AgentId('reentrant-enter')
const nested = stubAgent(id)
let nestedError = ''
let attempted = false
const target = stubAgent(id)
const outer = new Proxy(target, {
defineProperty(inner, property, descriptor) {
if (property === 'id' && !attempted) {
attempted = true
try {
ctx.agents.enter(nested)
} catch (error: unknown) {
nestedError = String(error)
}
}
return Reflect.defineProperty(inner, property, descriptor)
},
})
const detach = ctx.agents.enter(outer)
expect(nestedError).toMatch(/already registered/)
expect(ctx.agents.get(id)).toBe(outer)
detach()
expect(ctx.agents.get(id)).toBeUndefined()
})
it('captures one lifecycle carrier before commit so a filter getter cannot invert edges', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const events: string[] = []
const agent = stubAgent('reentrant-carrier')
let detach = (): void => {}
Object.defineProperty(agent, Context.filter, {
configurable: true,
get() {
events.push('filter-getter')
detach()
return undefined
},
})
detach = ctx.agents.enter(agent)
ctx.on('agent/created', () => { events.push('created') })
ctx.on('agent/disposed', () => { events.push('disposed') })
ctx.agents.announce(agent)
expect(events).toEqual(['filter-getter', 'created'])
expect(ctx.agents.get(agent.id)).toBe(agent)
detach()
expect(events).toEqual(['filter-getter', 'created', 'disposed'])
expect(ctx.agents.get(agent.id)).toBeUndefined()
})
it('revalidates an exact reservation after carrier construction runs caller code', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const reservation = ctx.agents.reserve(AgentId('released-during-enter'))
const agent = stubAgent('released-during-enter')
Object.defineProperty(agent, Context.filter, {
configurable: true,
get() {
reservation.release()
return undefined
},
})
expect(() => ctx.agents.enter(agent, reservation)).toThrow(/reservation is not active/)
expect(ctx.agents.get(agent.id)).toBeUndefined()
})
it('observes an async agent/disposed rejection through the stable carrier', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
ctx.on('agent/disposed', () => Promise.reject(new Error('late disposal failure')) as never)
const agent = stubAgent('async-disposed')
const detach = ctx.agents.enter(agent)
ctx.agents.announce(agent)
detach()
await Promise.resolve()
await Promise.resolve()
expect(warnings).toEqual([
'agent "async-disposed": agent/disposed listener rejected: Error: late disposal failure',
])
})
it('uses an opaque one-id reservation to gate unpublished factory insertion', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const held = ctx.agents.reserve(AgentId('held'))
expect(() => ctx.agents.reserve(AgentId('held'))).toThrow(/already registered or reserved/)
expect(() => ctx.agents.enter(stubAgent('held'))).toThrow(/reserved for unpublished creation/)
const other = ctx.agents.reserve(AgentId('other'))
expect(() => ctx.agents.enter(stubAgent('held'), other)).toThrow(/not active for this id/)
const agent = stubAgent('held')
const detach = ctx.agents.enter(agent, held)
ctx.agents.announce(agent)
held.release()
held.release()
expect(ctx.agents.get(AgentId('held'))).toBe(agent)
expect(() => ctx.agents.reserve(AgentId('held'))).toThrow(/already registered or reserved/)
detach()
other.release()
const expired = ctx.agents.reserve(AgentId('expired'))
expired.release()
expect(() => ctx.agents.enter(stubAgent('expired'), expired)).toThrow(/not active for this id/)
expect(() => ctx.agents.reserve(42 as unknown as AgentId)).toThrow(/id must be a string/)
})
it('owns reservations by the calling fiber and rolls back failed ownership registration', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let held!: import('@deepseek-ai/dsh-agent').AgentRegistrationReservation
let scopedAgents!: AgentRegistry
const owner = await ctx.plugin(Object.assign((inner: Context) => {
scopedAgents = inner.agents
held = inner.agents.reserve(AgentId('fiber-held'))
}, { inject: ['agents'] }))
expect(() => ctx.agents.reserve(AgentId('fiber-held'))).toThrow(/already registered or reserved/)
await owner.dispose()
const reused = ctx.agents.reserve(AgentId('fiber-held'))
reused.release()
held.release() // idempotent after the automatic owner-disposal release
// A disposed tracker cannot own a new effect. The failed effect install
// must remove the map entry it tentatively reserved before propagating.
expect(() => scopedAgents.reserve(AgentId('inactive-owner'))).toThrow(/inactive context/)
const recovered = ctx.agents.reserve(AgentId('inactive-owner'))
recovered.release()
})
it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let created = 0
let disposed = 0
let reentrantError = ''
ctx.on('agent/created', (agent) => {
created += 1
try {
ctx.agents.announce(agent)
} catch (error: unknown) {
reentrantError = String(error)
}
})
ctx.on('agent/disposed', () => { disposed += 1 })
const agent = stubAgent('once')
const detach = ctx.agents.enter(agent)
ctx.agents.announce(agent)
expect(reentrantError).toMatch(/already announced/)
expect(() => { ctx.agents.announce(agent) }).toThrow(/already announced/)
detach()
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
})
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
it('defers detach requested by a creation listener until that dispatch unwinds', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const order: string[] = []
const agent = stubAgent('reentrant-detach')
const detach = ctx.agents.enter(agent)
ctx.on('agent/created', (created) => {
order.push('created:first')
const agent = stubAgent('reentrant')
ctx.on('agent/created', () => {
order.push(`first:${ctx.agents.get(agent.id) === agent}`)
detach()
expect(ctx.agents.get(created.id)).toBe(created)
order.push(`after-detach:${ctx.agents.get(agent.id) === agent}`)
})
ctx.on('agent/created', (created) => {
order.push('created:second')
expect(ctx.agents.get(created.id)).toBe(created)
})
ctx.on('agent/disposed', (disposed) => {
order.push('disposed')
expect(ctx.agents.get(disposed.id)).toBeUndefined()
})
ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
ctx.on('agent/disposed', () => void order.push('disposed'))
const detach = ctx.agents.enter(agent)
ctx.agents.announce(agent)
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
expect(ctx.agents.get(agent.id)).toBeUndefined()
detach()
})
})
describe('agentEvents()', () => {
it('contains synchronous throws and returned-promise rejections per listener', async () => {
it('contains each synchronous throw and returned-promise rejection', async () => {
const ctx = new Context()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const agent = stubAgent('contained')
const heard: string[] = []
const hostile = { [Symbol.toPrimitive]() { throw new Error('cannot stringify') } }
ctx.on('agent/status', () => { throw hostile })
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const agent = stubAgent('event')
ctx.on('agent/status', () => { throw new Error('sync listener') })
ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
ctx.on('agent/status', (_subject, status) => { heard.push(status) })
ctx.on('agent/status', (_agent, status) => void heard.push(status))
expect(() => { agentEvents(ctx, agent).emit('agent/status', 'running') }).not.toThrow()
agentEvents(ctx, agent).emit('agent/status', 'running')
await Promise.resolve()
await Promise.resolve()
expect(heard).toEqual(['running'])
expect(warnings).toEqual([
'agent event "agent/status" listener threw: <unrenderable thrown value>',
'agent event "agent/status" listener threw: Error: sync listener',
'agent event "agent/status" listener rejected: Error: async listener',
])
})
})
describe('AgentRegistry factory seam', () => {
/** A stub AgentFactory that records calls and returns a stub agent. */
function stubFactory() {
const calls: {
create: Array<{ ownerCtx: Context; options: CreateAgentOptions }>
@@ -409,198 +151,44 @@ describe('AgentRegistry factory seam', () => {
calls.create.push({ ownerCtx, options })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
},
resume(ownerCtx, options) {
async resume(ownerCtx, options) {
calls.resume.push({ ownerCtx, options })
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
},
}
return { factory, calls }
}
it('create()/resume() throw when no factory is registered', async () => {
it('requires a factory and delegates through the calling context', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
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/)
})
it('setFactory registers a factory; create/resume delegate to it', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
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).toHaveLength(1)
expect(calls.create[0]!.ownerCtx.fiber).toBe(ctx.fiber)
expect(calls.create[0]!.options)
.toEqual({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
expect(resumed.agent.id).toBe('r1')
expect(calls.resume).toHaveLength(1)
expect(calls.resume[0]!.ownerCtx.fiber).toBe(ctx.fiber)
expect(calls.resume[0]!.options).toEqual({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
})
it('passes the calling fiber to a plain factory for create and resume ownership', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
let callerFiber: Context['fiber'] | undefined
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
await ctx.plugin(Object.assign(async (inner: Context) => {
callerFiber = inner.fiber
await inner.agents.create({ agentId: AgentId('owned-create'), sessionId: SessionId('owned-session') })
await inner.agents.resume({ agentId: AgentId('owned-resume'), resumeSessionId: SessionId('persisted') })
await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
}, { inject: ['agents'] }))
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber)
})
expect(calls.create[0]!.ownerCtx.fiber).toBe(callerFiber)
expect(calls.resume[0]!.ownerCtx.fiber).toBe(callerFiber)
it('rejects a second factory and clears the slot with its owner (HMR)', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const owner = await ctx.plugin(Object.assign((inner: Context) => {
inner.agents.setFactory(stubFactory().factory)
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
}, { inject: ['agents'] }))
await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined()
await owner.dispose()
await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
})
it('captures factory callbacks once while retaining the intentional target receiver', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const reads = { create: 0, resume: 0 }
const receivers: unknown[] = []
const replacements: string[] = []
const target = { label: 'accepted-target' } as { label: string } & AgentFactory
Object.defineProperties(target, {
createAgent: {
configurable: true,
get() {
reads.create += 1
return function (this: typeof target, _ownerCtx: Context, options: CreateAgentOptions) {
receivers.push(this)
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
}
},
},
resume: {
configurable: true,
get() {
reads.resume += 1
return function (this: typeof target, _ownerCtx: Context, options: ResumeAgentOptions) {
receivers.push(this)
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
}
},
},
})
ctx.agents.setFactory(target)
Object.defineProperties(target, {
createAgent: {
value: () => {
replacements.push('create')
return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() })
},
},
resume: {
value: () => {
replacements.push('resume')
return Promise.resolve({ agent: stubAgent('replacement'), dispose: () => Promise.resolve() })
},
},
})
await ctx.agents.create({ agentId: AgentId('captured-create'), sessionId: SessionId('captured-session') })
await ctx.agents.resume({ agentId: AgentId('captured-resume'), resumeSessionId: SessionId('captured-persisted') })
expect(reads).toEqual({ create: 1, resume: 1 })
expect(receivers).toEqual([target, target])
expect(replacements).toEqual([])
})
it('reserves the factory slot before reading reentrant callback accessors', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const nested = stubFactory().factory
const reads: string[] = []
const reentrantCreate: Promise<unknown>[] = []
const target = {} as AgentFactory
Object.defineProperties(target, {
createAgent: {
get() {
reads.push('createAgent')
expect(() => ctx.agents.setFactory(nested)).toThrow(/already registered/)
reentrantCreate.push(ctx.agents.create({
agentId: AgentId('during-acceptance'),
sessionId: SessionId('during-acceptance-session'),
}))
return (_ownerCtx: Context, options: CreateAgentOptions) => Promise.resolve({
agent: stubAgent(options.agentId),
dispose: () => Promise.resolve(),
})
},
},
resume: {
get() {
reads.push('resume')
return (_ownerCtx: Context, options: ResumeAgentOptions) => Promise.resolve({
agent: stubAgent(options.agentId),
dispose: () => Promise.resolve(),
})
},
},
})
ctx.agents.setFactory(target)
expect(reentrantCreate).toHaveLength(1)
await expect(Promise.all(reentrantCreate)).rejects.toThrow(/no agent factory/)
await expect(ctx.agents.create({
agentId: AgentId('after-acceptance'),
sessionId: SessionId('after-acceptance-session'),
})).resolves.toMatchObject({ agent: { id: 'after-acceptance' } })
expect(reads).toEqual(['createAgent', 'resume'])
})
it('validates the complete factory shape when accepting it', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
expect(() => ctx.agents.setFactory(null as unknown as AgentFactory)).toThrow(/non-null object or function/)
expect(() => ctx.agents.setFactory(42 as unknown as AgentFactory)).toThrow(/non-null object or function/)
expect(() => ctx.agents.setFactory({ resume() { return Promise.resolve() } } as unknown as AgentFactory))
.toThrow(/createAgent must be a function/)
expect(() => ctx.agents.setFactory({ createAgent() { return Promise.resolve() } } as unknown as AgentFactory))
.toThrow(/resume must be a function/)
const callable = Object.assign(() => undefined, stubFactory().factory)
const dispose = ctx.agents.setFactory(callable)
await expect(ctx.agents.create({ agentId: AgentId('callable'), sessionId: SessionId('callable-session') }))
.resolves.toBeDefined()
await dispose()
})
it('setFactory rejects a second factory', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
ctx.agents.setFactory(stubFactory().factory)
expect(() => ctx.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
})
it('disposing the setFactory fiber clears the factory (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let dispose!: () => Promise<void> | void
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
dispose = inner.agents.setFactory(stubFactory().factory)
}, { inject: ['agents'] }))
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).resolves.toBeDefined()
void dispose
await fiber.dispose()
// factory slot cleared → create throws again
await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/)
})
it('canonicalizes an already traced Service factory before caller retracing', async () => {
it('canonicalizes an already traced Service before tracing it for the caller', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const states = new WeakMap<object, string[]>()
@@ -609,71 +197,27 @@ describe('AgentRegistry factory seam', () => {
super(inner, 'tracedFactory')
states.set(this, [])
}
private calls(): string[] {
const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this
const calls = states.get(original)
if (calls === undefined) throw new Error('factory receiver did not canonicalize to the raw service')
if (calls === undefined) throw new Error('factory receiver was not canonicalized')
return calls
}
createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
this.calls().push('create')
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
}
resume(_ownerCtx: Context, options: ResumeAgentOptions) {
async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
this.calls().push('resume')
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
}
}
await ctx.plugin(TracedFactory)
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
ctx.agents.setFactory(traced)
await ctx.agents.create({ agentId: AgentId('traced-create'), sessionId: SessionId('traced-session') })
await ctx.agents.resume({ agentId: AgentId('traced-resume'), resumeSessionId: SessionId('traced-persisted') })
await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
expect(states.get(raw!)).toEqual(['create', 'resume'])
})
it('rolls back register and factory acceptance when their owner unloads reentrantly', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let ownerCtx!: Context
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
const agent = stubAgent('register-unload-race')
ctx.on('agent/created', (created) => {
if (created === agent) void owner.dispose()
})
ownerCtx.agents.register(agent)
await owner.dispose()
expect(ctx.agents.get(agent.id)).toBeUndefined()
let factoryOwnerCtx!: Context
const factoryOwner = await ctx.plugin(Object.assign((inner: Context) => { factoryOwnerCtx = inner }, { inject: ['agents'] }))
const target = {} as AgentFactory
Object.defineProperties(target, {
createAgent: {
get() {
void factoryOwner.dispose()
return (_inner: Context, options: CreateAgentOptions) => Promise.resolve({
agent: stubAgent(options.agentId),
dispose: () => Promise.resolve(),
})
},
},
resume: {
value: (_inner: Context, options: ResumeAgentOptions) => Promise.resolve({
agent: stubAgent(options.agentId),
dispose: () => Promise.resolve(),
}),
},
})
factoryOwnerCtx.agents.setFactory(target)
await factoryOwner.dispose()
await expect(ctx.agents.create({ agentId: AgentId('after-owner'), sessionId: SessionId('after-owner-s') }))
.rejects.toThrow(/no agent factory/)
})
})