Merge remote-tracking branch 'origin/master' into codex/project-instruction-files

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	docs/rfc/implemented/feature/2026-06-15-code-mode.md
#	docs/rfc/implemented/feature/2026-06-30-interception-seams.md
#	docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
#	docs/tool-execution-pipeline.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-core/tests/agent-core.spec.ts
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.md
#	packages/core/session/src/index.ts
#	packages/core/tools/README.md
#	packages/core/tools/src/index.ts
#	pnpm-lock.yaml
#	scripts/gen-doc-graphs.ts
#	scripts/type-equiv.manifest.json
This commit is contained in:
Yichen Jiang
2026-07-13 14:37:32 +08:00
243 changed files with 14538 additions and 4798 deletions

View File

@@ -8,14 +8,20 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-<uuid>` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
### Injected services
@@ -28,21 +34,23 @@ interface Config {
agents: Array<{
id: string // required
model?: string
resumeSessionId?: string // load this persisted session instead of creating one
cwd?: string // optional workspace cwd for the fresh session
}>
}
```
Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
### Classes
### Exported concrete class
- `ReactLoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
- `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`, `drainSteering`, `waitForQueued`).
- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy.
`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary.
### Loop lifecycle (`loop.ts`)
One invocation of `runLoop()` drives one agent for its whole lifetime:
The internal loop driver runs one agent for its whole lifetime:
```
create agent → emit agent/session-start(source) ⟵ once, before turn 1
@@ -55,7 +63,8 @@ forever:
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
STEP loop:
drain steering
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
assembly = await systemPrompt.assemble(assembleContextFor(agent))
⟵ renderPrompt(assembly) IS the full prompt
prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen
session prefix; on the header, never history
await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step;
@@ -68,29 +77,33 @@ forever:
message = waterfall agent/step-result
session('assistant/message')
each tool-call: session('tool/call')
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
→ tools.execute() [pre waterfall → monotonic guards → around dispatch → post waterfall → final notification]
→ session('tool/result')
append buffered deferred/post-execute additionalContexts as session('context/message')(s)
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation → ContinuationDecision
({action:'continue', reason?} records reason as next-step steering)
if action==stop (and no pending steering): break
pending steering can override an ordinary stop
terminal = serial agent/turn-stop → ContinuationStop | undefined
(after ordinary decision/reason/steering folding)
if terminal stop, or ordinary action==stop with no pending steering: break
session('turn/end')
await session/flush
re-enqueue leftover steering as queued
terminal turn: discard steering added before/during close and flush; keep ordinary queued sends
ordinary turn: re-enqueue leftover steering as queued
idle unless more queued
```
Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
Error containment: a throwing plugin ends the **turn**, never the loop. A throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.)
### What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/session-prefix`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` `tools/execute` `tools/post-execute` `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: `agent/pre-step`
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
- Persistence: `session/event` + `session/flush`
- UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`)

View File

@@ -11,7 +11,6 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
@@ -24,6 +23,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -37,6 +37,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",

View File

@@ -7,13 +7,89 @@
*/
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentId, AgentOptions, AgentStatus, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts'
import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
import { Inbox, type InboxMessage } from './inbox.ts'
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
/** 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')
/** Module-private quiescent stop, valid both before and after driver start. */
const stopDriver = Symbol('dsh.agent-loop.stop-driver')
/** Module-private context binding for the mutually referential agent scope. */
const bindContext = Symbol('dsh.agent-loop.bind-context')
/** Module-private publication marker. */
const publishAgent = Symbol('dsh.agent-loop.publish-agent')
/** Factory-owned controls that can operate only on the agent created with them. */
export interface PreparedReactLoopAgent {
/** The unpublished concrete agent. */
agent: ReactLoopAgent
/** Mark the agent public so teardown emits its status lifecycle. */
markPublished(): void
/** Stop the prepared instance even when publication has not started its loop. */
dispose(): Promise<void> | void
/**
* Start its driver after publication and session-start notification.
* The returned disposer reaches quiescence for both the loop and every
* fire-and-forget idle-injection flush the agent started.
*/
startDriver(): () => Promise<void> | 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 publish 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`)
}
const agent = new ReactLoopAgent(ctx, id, options, session)
claimedDriverSessions.add(session)
const dispose = () => agent[stopDriver]()
return {
agent,
markPublished: () => { agent[publishAgent]() },
dispose,
startDriver: () => {
agent[startDriver]()
return dispose
},
}
}
/**
* Install the concrete agent's scope context exactly once. Construction and
* scope minting are mutually referential (the scope key is the agent), so the
* factory performs this one post-construction binding before setup receives
* the unpublished agent. The module-private binding rejects a second bind.
* @param agent - the unpublished concrete agent to bind.
* @param ctx - its fully extended agent scope context.
*/
export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void {
agent[bindContext](ctx)
}
/**
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
*
@@ -22,14 +98,31 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
* the agent/* event taxonomy — plugins never need this class.
*/
export class ReactLoopAgent implements Agent {
/** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */
readonly #inbox = new Inbox()
/**
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
* the driver loop can drain it; {@link cancel} clears it wholesale.
* The agent's scope context ({@link Agent.ctx}), wired by the factory right
* after the scope is minted — before the agent is registered, announced, or
* driven, so no consumer can observe it unset. Definite-assignment (`!`)
* expresses that two-phase construction: the agent object and its scope
* context are mutually referential (the scope is keyed BY this agent), so
* neither can exist strictly before the other.
*/
readonly inbox = new Inbox()
private boundContext: Context | undefined
/** The agent's scoped composition context, bound once by its factory. */
get ctx(): Context {
if (this.boundContext === undefined) throw new Error(`agent "${this.id}" context is not bound`)
return this.boundContext
}
private _status: AgentStatus = 'idle'
private currentAbort: AbortController | undefined
/** Whether runLoop has been installed into {@link done}. */
private driverStarted = false
/** Whether registry publication began and status disposal is externally visible. */
private published = false
/**
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
* driver loop (via the LoopHandle) at every point a turn could start or
@@ -61,9 +154,15 @@ export class ReactLoopAgent implements Agent {
* the `disposed` transition fires and leave the promise hanging.
*/
private idleWaiters: (() => void)[] = []
/**
* Durability checkpoints started by idle {@link inject} calls. `inject()` is
* synchronous, so it cannot await them itself; the driver disposer drains
* this set before the lifecycle unregisters the agent or detaches its session.
*/
private pendingIdleFlushes = new Set<Promise<void>>()
constructor(
private ctx: Context,
private loopCtx: Context,
public readonly id: AgentId,
public readonly options: AgentOptions,
public readonly session: Session,
@@ -86,17 +185,13 @@ export class ReactLoopAgent implements Agent {
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
// not hang on one bad listener).
if (status !== 'running') this.settleIdleWaiters()
try {
this.ctx.emit('agent/status', this, status)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
}
agentEvents(this.loopCtx, this).emit('agent/status', status)
}
/**
* 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
@@ -108,23 +203,45 @@ export class ReactLoopAgent implements Agent {
return options?.source ?? { kind: 'user' }
}
send(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
/**
* Accept one public send/steer payload as the exact detached record shared by
* the live notification and inbox. Lossless-JSON materialization reads every
* nested field once; deep freeze prevents an observer from rewriting queued
* work before the loop drains it.
*/
private acceptInboxMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
const source = this.resolveSource(options)
this.inbox.enqueue({ content, source })
this.ctx.emit('agent/queued', this, content, { source, steering: false })
const accepted = snapshotJsonValue({ content, source })
if (accepted === undefined) {
throw new TypeError('agent message content and source must be losslessly JSON-serializable')
}
return deepFreeze(accepted)
}
/** Reject a driving operation once teardown has synchronously closed the agent. */
private assertNotDisposed(): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
}
send(content: ContentBlock[], options?: SendOptions): void {
this.assertNotDisposed()
const accepted = this.acceptInboxMessage(content, options)
this.#inbox.enqueue(accepted)
const info = { source: accepted.source, steering: false } as const
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
}
steer(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
this.assertNotDisposed()
if (this._status !== 'running') { this.send(content, options); return }
const source = this.resolveSource(options)
this.inbox.steer({ content, source })
this.ctx.emit('agent/queued', this, content, { source, steering: true })
const accepted = this.acceptInboxMessage(content, options)
this.#inbox.steer(accepted)
const info = { source: accepted.source, steering: true } as const
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
}
inject(content: ContentBlock[], options?: InjectOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
this.assertNotDisposed()
const source = this.resolveSource(options)
const context = {
content,
@@ -142,61 +259,49 @@ export class ReactLoopAgent implements Agent {
// No turn open: wrap the injection in a one-shot turn so every event stays
// turn-enclosed (the durability/replay boundary is the turn).
const turn = lastTurnNumber(this.session) + 1
// Once turn/start enters the log, a turn/end is OWED no matter what — even
// if a throwing `session/event` listener escapes from the turn/start append
// (Session.append pushes the event BEFORE notifying listeners) or the
// context/message append throws (non-serializable content, throwing
// listener). The finally re-checks the log via isTurnOpen() and closes the
// turn if one was actually opened, so the log never carries a permanently
// open injection turn that would corrupt later turns/replay. (If the
// turn/start append throws BEFORE pushing — non-serializable trigger, which
// can't happen for our fixed trigger — no turn was opened and none is owed.)
// Once turn/start enters the log, a turn/end is owed even if the message
// append fails acceptance or pre-commit validation. The finally re-checks
// the log and closes only a turn that actually opened; post-commit observers
// are contained by Session and cannot create a false append failure.
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
this.session.append('context/message', context, { surfaceOp: 'append' })
} finally {
// Close the turn if turn/start made it into the log. Contain a throwing
// turn/end listener: Session.append pushes before notifying, so a throw
// here still leaves turn/end in the log (the turn is balanced) — swallow
// it so it neither replaces the original exception nor skips the flush
// decision below. (It surfaces through the flush path is not needed; the
// turn-balance contract is what matters and it holds.)
// Close the turn if turn/start made it into the log. A pre-commit veto
// must escape rather than being mistaken for a committed turn/end.
if (isTurnOpen(this.session)) {
try {
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
} catch {
// turn/end is already in the log (pushed before the listener threw),
// so the turn is balanced; the throw is the listener's bug.
}
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
// Decide the durability checkpoint from the LOG, not a flag: a turn was
// recorded iff this turn's turn/start is logged (it may have been closed
// by a throwing-listener turn/end above, which still counts). A
// `turnRecorded` boolean set after append('turn/end') would be skipped by
// a throwing turn/end listener, losing the flush for a balanced in-memory
// turn (crash before the next turn/dispose would drop the idle injection).
// Decide the durability checkpoint from the log: an accepted one-shot
// turn must be flushed even when its message append was the failing step.
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
// Checkpoint the one-shot turn for durability, exactly as the loop does at
// every turn/end. The loop is NOT running (we are idle), so nothing else
// will flush this turn. Fire-and-forget with error containment: inject()
// is synchronous, and a persistence backend failing must not throw into
// the caller (e.g. a tool-bash task-done callback). Disposal still drains
// independently, so a slow flush is safe. A flush failure is reported via
// agent/error (step 0 — the idle-injection convention, there is no real
// step) AND the logger, mirroring the loop's post-turn/end flush path so
// plugins monitoring agent/error see idle-injection persistence failures
// too. A throwing agent/error listener is contained.
// independently, so a slow flush is safe. The task is tracked until it
// settles: driver disposal awaits every pending idle-injection checkpoint
// before unregistering the agent or detaching the session. A flush failure
// is reported via agent/error (step 0 — the idle-injection convention,
// there is no real step) AND the logger, mirroring the loop's post-turn/end
// flush path so plugins monitoring agent/error see idle-injection
// persistence failures too. A throwing agent/error listener is contained.
if (turnRecorded) {
void Promise.resolve(this.ctx.parallel('session/flush', this.session)).catch((error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error))
this.ctx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`)
try {
this.ctx.emit('agent/error', this, turn, 0, err)
} catch {
// contained: the failure is already logged; a throwing agent/error
// listener must not escape this fire-and-forget catch.
}
// Through the store's flush (the carrier owner), never a raw parallel.
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
const rendered = renderThrown(error)
const err = error instanceof Error ? error : new Error(rendered)
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
})
this.pendingIdleFlushes.add(flush)
// Attach the same retirement callback to both settlement arms so even a
// logger failure in the catch above cannot become an unhandled rejection.
// Teardown uses allSettled for the same reason: a reporting failure must
// not strand ownership.
const retire = (): void => { this.pendingIdleFlushes.delete(flush) }
void flush.then(retire, retire)
}
}
}
@@ -211,7 +316,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
@@ -222,7 +327,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).
@@ -240,12 +345,12 @@ export class ReactLoopAgent implements Agent {
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
* actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
* quiescence-observation hook, distinct from teardown (a lifecycle owner stops
* and unregisters via `AgentHandle.dispose()`, which awaits {@link done}
* directly, not through this).
* and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits
* both {@link done} and outstanding idle-injection flushes, not through this).
*/
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
@@ -260,17 +365,27 @@ export class ReactLoopAgent implements Agent {
})
}
/** Bind the mutually referential scope context once. */
private [bindContext](ctx: Context): void {
if (this.boundContext !== undefined) throw new Error(`agent "${this.id}" context is already bound`)
this.boundContext = ctx
}
/** Mark that public lifecycle publication began. */
private [publishAgent](): void {
this.published = true
}
/**
* Start the driver loop. Returns a disposer: calling it sets status to
* `disposed`, emits `agent/status('disposed')`, resolves the disposed
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
* aborts the current request if any. The returned `agent.done` promise
* resolves once the loop exits.
* @returns the disposer — idempotent and infallible (it runs inside the
* fiber's LIFO disposal chain, where a throw would skip later disposers).
* Start the driver loop. The prepared controller already owns its stable
* disposer, so teardown can mark the agent disposed even in the narrow
* publication window before this method runs.
*/
start(): () => void {
this.done = runLoop(this.ctx, this, {
[startDriver](): void {
if (this._status === 'disposed') return
this.driverStarted = true
this.done = runLoop(this.loopCtx, this, {
inbox: this.#inbox,
setStatus: (status) => { this.setStatus(status) },
setAbort: controller => void (this.currentAbort = controller),
disposed: this.disposed,
@@ -286,11 +401,15 @@ export class ReactLoopAgent implements Agent {
// that would resolve a freshly-queued prompt as cancelled.
settleIdle: () => { this.settleIdleWaiters() },
})
// The disposer must be infallible: it runs inside the fiber's LIFO
// disposal chain, where a throw would skip later disposers (e.g. the
// registry unregistration) and leave `done` pending forever.
return () => {
if (this._status === 'disposed') return
}
/**
* Quiescent stop shared by pre-start rollback and live teardown. It marks the
* agent disposed synchronously, contains an unexpected loop rejection, and
* drains every idle-injection flush before resolving.
*/
private [stopDriver](): Promise<void> | void {
if (this._status !== 'disposed') {
this._status = 'disposed'
this.resolveDisposed()
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
@@ -298,14 +417,39 @@ export class ReactLoopAgent implements Agent {
// waiter chains `done`, so it resolves only once the loop actually exits.
this.settleIdleWaiters()
this.currentAbort?.abort('disposed')
// setStatus refuses transitions out of 'disposed', so emit directly —
// 'disposed' is part of the agent/status contract. Guarded: a throwing
// listener must not break the disposal chain.
try {
this.ctx.emit('agent/status', this, 'disposed')
} catch {
// listener error during disposal — nothing safe left to do with it
// An unpublished rollback has no public status lifecycle to announce.
// Once publication begins, disposed is part of the agent/status contract.
if (this.published) {
agentEvents(this.loopCtx, this).emit('agent/status', 'disposed')
}
}
// Before runLoop starts there is normally nothing asynchronous to drain;
// keep publication rollback synchronous so create() cannot throw while its
// session/agent entries are still briefly live. A session-start listener
// may have called inject(), however, so preserve
// its durability checkpoint as a real quiescence boundary.
if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return
return this.drainDriver()
}
/** Await the loop (when started) and every outstanding idle flush. */
private async drainDriver(): Promise<void> {
// An unexpected driver rejection must not skip registry/session/scope
// cleanup. The normal loop contains turn failures itself; allSettled is the
// final lifecycle backstop for anything outside those boundaries.
await Promise.allSettled([this.done])
// No new inject() can start after the synchronous disposed transition.
// Loop because settled tasks retire themselves in promise reactions that
// may run beside this continuation; either the set is empty or this waits
// the exact remaining quiescence boundary. allSettled keeps a failure in
// error reporting from skipping registry/session/scope disposers.
while (this.pendingIdleFlushes.size > 0) {
await Promise.allSettled([...this.pendingIdleFlushes])
}
}
}
/** Render an ordinary thrown value for the error event and log. */
function renderThrown(value: unknown): string {
return value instanceof Error ? value.message : String(value)
}

View File

@@ -1,27 +1,319 @@
/**
* THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and
* registers them in ctx.agents. Deliberately thin — every behavior beyond
* "call the model, run the tools, repeat" belongs to plugins on the event
* taxonomy.
* Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
* through the agent/session registries, and owns their ordered teardown.
*
* @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 type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
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'
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader } 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 {
bindReactLoopAgentContext,
prepareReactLoopAgent,
ReactLoopAgent,
} from './agent.ts'
import type { PreparedReactLoopAgent } from './agent.ts'
export { ReactLoopAgent } from './agent.ts'
export { Inbox, type InboxMessage } from './inbox.ts'
export { runLoop } from './loop.ts'
/** Fiber states that cannot own or serve a new lifecycle. */
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.UNLOADING,
FiberState.DISPOSED,
FiberState.FAILED,
])
/** Factory-level ownership of every preparing or live transaction. */
class FactoryOwnership {
private accepting = true
private transactions = new Set<AgentCreationTransaction>()
constructor(private readonly fiber: Context['fiber']) {}
isActive(): boolean {
return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
}
track(transaction: AgentCreationTransaction): () => void {
this.transactions.add(transaction)
return () => { this.transactions.delete(transaction) }
}
async dispose(): Promise<void> {
this.accepting = false
const reason = new Error('agent loop is not active')
await Promise.all(
[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
)
}
}
/** Build the public cancellation error while preserving a caller-supplied cause. */
function signalAbortError(id: AgentId, signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
}
/**
* One create/resume transaction from caller ownership through unpublished
* setup, rollback-covered publication, and final quiescent teardown.
*
* The class deliberately owns the state machine in one place. Registries only
* arbitrate identity at their final `enter()` calls; before that point every
* resource is private to this transaction.
*/
class AgentCreationTransaction {
private active = true
private failure: Error | undefined
private readonly deactivation = Promise.withResolvers<void>()
private readonly publication = Promise.withResolvers<void>()
private readonly torndown = Promise.withResolvers<void>()
private readonly wrapperCompletion = Promise.withResolvers<void>()
private preparing: Promise<void> | undefined
private driver: PreparedReactLoopAgent | undefined
private scope: Scope | undefined
private session: Session | undefined
private lifecycleDispose: (() => Promise<void> | void) | undefined
private detachSession: (() => void) | undefined
private detachAgent: (() => void) | undefined
private publishing = false
private cleanupTask: Promise<void> | undefined
private ownerFollowing = true
private readonly ownerDispose: () => Promise<void> | void
private readonly untrackFactory: () => void
private readonly abortListener: (() => void) | undefined
readonly ownerAgent: Context['agent']
readonly ownerFiber: Context['fiber']
constructor(
private readonly loopCtx: Context,
private readonly ownerCtx: Context,
private readonly ownership: FactoryOwnership,
readonly id: AgentId,
signal?: AbortSignal,
) {
ownerCtx.fiber.assertActive()
this.ownerAgent = ownerCtx.agent
this.ownerFiber = ownerCtx.fiber
if (!ownership.isActive()) throw new Error('agent loop is not active')
this.ownerDispose = ownerCtx.effect(() => () => {
if (!this.ownerFollowing) return
return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
}, `agentLoop.owner(${id})`)
this.untrackFactory = ownership.track(this)
if (signal === undefined) {
this.abortListener = undefined
} else {
this.abortListener = () => {
/* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */
void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => {
this.loopCtx.logger.error(error)
})
}
signal.addEventListener('abort', this.abortListener, { once: true })
if (signal.aborted) this.deactivate(signalAbortError(id, signal))
}
this.signal = signal
}
private readonly signal: AbortSignal | undefined
/** Whether caller, provider, and optional parent-agent ownership remain live. */
isActive(): boolean {
return this.active
&& this.ownership.isActive()
&& this.ownerFiber.uid !== null
&& !INACTIVE_STATES.has(this.ownerFiber.state)
&& this.ownerAgent?.status !== 'disposed'
}
/** Fail synchronously at every real lifecycle boundary after deactivation. */
assertActive(): void {
if (this.isActive()) return
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
}
/** Race an external async operation against structural/signal deactivation. */
async waitFor<T>(operation: PromiseLike<T> | T): Promise<T> {
this.assertActive()
return await Promise.race([
Promise.resolve(operation),
this.deactivation.promise.then(() => {
/* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */
throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`)
}),
])
}
/** Construct the driver and scope, then install their complete ordered lifecycle. */
prepare(options: AgentOptions, session: Session): ReactLoopAgent {
this.assertActive()
const gate = Promise.withResolvers<void>()
this.preparing = gate.promise
try {
this.session = session
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session)
this.driver = driver
const agent = driver.agent
const scope = createScope(this.loopCtx, agent)
this.scope = scope
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
this.installLifecycle(scope, driver)
this.assertActive()
return agent
} catch (error: unknown) {
if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) {
throw this.failure ?? this.disposalReason()
}
throw error
} finally {
gate.resolve()
this.preparing = undefined
}
}
/** Register the exact scope disposer inside the ordered transaction effect. */
private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void {
this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) {
// First yielded, disposed last.
yield () => { this.finish() }
yield scope.rawDispose
yield () => {
this.detachSession?.()
this.detachSession = undefined
}
yield () => {
this.detachAgent?.()
this.detachAgent = undefined
}
// Last yielded, disposed first.
yield () => {
this.deactivate(this.disposalReason())
if (this.publishing) {
return this.publication.promise.then(() => driver.dispose())
}
return driver.dispose()
}
}.bind(this), `agentLoop.lifecycle(${this.id})`)
}
/** Publish the exact prepared objects and start the driver. */
publish(source: SessionStartSource): AgentHandle {
this.assertActive()
const driver = this.driver
/* v8 ignore next -- publish() is private and every caller invokes prepare() first. */
if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`)
const agent = driver.agent
const session = this.session
/* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */
if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`)
this.publishing = true
try {
this.detachSession = agent.ctx.sessions.enter(session)
this.detachAgent = this.loopCtx.agents.enter(agent)
agent.ctx.sessions.announce(session)
this.assertActive()
this.loopCtx.agents.announce(agent)
this.assertActive()
driver.markPublished()
agentEvents(this.loopCtx, agent).emit('agent/session-start', source)
this.assertActive()
driver.startDriver()
return { agent, dispose: () => this.dispose() }
} finally {
this.publishing = false
this.publication.resolve()
}
}
/** Mark the transaction inactive exactly once and wake load/setup races. */
private deactivate(reason: Error): void {
if (!this.active) return
this.active = false
this.failure = reason
this.deactivation.resolve()
}
/** Choose the structural cause when an owner/factory effect starts teardown first. */
private disposalReason(): Error {
if (this.failure !== undefined) return this.failure
if (!this.ownership.isActive()) return new Error('agent loop is not active')
if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') {
return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
}
return new Error(`agent "${this.id}" lifecycle disposed`)
}
/** Complete ownership bookkeeping after every resource reached quiescence. */
private finish(): void {
this.untrackFactory()
this.ownerFollowing = false
void this.ownerDispose()
this.torndown.resolve()
}
/**
* Deactivate and quiesce this transaction. The promise is memoized because
* Cordis effect disposers are single-shot while handles promise shared
* quiescence to every racing owner.
*/
dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise<void> {
this.deactivate(reason)
return (this.cleanupTask ??= (async () => {
if (this.preparing !== undefined) await this.preparing
if (this.lifecycleDispose !== undefined) {
await this.lifecycleDispose()
await this.torndown.promise
return
}
try {
await this.driver?.dispose()
} finally {
try {
await this.scope?.dispose()
} finally {
this.finish()
}
}
})())
}
/** Mark the public create/resume continuation settled and detach its creation-only signal. */
finishWrapper(): void {
if (this.signal !== undefined && this.abortListener !== undefined) {
this.signal.removeEventListener('abort', this.abortListener)
}
this.wrapperCompletion.resolve()
}
/** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */
async disposeForFactory(reason: Error): Promise<void> {
await this.dispose(reason)
await this.wrapperCompletion.promise
}
}
declare module 'cordis' {
interface Context {
@@ -29,52 +321,24 @@ declare module 'cordis' {
}
}
/**
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
* declaratively at startup, so a cordis.yml deployment needs no code.
*/
/** Plugin configuration for declarative startup agents. */
export interface Config {
/** Agents created from configuration at startup. */
/** Agents created or resumed at plugin startup. */
agents: (AgentOptions & {
/** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
/** Registry identity for the live agent. */
id: AgentId
/** Optional workspace cwd for the config-created fresh session. */
/** Optional workspace for a fresh session. */
cwd?: string
/**
* If set, the config agent RESUMES this persisted session id instead of
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
* cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
* demo can continue a prior conversation without code changes. Requires a
* `dsh-session-persistence` backend; the resume is deferred until that
* service is available (via `ctx.inject`) and the loaded session's events
* seed the live session so history continues.
*
* The schema accepts a plain string at runtime (cordis.yml values are
* untyped); the brand is compile-time only — the config format is the
* boundary where an id enters, so the TYPE declares the brand here.
*/
/** Persisted session to resume instead of creating a fresh session. */
resumeSessionId?: SessionId
})[]
}
/**
* The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs
* their loops, and registers them in `ctx.agents`. Also implements the
* {@link AgentFactory} seam, so plugins create/resume agents through
* `ctx.agents` (the interface) without depending on this concrete package.
*
* The loop itself is deliberately thin — every behavior beyond "call the
* model, run the tools, repeat" belongs to plugins listening on the event
* taxonomy declared in @deepseek-ai/dsh-agent.
*/
/** Concrete ReactLoopAgent factory and driver service. */
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
// 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
// zero-cost compile-time cast, so the runtime schema stays string-based and we
// assert the branded view once here — the single schema boundary.
/** Runtime schema for declarative agents. */
static Config = z.object({
agents: z.array(z.object({
id: z.string().required(),
@@ -84,269 +348,145 @@ export class AgentLoop extends Service implements AgentFactory {
})).default([]),
}) as unknown as z<Config>
private readonly ownership: FactoryOwnership
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
private readonly runtime: { ctx: Context }
constructor(ctx: Context, public config: Config) {
super(ctx, 'agentLoop')
// Provide the agent-creation factory to the registry (effect-scoped: the
// slot is cleared on dispose).
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
// The prompt variables the shipped loop provides, registered once. The
// sections themselves (`harness:identity`, `deployment:persona`) belong to
// dsh-system-prompt — they must survive a swapped loop plugin — but
// `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives:
// it assembles with `{ agent }` each step (loop.ts), and the variables
// project the agent's configured model and its session workspace from that
// context. A provider returns undefined when the fact is absent
// (renderPrompt then rejects a persona that claims it — fail loud).
this.ownership = new FactoryOwnership(ctx.fiber)
this.runtime = { ctx }
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
if (resumeSessionId !== undefined && resumeSessionId !== '') {
// Resume a prior session instead of starting fresh. resume() needs
// `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
// lists the backend later). `ctx.inject(['sessionPersistence'], cb)`
// runs `cb` with a child ctx once the service exists; the child reads
// the persistence and hands it to resumeWith (which uses this.ctx — the
// parent — for sessions/registry, all in AgentLoop's static inject). A
// failed resume is contained + logged: startup must not crash.
ctx.effect(() => {
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
.catch((error: unknown) => {
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
})
})
return () => void fiber.dispose()
}, `agentLoop.resume(${id})`)
} else {
if (resumeSessionId === undefined || resumeSessionId === '') {
this.create(id, options, cwd === undefined ? {} : { cwd })
continue
}
ctx.effect(() => {
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(ctx, childCtx.sessionPersistence, {
agentId: id,
resumeSessionId,
agentOptions: options,
}).catch((error: unknown) => {
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
})
})
return fiber.dispose
}, `agentLoop.resume(${id})`)
}
}
/**
* Config-driven create: an agent on a FRESH, non-colliding session id per run
* (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
* the shared core for the programmatic factory {@link createAgent}.
*
* Why a per-run id, not a fixed `${id}-session`: once a durable persistence
* backend is loaded, a fixed id collides on the second run — the backend
* refuses to re-create an id whose log already exists on disk (the SessionId
* is the identity). A fresh id means each run is a new session.
*
* TODO(demo): each run starting a brand-new session is fine for demos but is
* NOT real conversation continuity. A production config-driven agent needs a
* deliberate resume-or-create policy (resume the prior session if one exists,
* else start fresh) or an explicit caller-chosen session id — revisit when the
* UI/ACP path owns session selection.
* @param id - the agent id; also seeds the generated session id.
* @param options - loop options (model, limits, …); defaults applied per option.
* @param meta - optional session metadata for the fresh session.
* @returns the running agent, owned by the calling fiber (no handle).
* Create an agent on a fresh per-run session, owned by the accessing fiber.
* Constructor-driven config calls use the loop fiber itself.
* @param id - agent registry id.
* @param options - concrete loop options.
* @param meta - optional fresh-session workspace metadata.
* @returns the published running agent.
*/
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
// Config/programmatic path: prepare the session and let start() fold its
// lifecycle into the agent's composite effect (so a fiber unload tears the
// session + agent down as one ordered chain, capturing the loop's closing
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
const { agent } = this.start(id, options, session, 'startup')
return agent
const loopCtx = this.runtime.ctx
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
try {
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
const session = loopCtx.sessions.prepare(sessionId, { meta })
const agent = transaction.prepare(options, session)
transaction.publish('startup')
return agent
} catch (error: unknown) {
void transaction.dispose(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
transaction.finishWrapper()
}
}
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
* metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The
* ACP bridge uses this so the client-generated session id becomes the
* live/persisted session id; the in-process FORK subagent backend passes a
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
* starts with the parent's context. Returns an {@link AgentHandle} the owner
* disposes to tear down exactly this agent.
* @param options - agent id, caller-supplied session id, optional seed/meta,
* and agent options.
* @returns the handle whose dispose tears down exactly this agent.
* Create an owned agent on a caller-supplied session id.
* @param ownerCtx - caller context that structurally owns the transaction.
* @param options - identities, session seed/metadata, loop options, setup, and cancellation.
* @returns the published handle.
*/
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')
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
const transaction = new AgentCreationTransaction(
this.runtime.ctx,
ownerCtx,
this.ownership,
options.agentId,
options.signal,
)
try {
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
...options.seed === undefined ? {} : { seed: options.seed },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = transaction.prepare(options.agentOptions ?? {}, session)
await transaction.waitFor(options.setup?.(agent.ctx))
transaction.assertActive()
return transaction.publish('startup')
} catch (error: unknown) {
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
transaction.finishWrapper()
}
}
/**
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the
* session log + metadata via `ctx.sessionPersistence`, reconstructs the live
* session with the loaded events (so `lastTurnNumber`/`deriveMessages`
* continue), and starts a fresh agent on it. The live session id is the
* resumed id, NOT `${agentId}-session`.
*
* Requires `ctx.sessionPersistence`; rejects with a clear error if it is not
* configured. NOT hard-injected (that would make non-persistent demos pend
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
* by the time this runs the service exists.
* @param options - the persisted session id to reload, plus agent id/options.
* @returns the handle for the agent resumed on the reconstructed session.
* Resume an owned agent from the configured persistence service.
* @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
* @param options - persisted identity, loop options, setup, and cancellation.
* @returns the published handle.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
// Read the service through `ctx.get('sessionPersistence')` — a direct
// global-store lookup keyed by the isolate symbol — NOT
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
// `sessionPersistence` (injecting it would pend non-persistent demos
// forever). The `ctx.<name>` property proxy resolves a service by an
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
// own fiber (which lacks the inject) that walk never reaches the sibling
// backend fiber and throws "cannot get property … without inject". Worse,
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
// sidesteps the fiber walk entirely (a store lookup by the global isolate
// key), so resume works from any caller fiber. It is strict by default: a
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
// and we reject below, rather than handing back an unusable handle.
const persistence = this.ctx.get('sessionPersistence')
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
const persistence = this.runtime.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
}
return this.resumeWith(persistence, options)
return this.resumeWith(ownerCtx, persistence, options)
}
/**
* Resume against an EXPLICIT persistence handle. Factored out of {@link resume}
* so the config-driven path can pass the handle it obtained from a
* `ctx.inject(['sessionPersistence'], …)` child context: `this.ctx` (the
* service's own fiber) did not inject `sessionPersistence`, so reading it
* there from inside the inject child trips the cordis inject guard. The
* sessions store + registry are still read through `this.ctx` (both are in
* 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')
}
/**
* Reject a duplicate agent id BEFORE the session is entered into the store, so
* a failed factory call never leaves an orphaned live session (and lazy
* persistence state) behind. `register()` enforces the same uniqueness, but
* only after the session has already entered the store.
*/
private assertAgentIdFree(id: AgentId): void {
if (this.ctx.agents.get(id) !== undefined) {
throw new Error(`agent "${id}" is already registered`)
/** Resume through an explicit persistence handle used by the deferred config path. */
private async resumeWith(
ownerCtx: Context,
persistence: SessionPersistence,
options: ResumeAgentOptions,
): Promise<AgentHandle> {
const transaction = new AgentCreationTransaction(
this.runtime.ctx,
ownerCtx,
this.ownership,
options.agentId,
options.signal,
)
try {
const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
transaction.assertActive()
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
seed: loaded.events,
meta: {
createdAt: loaded.meta.createdAt,
...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
},
})
const agent = transaction.prepare(options.agentOptions ?? {}, session)
await transaction.waitFor(options.setup?.(agent.ctx))
transaction.assertActive()
return transaction.publish('resume')
} catch (error: unknown) {
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
throw error
} finally {
transaction.finishWrapper()
}
}
/**
* 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`).
*/
private start(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const agent = new ReactLoopAgent(this.ctx, id, options, session)
const dispose = this.ctx.effect(function* (this: AgentLoop) {
yield this.ctx.sessions.enter(session)
this.ctx.sessions.announce(session)
yield this.ctx.agents.register(agent)
// 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).
try {
this.ctx.emit('agent/session-start', agent, 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() } }
}
/**
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The
* handle's `dispose()` runs the composite effect's disposer (see
* {@link start}) — which stops the loop, awaits its exit (final flush
* captured), unregisters the agent, and detaches the session, in that order.
* The same composite effect is what a fiber unload disposes, so both teardown
* triggers honor the ordering identically.
*
* `dispose()` is MEMOIZED: the underlying cordis effect disposer is
* single-shot (a second call returns immediately because the effect's epoch is
* already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated
* `dispose()` calls would otherwise resolve before the first call's
* `await agent.done` + final flush completed. Memoizing the promise makes every
* caller observe the SAME quiescence boundary, honoring the
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
* helper).
*/
private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle {
const { agent, disposeAgent } = this.start(id, options, session, source)
let disposing: Promise<void> | undefined
return { agent, dispose: () => (disposing ??= disposeAgent()) }
}
}
export default AgentLoop

View File

@@ -10,7 +10,8 @@
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 type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, 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'
@@ -19,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 }
@@ -107,6 +109,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. */
@@ -155,12 +159,13 @@ export interface LoopHandle {
* every prompt blocked → 'turn/end'(rejected), 0 steps
* STEP loop:
* drain steering → session('steering/message') ⟵ catches late steering
* assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
* assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble
* (scope-filtered; scoped sections/tools join); renderPrompt
* (persona section + {{variables}}) IS the full prompt
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
* session prefix; logged on the header, never
* session history
* await ctx.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
* session history (scope-filtered, fused dispatch)
* await events.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
* pressure gates see the prefix the request carries
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
* session('step/start') same sync frame, strictly before step/start
@@ -183,9 +188,12 @@ 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.parallel('session/flush', session) ⟵ durability checkpoint
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
* re-enqueue leftover steering as queued ⟵ steering is never stranded
* idle (emit agent/status) unless more queued
* ```
@@ -202,9 +210,13 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
const transmission = createTransmissionLog()
const { session } = agent
// The fused agent-subject dispatcher: every agent/* dispatch below carries
// the agent's scope (an `agent.ctx` listener hears only this agent) with
// the subject injected — one spelling, checked by the dev invariants.
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
@@ -222,7 +234,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
}
@@ -244,7 +256,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
}
@@ -255,8 +267,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, 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
@@ -264,10 +277,13 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
// the previous turn/end), where the persistence backend drops it as a
// crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
// driver survives and moves on.
// Acceptance and internal dispatch validation can reject before
// turn/start commits. Report that supported pre-turn failure without
// inventing a turn/end for a turn that never opened.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
try {
ctx.emit('agent/error', agent, turn, 0, err)
events.emit('agent/error', turn, 0, err)
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
}
@@ -280,27 +296,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, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
): Promise<void> {
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
): 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')
@@ -310,35 +329,16 @@ 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
// them (see the agent event-domain rule). A throwing step/end session-event
// listener must not abort finalization and strand the turn open (turn/end
// balance > notifying one bad listener); it is contained and surfaced as a
// turn error below.
const closeStep = (): boolean => {
if (!stepOpen) return false
// Close the open step exactly once (idempotent via stepOpen). Post-commit
// session/event observers are contained by Session; a pre-commit validator
// failure still escapes so the outer recovery path may retry the boundary or
// fail loudly without pretending an uncommitted step/end exists.
const closeStep = (): void => {
if (!stepOpen) return
session.append('step/end', { turn, step })
stepOpen = false
// Session.append pushes step/end BEFORE notifying session/event listeners,
// so a throwing listener leaves step/end in the log (balance holds) but
// would otherwise abort finalization. Contain it and surface it as a turn
// error below.
let failure: unknown
try {
session.append('step/end', { turn, step })
} catch (error: unknown) {
failure = error
}
// A throwing step/end session-event listener surfaces as a turn error via
// failTurn (idempotent). This prevents a throwing listener from producing a
// silent "completed" turn when the step itself succeeded, AND keeps
// finalization going when closeStep runs from the outer catch.
if (failure !== undefined) {
failTurn(toError(failure))
return true
}
return false
}
// Record a step/turn failure exactly once: set the error reason (carrying the
@@ -350,45 +350,29 @@ async function runTurn(
const failTurn = (err: CodedError): void => {
if (errorReported) return
errorReported = true
// The turn is always still open here: the only failure that can reach
// failTurn once turn/end is appended would be a throwing turn-boundary
// listener, and turn boundaries are durable session events with no agent/*
// mirror to throw. A throwing `turn/end` session-event listener is already
// contained inside closeTurn (append pushes before notifying, so the
// boundary is durable). So set the error reason for closeTurn to append.
// The turn is still open here. Post-commit observers cannot escape append,
// and a pre-commit turn/end veto leaves no closing boundary to overwrite.
// Set the reason that the next successful closeTurn will append.
reason = { kind: 'error', step, ...errorData(err) }
try {
ctx.emit('agent/error', agent, turn, step, err)
events.emit('agent/error', turn, step, err)
} catch {
// contained: the error is already captured on `reason`; a throwing
// agent/error listener must not prevent the turn from closing.
}
}
// Close the turn. Called exactly once per turn — the normal loop exit and the
// outer catch are mutually exclusive paths, and this never throws (the append
// is contained below), so there is no re-entry to guard against (unlike
// closeStep, which the cancel branches and the outer catch can both reach).
// Turn boundaries are durable session events only — there is no agent/* turn
// emit to mirror them (see the agent event-domain rule).
// Close the turn. Post-commit observer failures are contained by Session;
// pre-commit validation failures escape to recovery instead of being mistaken
// for a committed boundary. Turn boundaries are durable session events only.
const closeTurn = (): void => {
// Session.append pushes turn/end BEFORE notifying session/event listeners,
// so a throwing listener leaves turn/end in the log (the turn is balanced)
// but would otherwise escape — from the outer catch it would propagate to
// the runLoop backstop. Contain it: the boundary is durable either way, and
// finalization must not abort on a bad listener.
try {
session.append('turn/end', { turn, reason })
} catch (error: unknown) {
ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`)
}
session.append('turn/end', { turn, reason })
}
try {
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
// matter what throws below; the catch + closeTurn guarantee it (the catch
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
// listener — append pushes before notifying — still gets its turn/end).
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
// veto leaves no turn/start in the log and therefore owes no turn/end.
session.append('turn/start', { turn, trigger })
// Each drained queued message runs the `agent/prompt-submit` waterfall before
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
@@ -402,8 +386,8 @@ async function runTurn(
// batch always reports the last vetoing reason.
let lastBlockReason = 'prompt blocked by hook'
for (const message of queued) {
const decision = await ctx.waterfall(
'agent/prompt-submit', agent, message.content, message.source,
const decision = await events.waterfall(
'agent/prompt-submit', message.content, message.source,
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
)
if (decision.kind === 'block') {
@@ -447,7 +431,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
@@ -462,9 +446,9 @@ async function runTurn(
// against the system prompt (it counts toward the budget). runStep reuses
// this same assembly for the request, so the prompt is assembled once per
// step. renderPrompt IS the full prompt — the persona is the order-0
// section (registered by the AgentLoop plugin) and `{{variable}}`
// section (owned by dsh-system-prompt) and `{{variable}}`
// interpolation happens in the render, so there is no separate join.
const assembly = await ctx.systemPrompt.assemble({ agent })
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
const fullSystemPrompt = renderPrompt(assembly)
// Interruption landing after assembly: dispose() or cancel() in a
@@ -501,8 +485,8 @@ async function runTurn(
// CURRENT request.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await ctx.waterfall(
'agent/session-prefix', agent, emptyPrefix, abort.signal,
const composed = await events.waterfall(
'agent/session-prefix', emptyPrefix, abort.signal,
() => Promise.resolve(emptyPrefix),
)
@@ -537,7 +521,7 @@ async function runTurn(
// pre-step plugin ends the turn, not the loop. The composed session
// prefix rides along so token-pressure listeners count everything the
// request will actually carry.
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty step.
if (handle.isCancelled() || handle.isDisposed()) {
@@ -550,20 +534,19 @@ async function runTurn(
// messages are snapshotted HERE, in the same synchronous frame as the
// step/start append directly below — so the snapshot is exactly the
// derivation over the log prefix strictly before step/start's seq.
// Anything appended later by a step/start session/event listener, an
// agent/request-window inject(), any concurrent task lands after the
// boundary and joins the NEXT request. An external reconstructor
// Anything appended later by the request-window inject seam or a
// concurrent task lands after the boundary and joins the NEXT request.
// session/event itself is observe-only: append reentrancy is rejected
// until the current callback list drains. An external reconstructor
// recovers these exact messages by folding the surface over
// events[0..stepStartSeq).
const boundaryMessages = session.deriveMessages()
// Mark the step open BEFORE the append: Session.append pushes the event
// to the log before notifying session/event listeners, so a THROWING
// step/start listener leaves step/start in the log. Setting stepOpen first
// means the outer catch's closeStep() then appends the balancing step/end
// (turn stays enclosed) instead of stranding an open step under turn/end.
stepOpen = true
session.append('step/start', { turn, step })
// Only a committed step/start creates a balancing obligation. A
// pre-commit veto throws before this assignment; post-commit observers
// are contained inside Session.append().
stepOpen = true
// Cancel landing in the step-start window: a synchronous `session/event`
// step/start listener can cancel after the step is already open. Check
@@ -578,7 +561,8 @@ async function runTurn(
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
try {
stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
stepOutcome = await runStep(
ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
} catch (error: unknown) {
stepOutcome = { error: toError(error) }
} finally {
@@ -613,15 +597,15 @@ 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
closeStep()
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
let decision: ContinuationDecision
try {
decision = await ctx.waterfall(
'agent/turn-continuation', agent, turn, defaultDecision,
decision = await events.waterfall(
'agent/turn-continuation', turn, defaultDecision,
() => Promise.resolve(defaultDecision),
)
} catch (error: unknown) {
@@ -635,14 +619,38 @@ 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.serial('agent/turn-stop', turn)
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
@@ -664,21 +672,10 @@ async function runTurn(
// Normal / inline-error loop exit: close the turn.
closeTurn()
} catch (error: unknown) {
// Decide whether this turn was ever opened from the LOG, not a flag.
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a throwing listener on the `turn/start` append leaves turn/start in the
// log even though execution never reached the lines after that append.
// Gating on a "turn started" boolean would skip turn/end and leave a
// permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
// check the log for THIS turn's turn/start: present means a turn/end is owed
// and the normal-exit `closeTurn()` did NOT run (we are here because a throw
// preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
// so this catch appends turn/end with the disposed/error reason chosen below.
// `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
// already in a step branch, so running it again is a safe no-op. Absent
// turn/start means the append threw BEFORE its push (a non-serializable
// trigger — impossible for our fixed trigger); nothing was opened, so rethrow
// to the runLoop backstop.
// Decide whether this turn opened from the LOG, not a speculative flag. A
// pre-commit validator or acceptance failure leaves no turn/start and owes
// no turn/end, so it propagates to runLoop's backstop. Once turn/start is
// present, this path balances any committed step and records the failure.
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
if (!turnStartLogged) throw error
closeStep()
@@ -698,8 +695,9 @@ async function runTurn(
// Durability checkpoint: persistence plugins drain write-behind buffers.
// A failing persistence plugin is reported but doesn't kill the agent.
// Through the store's flush (the carrier owner), never a raw parallel.
try {
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
} catch (error: unknown) {
// The turn is already closed (turn/end appended above) and flush must run
// AFTER turn/end to be a checkpoint — so there is no in-turn position left
@@ -711,16 +709,17 @@ async function runTurn(
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
try {
ctx.emit('agent/error', agent, turn, step, err)
events.emit('agent/error', turn, step, err)
} catch {
// 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' })
}
@@ -736,6 +735,7 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
* the surface prefix at step/start and already reflects any compaction. */
async function runStep(
ctx: Context,
events: AgentEventDispatch,
agent: ReactLoopAgent,
turn: number,
step: number,
@@ -768,7 +768,7 @@ async function runStep(
// model-visible content flows through the log channels). The header event
// below records whatever the request ACTUALLY uses, so a listener's switch
// is a logged, reconstructable fact, never silent drift.
const config = await ctx.waterfall('agent/request', agent, turn, step, seedConfig, () => Promise.resolve(seedConfig))
const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
if (!config.model) {
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
}
@@ -827,7 +827,7 @@ async function runStep(
if (assembler.finish.kind === 'max-tokens') {
let message: Message = withoutToolCalls(assembler.message())
message = withoutToolCalls(await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)))
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)))
// Fire the assistant/message when there is content OR usage: a max-tokens
// step can be cut off with empty content but still carry token accounting,
// and assistant/message is the only host for usage (there is no standalone
@@ -850,7 +850,7 @@ async function runStep(
// source of truth for derived history and replay) records the message that
// tool dispatch actually uses.
let message: Message = assembler.message()
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
// Same content-or-usage guard as the max-tokens branch: a step that finishes
// with neither assembled content nor usage (e.g. a bare `stop` finish that

View File

@@ -7,6 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -48,6 +49,33 @@ function send(agent: ReactLoopAgent, text: string) {
}
describe('ReactLoopAgent', () => {
it('rejects access before context binding and a second driver for one session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('exclusive-driver'))
const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session)
expect(() => prepared.agent.ctx).toThrow('context is not bound')
expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session))
.toThrow('already has a concrete agent driver')
await prepared.dispose()
await ctx.fiber.dispose()
})
it('borrows caller options and binds its scoped context exactly once', async () => {
const ctx = await harness(new MockAdapter([textResponse('unused')]))
const options = { model: 'mock' }
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
expect(agent.options).toBe(options)
expect(agent.id).toBe('owned-bindings')
expect(agent.session.id).toMatch(/^owned-bindings-session-/)
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
await ctx.fiber.dispose()
})
it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -158,10 +186,8 @@ describe('ReactLoopAgent', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// A session/event listener that throws on the synthetic turn/end. Append
// pushes before notifying, so turn/end is in the log (turn balanced) but the
// throw must NOT skip the durability checkpoint — the flush decision is made
// from the log, not a flag set after the (throwing) append.
// Session contains a throwing post-commit turn/end observer. The accepted
// boundary still triggers the idle injection's durability checkpoint.
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
@@ -226,26 +252,45 @@ describe('ReactLoopAgent', () => {
})
it('disposer is idempotent (double-stop)', async () => {
// Create a bare ReactLoopAgent and call start() directly to get the disposer.
// Then call it twice — the second call hits the early-return branch.
// Create a bare ReactLoopAgent and start it through the package-internal
// test seam. Then call its disposer twice — the second call hits the
// early-return branch.
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('test'))
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const { agent } = prepared
// Start the loop to get the disposer; the agent waits for messages
// (idle, never-resolving cancel), so it will stay idle.
const dispose = agent.start()
prepared.markPublished()
const dispose = prepared.startDriver()
// First dispose
dispose()
const firstDisposal = dispose()
expect(agent.status).toBe('disposed')
await firstDisposal
// Second dispose — idempotent, no throw
expect(() => { dispose() }).not.toThrow()
await expect(dispose()).resolves.toBeUndefined()
expect(agent.status).toBe('disposed')
})
it('a pre-start disposal makes a later driver-start attempt inert', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session)
await prepared.dispose()
expect(prepared.agent.status).toBe('disposed')
const dispose = prepared.startDriver()
await dispose()
await expect(prepared.agent.done).resolves.toBeUndefined()
expect(prepared.agent.session.events).toEqual([])
await ctx.fiber.dispose()
})
it('setting the same status does not emit agent/status again', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -324,7 +369,7 @@ describe('ReactLoopAgent', () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
// start() disposer keeps the emit synchronous.
// internal driver disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -334,17 +379,19 @@ describe('ReactLoopAgent', () => {
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const session = ctx.sessions.create(SessionId('bare'))
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const dispose = agent.start()
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const { agent } = prepared
prepared.markPublished()
const dispose = prepared.startDriver()
agent.send([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
const idle = agent.whenIdle() // queues an internal waiter (running)
dispose() // settles the waiter synchronously; whenIdle chains done
const disposal = dispose() // settles the waiter synchronously; whenIdle chains done
await idle
expect(agent.status).toBe('disposed')
await agent.done
await disposal
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
@@ -409,7 +456,7 @@ describe('ReactLoopAgent', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
@@ -427,7 +474,7 @@ describe('ReactLoopAgent', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
})

View File

@@ -202,7 +202,7 @@ describe('Agent.cancel()', () => {
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
const handle = await ctx.agents.create({
agentId: AgentId('a-dispose-prefix'),
sessionId: SessionId('dispose-prefix-session'),
agentOptions: { model: 'mock' },
@@ -333,7 +333,7 @@ describe('Agent.cancel()', () => {
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
const handle = await ctx.agents.create({
agentId: AgentId('a-dispose-step-start'),
sessionId: SessionId('dispose-step-start-session'),
agentOptions: { model: 'mock' },

View File

@@ -24,6 +24,24 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
}
describe('config-driven session id', () => {
it('identity-nests the deferred resume fiber under its labeled owner effect', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const loopFiber = await ctx.plugin(AgentLoop, {
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }],
})
const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)')
expect(resumeEffect?.children.map(child => child.label)).toEqual(['ctx.plugin()'])
expect(loopFiber.getEffects().filter(effect => effect.label === 'ctx.plugin()')).toEqual([])
await loopFiber.dispose()
})
it('config-driven create uses a fresh ${id}-session-<uuid> per run (restart-safe)', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-session-'))
dirs.push(root)
@@ -78,7 +96,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
@@ -35,31 +36,24 @@ function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
// A non-serializable message source makes the turn/start append throw BEFORE
// the event is pushed (Session.append validates before push), so turn/start
// never enters the log. runTurn sees no logged turn/start and rethrows; the
// runLoop backstop reports via agent/error (step 0) + the logger and the
// driver survives. This is the ONLY path that reaches the backstop.
const adapter = new MockAdapter([textResponse('turn 2')])
describe('inbox acceptance', () => {
it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
const adapter = new MockAdapter([textResponse('turn 1')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let queued = 0
ctx.on('agent/queued', () => { queued += 1 })
const errors: { turn: number; step: number; message: string }[] = []
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
expect(() => {
agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
}).toThrow(/losslessly JSON-serializable/)
expect(() => {
agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
}).toThrow(/losslessly JSON-serializable/)
expect(queued).toBe(0)
expect(agent.session.events).toHaveLength(0)
// A non-serializable source (BigInt) on the queued message.
agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.step).toBe(0)
expect(errors[0]!.message).toMatch(/non-JSON-serializable/)
// No turn boundary was written (the turn/start append threw before push).
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
// loop survives: a well-formed second turn runs normally.
// The rejected value never woke or poisoned the loop; a valid message runs.
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
@@ -129,13 +123,15 @@ describe('tool JSON parse', () => {
})
describe('toError normalization', () => {
it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => {
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('session/event', (_session, event) => {
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'turn/start' && !threwOnce) {
threwOnce = true
throw 'naked string error' // non-Error throw, normalized via toError
@@ -148,11 +144,9 @@ describe('toError normalization', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toBe('naked string error')
// A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the
// turn-end error reason carries a routable code instead of degrading.
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
expect(adapter.requests).toEqual([])
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
})
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Inbox } from '@deepseek-ai/dsh-agent-loop'
import { Inbox } from '../src/inbox.ts'
function resolverPair() {
let r!: () => void

View File

@@ -319,6 +319,36 @@ describe('agent/session-start', () => {
})
describe('agent/session-prefix', () => {
it('dispatches to global and matching agent-scope listeners only', async () => {
const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
const ctx = await harness(adapter)
const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { model: 'mock' })
const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { model: 'mock' })
const seen: string[] = []
ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
seen.push(`global:${agent.id}`)
return next()
})
agentA.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
seen.push(`a:${agent.id}`)
return next()
})
agentB.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
seen.push(`b:${agent.id}`)
return next()
})
send(agentA, 'run a')
await waitForIdle(ctx, agentA)
send(agentB, 'run b')
await waitForIdle(ctx, agentB)
expect(seen).toEqual([
'global:prefix-a', 'a:prefix-a',
'global:prefix-b', 'b:prefix-b',
])
})
it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'ping' }),

View File

@@ -168,7 +168,7 @@ describe('agent loop', () => {
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'Working in {{cwd}}.')
const handle = ctx.agents.create({
const handle = await ctx.agents.create({
agentId: AgentId('a-cwd'),
sessionId: SessionId('s-cwd'),
meta: { cwd: '/work/space' },
@@ -243,6 +243,44 @@ describe('agent loop', () => {
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
})
it.each([
['BigInt', { n: 1n }],
['Map', new Map([['key', 'value']])],
['class instance', new (class ResultMeta { x = 1 })()],
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
const adapter = new MockAdapter([
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
textResponse('recovered'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'bad-meta',
description: 'returns invalid durable metadata',
parameters: {},
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
}))
const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(result?.type).toBe('tool/result')
if (result?.type === 'tool/result') {
expect(result.data.callId).toBe('bad-meta-call')
expect(result.data.isError).toBe(true)
expect(result.data.meta).toBeUndefined()
expect(result.data.content).toEqual([{
type: 'text',
text: 'Error: tool result must be losslessly JSON-serializable',
}])
}
// The normalized failure was durably logged and fed back to the model; the
// turn continued normally instead of failing after an apparent success.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
})
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
// The documented escape valve: a deployment that must drop the harness
// openers short-circuits the assemble waterfall; the request then carries
@@ -798,10 +836,10 @@ describe('agent loop', () => {
])
})
it('stops the turn when a step/end session-event listener failure has recorded an error', async () => {
it('contains a step/end observer failure without changing continuation', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'x' }),
textResponse('should not run'),
textResponse('continued after tool call'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
@@ -814,9 +852,8 @@ describe('agent loop', () => {
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threw = false
// A throwing step/end session-event listener is the surviving boundary-listener
// failure path (step boundaries have no agent/* mirror): closeStep contains it
// and surfaces it as a turn error rather than stranding the turn open.
// Post-commit session observers cannot control the loop. The tool call still
// drives the second model request, and the turn completes normally.
ctx.on('session/event', (_session, event) => {
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') }
})
@@ -824,9 +861,9 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(2)
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
})
it('chains queued messages into consecutive turns', async () => {

View File

@@ -222,7 +222,7 @@ describe('request stability across the loop', () => {
// one's full log (the resume/fork path).
const adapter2 = new MockAdapter([textResponse('two')])
const ctx2 = await harness(adapter2)
const handle = ctx2.agents.create({
const handle = await ctx2.agents.create({
agentId: AgentId('gen2'),
sessionId: SessionId('gen2-session'),
seed: [...agent.session.events],

View File

@@ -19,6 +19,10 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive:
async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
dirs.push(root)
return { ctx: await mountPersistentHarness(root, adapter), root }
}
async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -28,7 +32,22 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], adapter)
return { ctx, root }
return ctx
}
async function persistSession(sessionId: SessionId): Promise<string> {
const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')]))
// Persistence deliberately has no artifact for a truly empty session. A
// balanced completed turn is the smallest resumable log and avoids running
// the model merely to construct this lifecycle fixture.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
const session = ctx.sessions.create(sessionId, { seed })
await ctx.sessions.flush(session)
await ctx.fiber.dispose()
return root
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
@@ -39,11 +58,44 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
async function promptly<T>(task: Promise<T>): Promise<T> {
const timeout = Promise.withResolvers<never>()
const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000)
try {
return await Promise.race([task, timeout.promise])
} finally {
clearTimeout(timer)
}
}
/** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
function throwUnknown(value: unknown): never {
throw value
}
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => {
const sessionId = SessionId('unknown-resume-failure-s')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const failure = { source: 'resume' }
ctx.on('session/created', () => throwUnknown(failure))
await expect(ctx.agents.resume({
agentId: AgentId('unknown-resume-failure'),
resumeSessionId: sessionId,
})).rejects.toBe(failure)
expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
expect(agent.session.id).toBe('custom-session')
expect(agent.session.header.cwd).toBe('/w')
await ctx.fiber.dispose()
@@ -52,10 +104,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
// A second create with the SAME agent id but a fresh session id must reject
// up front — and must NOT leave an orphaned 'sess-b' session behind.
expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/)
await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -63,7 +115,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent works without meta (no cwd)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
expect(agent.session.id).toBe('nometa-session')
expect(agent.session.header.cwd).toBeUndefined()
await ctx.fiber.dispose()
@@ -73,7 +125,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -100,7 +152,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
@@ -124,6 +176,250 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.fiber.dispose()
})
it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => {
const sessionId = SessionId('resume-setup-success')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const gate = Promise.withResolvers<undefined>()
const setupStarted = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/created', (session) => {
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session)
order.push('session/created')
})
ctx.on('agent/created', (agent) => {
expect(agent.status).toBe('idle')
order.push('agent/created')
})
ctx.on('agent/session-start', (agent) => {
expect(() => { agent.cancel('now live') }).not.toThrow()
order.push('agent/session-start')
})
const resuming = ctx.agents.resume({
agentId: AgentId('resumed-atomic'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
expect(agentCtx.agent?.session.events).toHaveLength(2)
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
order.push('setup:start')
setupStarted.resolve(undefined)
await gate.promise
order.push('setup:end')
},
})
await setupStarted.promise
expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
expect(order).toEqual(['setup:start'])
gate.resolve(undefined)
const handle = await resuming
expect(order).toEqual([
'setup:start',
'setup:end',
'session/created',
'setup-listener:session/created',
'agent/created',
'setup-listener:agent/created',
'agent/session-start',
])
await handle.dispose()
await ctx.fiber.dispose()
})
it('successful resume disposal retires its caller-owned transaction effects', async () => {
const sessionId = SessionId('resume-retired-effects-s')
const agentId = AgentId('resume-retired-effects')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const handle = await ctx.agents.resume({
agentId,
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
const transactionLabels = [
`agentLoop.owner(${agentId})`,
`agentLoop.lifecycle(${agentId})`,
]
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
await handle.dispose()
expect(ctx.fiber.getEffects().filter(effect => transactionLabels.includes(effect.label))).toEqual([])
await ctx.fiber.dispose()
})
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
const sessionId = SessionId('resume-setup-reject')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
await expect(ctx.agents.resume({
agentId: AgentId('resume-reject'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async () => {
await Promise.resolve()
throw new Error('resume setup failed')
},
})).rejects.toThrow('resume setup failed')
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const retry = await ctx.agents.resume({
agentId: AgentId('resume-reject'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
await retry.dispose()
await ctx.fiber.dispose()
})
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
const sessionId = SessionId('resume-setup-owner-unload')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const gate = Promise.withResolvers<undefined>()
const setupStarted = Promise.withResolvers<undefined>()
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
let resuming!: ReturnType<typeof ctx.agents.resume>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({
agentId: AgentId('resume-owner-race'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async () => {
setupStarted.resolve(undefined)
await gate.promise
},
})
}, { inject: ['agents'] }))
await setupStarted.promise
await owner.dispose()
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
gate.resolve(undefined)
await Promise.resolve()
expect(published).toEqual([])
await ctx.fiber.dispose()
})
it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => {
const sessionId = SessionId('resume-load-owner-unload')
const agentId = AgentId('resume-load-race')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const snapshot = await ctx.sessionPersistence.load(sessionId)
const lateLoad = Promise.withResolvers<typeof snapshot>()
const loadStarted = Promise.withResolvers<undefined>()
let loads = 0
ctx.sessionPersistence.load = (id) => {
expect(id).toBe(sessionId)
loads += 1
if (loads === 1) {
loadStarted.resolve(undefined)
return lateLoad.promise
}
return Promise.resolve(structuredClone(snapshot))
}
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
let resuming!: ReturnType<typeof ctx.agents.resume>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
await promptly(owner.dispose())
expect(published).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
// owner.dispose() awaited transaction settlement, so the same identities
// can be reused before awaiting the public rejection.
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
await rejection
expect(loads).toBe(2)
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
// Settlement of the abandoned backend promise cannot resume the old
// transaction or emit a second publication after the retry owns the ids.
lateLoad.resolve(structuredClone(snapshot))
await Promise.resolve()
await Promise.resolve()
expect(ctx.agents.get(agentId)).toBe(retry.agent)
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
await retry.dispose()
await ctx.fiber.dispose()
})
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
const sessionId = SessionId('resume-load-factory-unload')
const agentId = AgentId('resume-load-factory-race')
const root = await persistSession(sessionId)
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
const snapshot = await ctx.sessionPersistence.load(sessionId)
const lateLoad = Promise.withResolvers<typeof snapshot>()
const loadStarted = Promise.withResolvers<undefined>()
ctx.sessionPersistence.load = (id) => {
expect(id).toBe(sessionId)
loadStarted.resolve(undefined)
return lateLoad.promise
}
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
await promptly(loopFiber.dispose())
await rejection
expect(published).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
lateLoad.resolve(structuredClone(snapshot))
await Promise.resolve()
await Promise.resolve()
expect(published).toEqual([])
await ctx.fiber.dispose()
})
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
// in its header) by creating it with a complete-turn seed — the write path
@@ -170,7 +466,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// disk, since a crash before the next turn would otherwise lose it.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -195,7 +491,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// drop it on reload (the bug this guards).
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -223,7 +519,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: run one full turn, persisting it.
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]

View File

@@ -1,18 +1,16 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
* Regression tests for the findings of the first architecture review
* (Codex + sub-agent, post phase-1). Each describe block names the finding.
*/
/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
async function harness(adapter: MockAdapter) {
const ctx = new Context()
@@ -436,6 +434,94 @@ describe('MEDIUM: misc registry and config fixes', () => {
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
})
it('send() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('owned-send'), { model: 'mock' })
const content = [{ type: 'text' as const, text: 'accepted-send' }]
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
ctx.on('agent/queued', (subject, acceptedContent, info) => {
if (subject !== agent || info.steering) return
// Retain the exact notification references: cloning here would test the
// listener's copy rather than the event/inbox ownership boundary.
notifiedContent = acceptedContent
notifiedSource = info.source
})
agent.send(content, { source })
content[0]!.text = 'caller-mutated-send'
source.plugin = 'caller-mutated-source'
await waitForIdle(ctx, agent)
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }])
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
expect(recorded).toContainEqual({
content: [{ type: 'text', text: 'accepted-send' }],
source: { kind: 'plugin', plugin: 'accepted-source' },
})
const request = JSON.stringify(adapter.requests[0]!.messages)
expect(request).toContain('accepted-send')
expect(request).not.toContain('caller-mutated-send')
})
it('running steer() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('owned-steer'), { model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.tools.register(defineTool({
name: 'gate',
description: '',
parameters: {},
async execute() {
entered.resolve(undefined)
await release.promise
return [{ type: 'text', text: 'tool done' }]
},
}))
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
ctx.on('agent/queued', (subject, acceptedContent, info) => {
if (subject !== agent || !info.steering) return
notifiedContent = acceptedContent
notifiedSource = info.source
})
agent.send([{ type: 'text', text: 'start' }])
await entered.promise
expect(agent.status).toBe('running')
const content = [{ type: 'text' as const, text: 'accepted-steer' }]
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
agent.steer(content, { source })
content[0]!.text = 'caller-mutated-steer'
source.plugin = 'caller-mutated-source'
const idle = waitForIdle(ctx, agent)
release.resolve(undefined)
await idle
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }])
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
expect(recorded).toContainEqual({
turn: 1,
content: [{ type: 'text', text: 'accepted-steer' }],
source: { kind: 'plugin', plugin: 'accepted-source' },
})
const request = JSON.stringify(adapter.requests[1]!.messages)
expect(request).toContain('accepted-steer')
expect(request).not.toContain('caller-mutated-steer')
})
})
describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => {
@@ -458,8 +544,10 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
ctx2.llm.registerAdapter(['mock'], second)
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
ctx2.effect(() => forked.start())
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
const forked = prepared.agent
prepared.markPublished()
ctx2.effect(() => prepared.startDriver())
const turns: number[] = []
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
@@ -558,7 +646,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
})
})
describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
describe('step boundary publication order', () => {
it('the step/start event is in session.events when its session/event listener fires', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
@@ -589,7 +677,7 @@ describe('P1-6: a step/start session-event listener sees the event already in th
})
})
describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => {
describe('turn and step boundary recovery', () => {
// Harness with the invariants plugin loaded as an oracle: it throws on
// append if the log goes unbalanced (turn/end while a step is open,
// turn/start while a turn is open, etc.), so a regression surfaces as an
@@ -602,7 +690,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
@@ -620,19 +708,13 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
}
}
it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
it('a throwing step/start observer cannot change a successful turn', async () => {
const adapter = new MockAdapter([textResponse('request completed')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
// Step boundaries have no agent/* mirror; a throwing step/start session-event
// listener is the surviving step-boundary-listener failure. The loop marks
// the step open BEFORE appending step/start (Session.append pushes before
// notifying, so a post-push listener throw still leaves stepOpen=true), so
// the outer catch's closeStep() appends the balancing step/end — the turn
// stays enclosed. The invariants oracle (balancedHarness) rejects any
// imbalance, so a green run proves turn/start → step/start → step/end →
// turn/end nesting holds.
// Session owns post-commit containment. The loop sees a successful append,
// runs the request, and balances the ordinary step and turn boundaries.
let threw = false
ctx.on('session/event', (_s, event) => {
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
@@ -645,8 +727,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
const e = [...agent.session.events]
const c = boundaryCounts(agent)
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
expect(errors.map(x => x.message)).toEqual(['boom step-start'])
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
expect(errors).toEqual([])
// step/end precedes turn/end (the invariants oracle would reject
// turn/end-while-step-open, but assert the order explicitly too).
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
@@ -655,6 +737,101 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(stepEndIdx).toBeLessThan(turnEndIdx)
})
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'step/start' && !rejected) {
rejected = true
throw new Error('reject step-start before commit')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toEqual([])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
stepStart: 0,
stepEnd: 0,
errors: 1,
})
expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
})
it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => {
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
const adapter = new MockAdapter([errorStream])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'turn/end' && !rejected) {
rejected = true
throw new Error('reject first turn-end')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors.map(error => error.message)).toEqual(['provider failed'])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
stepStart: 1,
stepEnd: 1,
errors: 1,
})
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({
kind: 'error',
message: 'provider failed',
})
})
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
const adapter = new MockAdapter([textResponse('completed before close validation')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'step/end' && !rejected) {
rejected = true
throw new Error('reject first step-end')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(errors.map(error => error.message)).toEqual(['reject first step-end'])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
stepStart: 1,
stepEnd: 1,
errors: 1,
})
})
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
// First turn: model stream ends with a finish-error → step error path →
// failTurn emits agent/error, whose listener throws. The turn must still
@@ -717,13 +894,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
// disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
// the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
// gets to run — so the catch sees `isDisposed() && !errorReported` and must
// PRESERVE reason=disposed rather than overwrite it with the listener's throw
// (disposal is not a failure). This is the surviving path to that sub-branch
// now that there is no turn-boundary emit to throw from.
// A pre-step listener requests disposal and then throws before the ordinary
// post-listener disposal check. The outer catch sees disposal already won
// and must preserve reason=disposed rather than rewrite it as a plugin error.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
@@ -759,16 +932,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(errorEmits).toHaveLength(0)
})
it('a throwing session/event listener on the turn/start append still balances the turn', async () => {
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a listener throwing on turn/start leaves turn/start IN THE LOG. The
// loop must therefore still owe (and append) a turn/end — deciding "owed"
// from the log via isTurnOpen, not a "turn started" flag that the throw
// skipped. Otherwise the turn stays permanently open and poisons the next
// turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants
// oracle — because the throwing listener is itself a session/event
// subscriber.)
const adapter = new MockAdapter([textResponse('turn 2')])
it('a throwing turn/start observer cannot starve the loop or later turns', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
@@ -782,12 +947,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
send(agent, 'go')
await waitForIdle(ctx, agent)
// The error was surfaced exactly once via agent/error.
expect(errors.map(e => e.message)).toEqual(['boom turn/start append'])
// The turn is BALANCED: turn/start is in the log (it was pushed before the
// listener threw), so a turn/end was owed and appended — no open turn. The
// last turn-boundary event being turn/end is exactly the loop's isTurnOpen
// check (no open turn remains).
expect(errors).toEqual([])
// Session contains the observer failure per listener, so the committed turn
// remains visible to later observers and executes normally.
const types = [...agent.session.events].map(e => e.type)
expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
@@ -798,15 +960,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// loop survives: a second turn runs normally.
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(2)
})
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
// closeStep() must surface a throwing step/end listener via failTurn so the
// turn ends with reason error, not a silent "completed" with the throw
// swallowed. Regression test for the closeStep() catch that previously
// swallowed the throw in the normal (no-tool, no-steering) path. (Step
// boundaries have no agent/* mirror; the session-event listener is the path.)
it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
@@ -822,11 +979,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
await waitForIdle(ctx, agent)
const c = boundaryCounts(agent)
// step opened and closed; exactly one error turn-end; turn balanced.
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
expect(errors.map(e => e.message)).toEqual(['boom step-end'])
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
expect(errors).toEqual([])
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason)
.toEqual({ kind: 'error', step: 1, message: 'boom step-end' })
.toEqual({ kind: 'completed' })
// step/end precedes turn/end (ordering contract)
const e = [...agent.session.events]
@@ -844,14 +1000,11 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(c2.stepStart).toBe(c2.stepEnd)
})
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
it('a throwing step/end observer cannot interrupt error finalization', async () => {
// A finish-error stream opens a step then fails it, driving finalization
// through closeStep() with the step open. closeStep appends step/end; a
// session/event listener throwing on THAT must not abort the catch before
// closeTurn — step/end is already logged (balance holds) and the throw is
// contained + surfaced via failTurn, so turn/end is still appended. (The
// failed step itself also routes through failTurn; the step/end-listener
// throw is the second, contained, failure.)
// through closeStep() with the step open. Session contains the observer
// failure after committing step/end, so closeTurn still records the model
// failure and balances the turn.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
@@ -872,7 +1025,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(e.some(x => x.type === 'step/end')).toBe(true)
expect(e.some(x => x.type === 'turn/end')).toBe(true)
expect(e.at(-1)?.type).toBe('turn/end')
expect(errors.length).toBeGreaterThanOrEqual(1) // surfaced via agent/error
expect(errors.map(error => error.message)).toEqual(['provider 500'])
// loop survives.
send(agent, 'again')
@@ -881,12 +1034,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
// closeTurn appends turn/end; Session.append pushes it BEFORE notifying
// session/event listeners, so a throwing listener leaves turn/end in the log
// (the turn is balanced) but must not escape — from the normal-path closeTurn
// it would otherwise propagate; the append is contained so the loop continues.
// Turn boundaries are durable session events only (no agent/* mirror), so this
// session/event append-notify throw is the sole turn-end-listener failure path.
// Session contains the observer failure after committing turn/end, so the
// boundary stays authoritative and the loop continues normally.
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
@@ -912,7 +1061,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
})
describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
describe('tool result call identity', () => {
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
// Model emits a tool-call with id "c1", then a final text turn.
const adapter = new MockAdapter([
@@ -992,7 +1141,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
describe('disposal and cancellation during pre-step assembly', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
// Block `system-prompt/assemble` on a promise. Start disposal (which
// calls stop() synchronously, setting status=disposed), then release the
@@ -1011,7 +1160,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
// Blocking listener on the parent context (survives fiber disposal).
@@ -1068,7 +1217,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
@@ -1124,7 +1273,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
@@ -1176,7 +1325,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
@@ -1225,7 +1374,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants, { freeze: false })
await ctx.plugin(Invariants)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {

File diff suppressed because it is too large Load Diff

View File

@@ -107,7 +107,7 @@ describe('loop-level canonical tool order', () => {
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
const end = agent.session.events.find(e => e.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
agent.send([{ type: 'text', text }])
return agent.whenIdle()
}
function registerEcho(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'echo',
description: 'echo',
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
}))
}
describe('agent/turn-stop', () => {
it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => {
const adapter = new MockAdapter([
textResponse('the ordinary decision is stop'),
textResponse('must not be requested'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let steered = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
const downstream = await next()
if (subject === agent && !steered) {
steered = true
subject.steer([{ type: 'text', text: 'late continuation steering' }])
}
return downstream
}, { prepend: true })
await send(agent)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
})
it('discards steering that arrives from session/flush after the terminal checkpoint', async () => {
const adapter = new MockAdapter([
textResponse('terminal answer'),
textResponse('must not become a late-steering turn'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let injected = false
ctx.on('session/flush', (session) => {
if (session !== agent.session || injected) return
injected = true
agent.steer([{ type: 'text', text: 'steering from flush' }])
})
await send(agent)
expect(injected).toBe(true)
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
})
it('preserves an ordinary queued send that arrives during terminal flush', async () => {
const adapter = new MockAdapter([
textResponse('first terminal answer'),
textResponse('queued follow-up answer'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let queued = false
ctx.on('session/flush', (session) => {
if (session !== agent.session || queued) return
queued = true
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
})
await send(agent)
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
})
it('filters a scoped terminal listener to its own agent', async () => {
const adapter = new MockAdapter([
toolCallResponse('a1', 'echo', { text: 'a' }),
toolCallResponse('b1', 'echo', { text: 'b' }),
textResponse('b continues normally'),
])
const ctx = await harness(adapter)
registerEcho(ctx)
const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' })
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' })
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
await send(stopped)
expect(adapter.requests).toHaveLength(1)
await send(ordinary)
expect(adapter.requests).toHaveLength(3)
expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
})
it('unregisters with its scoped owner disposer', async () => {
const adapter = new MockAdapter([
toolCallResponse('first', 'echo', { text: 'first' }),
toolCallResponse('second', 'echo', { text: 'second' }),
textResponse('continued after listener disposal'),
])
const ctx = await harness(adapter)
registerEcho(ctx)
const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' })
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
await send(agent, 'first turn')
expect(adapter.requests).toHaveLength(1)
disposeStop()
await send(agent, 'second turn')
expect(adapter.requests).toHaveLength(3)
})
it('fails a throwing terminal policy closed while the driver survives', async () => {
const adapter = new MockAdapter([
textResponse('throwing policy'),
textResponse('healthy later turn'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' })
const reasons: TurnEndReason[] = []
const errors: string[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
})
agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) })
const disposeThrowing = agent.ctx.on('agent/turn-stop', () => {
throw new Error('terminal policy exploded')
})
await send(agent, 'first')
disposeThrowing()
await send(agent, 'healthy')
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'completed'])
expect(errors).toContain('terminal policy exploded')
expect(adapter.requests).toHaveLength(2)
})
})

View File

@@ -34,6 +34,9 @@
},
{
"path": "../../core/agent"
},
{
"path": "../../core/scope"
}
]
}