fix(core): enforce agent-scoped ownership boundaries
This commit is contained in:
62 files changed
+3982
-857
No files matched your search
@@ -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' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user