Reorganize packages into a modular hierarchy
Move the 18 flat packages/<name> packages into role-grouped dirs: core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are pure containers; each package keeps its @deepseek-ai/dsh-* name. Collapse the per-package tsconfig paths maps (base + typecheck) into one @deepseek-ai/dsh-* wildcard with a candidate per group, and derive the publint list from the hierarchy. Update all depth-coupled globs/configs (workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs, per-package tsconfigs, generators, doc-script scopes, type-equiv manifest) and the cross-package/script relative imports in tests. Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the TypeScript API instead of a regex comment-strip, which corrupted the new wildcard `/*/` path candidates. WIP: doc cross-links and package/RFC docs still to update.
This commit is contained in:
81
packages/core/agent-loop/README.md
Normal file
81
packages/core/agent-loop/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# dsh-agent-loop
|
||||
|
||||
THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
|
||||
|
||||
This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
|
||||
|
||||
## Service: `AgentLoop` (ctx key: `agentLoop`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). 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.
|
||||
|
||||
`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?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. 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/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`.
|
||||
|
||||
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) hold a handle and own per-agent teardown.
|
||||
|
||||
### Injected services
|
||||
|
||||
`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services.
|
||||
|
||||
### Configuration (schemastery)
|
||||
|
||||
```ts
|
||||
interface Config {
|
||||
agents: Array<{
|
||||
id: string // required
|
||||
model?: string
|
||||
systemPrompt?: string
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Agents listed in config are auto-created at startup.
|
||||
|
||||
### Classes
|
||||
|
||||
- `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`).
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
One invocation of `runLoop()` drives one agent for its whole lifetime:
|
||||
|
||||
```
|
||||
forever:
|
||||
wait for queued messages (idle)
|
||||
TURN (error-contained):
|
||||
drain queued → 'turn/start' → session('user/message')
|
||||
STEP loop:
|
||||
drain steering
|
||||
assembly = systemPrompt.assemble()
|
||||
request = waterfall agent/request
|
||||
stream llm.stream(request) → session('assistant/chunk')
|
||||
message = waterfall agent/step-result
|
||||
session('assistant/message')
|
||||
each tool-call: session('tool/call') → tools.execute() → session('tool/result')
|
||||
drain steering → session('steering/message')
|
||||
cont = waterfall agent/turn-continuation
|
||||
if !cont: break
|
||||
session('turn/end')
|
||||
await session/flush
|
||||
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.
|
||||
|
||||
Cancellation: `agent.abort()` aborts only the in-flight step; `agent.cancel()` is the broad verb — 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.
|
||||
|
||||
### 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/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
|
||||
- Compaction: `agent/request`
|
||||
- Sandbox, permission, plan mode: `tools/execute`
|
||||
- Sub-agents: TODO seam on `AgentLoop.create()`
|
||||
- Persistence: `session/event` + `session/flush`
|
||||
- UI: `agent/stream-chunk` + `agent/*` events
|
||||
45
packages/core/agent-loop/package.json
Normal file
45
packages/core/agent-loop/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-loop",
|
||||
"description": "The concrete agent loop plugin for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^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",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
301
packages/core/agent-loop/src/agent.ts
Normal file
301
packages/core/agent-loop/src/agent.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* The concrete Agent implementation: ReactLoopAgent plus its inbox. Everything
|
||||
* observable happens through session events and the agent/* event taxonomy —
|
||||
* plugins never need this class.
|
||||
*
|
||||
* @module dsh-agent-loop/agent
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
||||
*
|
||||
* Owns the inbox (queued + steering FIFOs), the per-step AbortController, and
|
||||
* the loop driver. Everything observable happens through session events and
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
readonly inbox = new Inbox()
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
private currentAbort: AbortController | undefined
|
||||
/**
|
||||
* 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
|
||||
* continue. Armed ONLY when there is something to cancel (a running turn, an
|
||||
* in-flight step, or queued/steering work), so an idle no-op cancel cannot
|
||||
* leave it set to wrongly drop a later prompt.
|
||||
*/
|
||||
private cancelRequested = false
|
||||
/**
|
||||
* The resolved reason for the pending {@link cancel} (`reason ?? 'cancelled'`),
|
||||
* read by the driver loop's marker branches so a turn dropped in a
|
||||
* marker-only window (pre-step / continuation, where no `AbortController`
|
||||
* carries the reason) ends with the SAME `{kind:'aborted', reason}` the
|
||||
* mid-step abort path produces from `abort.signal.reason`. Without this the
|
||||
* caller's `cancel(reason)` would be silently replaced by the literal
|
||||
* 'cancelled' whenever the cancel landed outside a running step — making the
|
||||
* logged reason race-dependent and the public `reason?` param half-effective.
|
||||
*/
|
||||
private cancelReason = 'cancelled'
|
||||
private disposed: Promise<void>
|
||||
private resolveDisposed!: () => void
|
||||
/** Resolves when the driver loop has fully exited (tests/disposal). */
|
||||
done: Promise<void> = Promise.resolve()
|
||||
/**
|
||||
* Pending {@link whenIdle} waiters, resolved by {@link settleIdleWaiters} when
|
||||
* the agent next settles out of `running`. Kept as internal agent state (NOT
|
||||
* an effect-scoped `ctx.on` listener) so a concurrent fiber disposal — which
|
||||
* runs the agent's own listeners' disposers — cannot drop the waiter before
|
||||
* the `disposed` transition fires and leave the promise hanging.
|
||||
*/
|
||||
private idleWaiters: (() => void)[] = []
|
||||
|
||||
constructor(
|
||||
private ctx: Context,
|
||||
public readonly id: AgentId,
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
) {
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.disposed = promise
|
||||
this.resolveDisposed = resolve
|
||||
}
|
||||
|
||||
get status(): AgentStatus {
|
||||
return this._status
|
||||
}
|
||||
|
||||
private setStatus(status: AgentStatus): void {
|
||||
if (this._status === status || this._status === 'disposed') return
|
||||
this._status = status
|
||||
// Release quiescence waiters on a transition OUT of running BEFORE emitting
|
||||
// (the disposer handles the disposed transition separately). Settling first
|
||||
// means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
|
||||
// waiter (AGENTS.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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
private settleIdleWaiters(): void {
|
||||
const waiters = this.idleWaiters
|
||||
this.idleWaiters = []
|
||||
for (const resolve of waiters) resolve()
|
||||
}
|
||||
|
||||
private resolveSource(options?: SendOptions): MessageSource {
|
||||
return options?.source ?? { kind: 'user' }
|
||||
}
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
const source = this.resolveSource(options)
|
||||
this.inbox.enqueue({ content, source })
|
||||
this.ctx.emit('agent/queued', this, content, { source, steering: false })
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const source = this.resolveSource(options)
|
||||
this.inbox.steer({ content, source })
|
||||
this.ctx.emit('agent/queued', this, content, { source, steering: true })
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
const source = this.resolveSource(options)
|
||||
if (isTurnOpen(this.session)) {
|
||||
// A turn is open in the LOG (decided from the log, not agent status —
|
||||
// status can be `running` with no turn open): the context/message is
|
||||
// turn-enclosed by that turn, so append it directly.
|
||||
this.session.append('context/message', { content, source })
|
||||
return
|
||||
}
|
||||
// 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.)
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', { content, source })
|
||||
} 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.)
|
||||
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.
|
||||
}
|
||||
}
|
||||
// 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).
|
||||
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.
|
||||
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.
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abort(reason?: string): void {
|
||||
this.currentAbort?.abort(reason ?? 'aborted')
|
||||
}
|
||||
|
||||
cancel(reason?: string): void {
|
||||
// Arm-gate: only mark a cancellation when there is actually work to cancel —
|
||||
// a running turn, an in-flight step, or queued/steering work. An idle cancel
|
||||
// with nothing pending is a true no-op; arming the marker then would wrongly
|
||||
// drop the NEXT legitimate prompt (the marker is consumed only at the loop's
|
||||
// turn-decision points, which an idle parked loop does not reach until woken
|
||||
// by a real send()). Note the gate canNOT be `status === 'running'` alone:
|
||||
// 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) {
|
||||
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
|
||||
// below; the marker path reads it via the LoopHandle's cancelReason().
|
||||
this.cancelReason = reason ?? 'cancelled'
|
||||
}
|
||||
// Drop all pending queued + steering work (un-started prompts never run; the
|
||||
// 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()
|
||||
// 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).
|
||||
this.currentAbort?.abort(reason ?? 'cancelled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the agent has reached quiescence after settling out of
|
||||
* `running`. If it is already disposed, awaits {@link done} (the loop-exit
|
||||
* promise) — `agent/status('disposed')` fires in the disposer BEFORE the
|
||||
* driver loop has unwound, so it is NOT itself a quiescence signal. If it is
|
||||
* idle AND has no queued work, resolves immediately. Otherwise queues an
|
||||
* internal waiter (see {@link idleWaiters}) released on the next
|
||||
* running→idle/disposed transition, resolving on `idle` directly (the turn
|
||||
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
|
||||
* actually exit). Implements the {@link Agent.whenIdle} contract used by
|
||||
* teardown (`abort()` then `await whenIdle()`).
|
||||
*/
|
||||
whenIdle(): Promise<void> {
|
||||
if (this._status === 'disposed') return this.done
|
||||
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
|
||||
// could remove a `ctx.on` waiter before the `disposed` transition fires and
|
||||
// hang the promise. On disposal the disposer settles the waiter AND we chain
|
||||
// `done` here for true loop-exit quiescence (status flips to disposed before
|
||||
// the loop unwinds); a plain idle transition resolves directly.
|
||||
return new Promise<void>((resolve) => {
|
||||
this.idleWaiters.push(() => {
|
||||
resolve(this._status === 'disposed' ? this.done : undefined)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
start(): () => void {
|
||||
this.done = runLoop(this.ctx, this, {
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
setAbort: controller => void (this.currentAbort = controller),
|
||||
disposed: this.disposed,
|
||||
isDisposed: () => this._status === 'disposed',
|
||||
isCancelled: () => this.cancelRequested,
|
||||
cancelReason: () => this.cancelReason,
|
||||
clearCancel: () => { this.cancelRequested = false },
|
||||
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step
|
||||
// cancel-skip path drops the about-to-run turn and re-parks without ever
|
||||
// flipping running→idle, so a waiter registered in the pre-step window
|
||||
// (status idle, hasQueued was true) would otherwise hang. This emits no
|
||||
// agent/status, so an ACP agent/status listener never sees a spurious idle
|
||||
// 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
|
||||
this._status = 'disposed'
|
||||
this.resolveDisposed()
|
||||
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
|
||||
// internal state that must settle even if a listener throws below. Each
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
packages/core/agent-loop/src/inbox.ts
Normal file
75
packages/core/agent-loop/src/inbox.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Per-agent message inbox: queued and steering FIFOs. Purely an in-memory
|
||||
* mechanism of the loop driver — the public surface is `Agent.send()` and
|
||||
* `Agent.steer()`.
|
||||
*
|
||||
* @module dsh-agent-loop/inbox
|
||||
*/
|
||||
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** One message waiting in an agent's inbox. */
|
||||
export interface InboxMessage {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO
|
||||
* (drained between steps of a running turn). Purely an in-memory mechanism of
|
||||
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
|
||||
*/
|
||||
export class Inbox {
|
||||
private queuedMessages: InboxMessage[] = []
|
||||
private steeringMessages: InboxMessage[] = []
|
||||
private wakeup: (() => void) | undefined
|
||||
|
||||
/** Resolves when a queued message arrives (used by the idle loop). */
|
||||
get hasQueued(): boolean {
|
||||
return this.queuedMessages.length > 0
|
||||
}
|
||||
|
||||
get hasSteering(): boolean {
|
||||
return this.steeringMessages.length > 0
|
||||
}
|
||||
|
||||
enqueue(message: InboxMessage): void {
|
||||
this.queuedMessages.push(message)
|
||||
this.wakeup?.()
|
||||
}
|
||||
|
||||
steer(message: InboxMessage): void {
|
||||
this.steeringMessages.push(message)
|
||||
}
|
||||
|
||||
/** Drain all queued messages (turn start). */
|
||||
drainQueued(): InboxMessage[] {
|
||||
return this.queuedMessages.splice(0)
|
||||
}
|
||||
|
||||
/** Drain all steering messages (between steps). */
|
||||
drainSteering(): InboxMessage[] {
|
||||
return this.steeringMessages.splice(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard all pending messages (queued + steering) without delivering them —
|
||||
* used by `cancel()`, which drops un-started work rather than draining it into
|
||||
* a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away.
|
||||
*/
|
||||
clear(): void {
|
||||
this.queuedMessages.length = 0
|
||||
this.steeringMessages.length = 0
|
||||
}
|
||||
|
||||
/** Wait until a queued message arrives or `cancel` resolves. */
|
||||
waitForQueued(cancel: Promise<void>): Promise<void> {
|
||||
if (this.hasQueued) return Promise.resolve()
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.wakeup = resolve
|
||||
void cancel.then(resolve)
|
||||
return promise.finally(() => {
|
||||
if (this.wakeup === resolve) this.wakeup = undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
292
packages/core/agent-loop/src/index.ts
Normal file
292
packages/core/agent-loop/src/index.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-loop
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { ReactLoopAgent } from './agent.ts'
|
||||
|
||||
export { ReactLoopAgent } from './agent.ts'
|
||||
export { Inbox, type InboxMessage } from './inbox.ts'
|
||||
export { runLoop } from './loop.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
}
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
/** Agents created from configuration at startup. */
|
||||
agents: (AgentOptions & {
|
||||
id: 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.
|
||||
*/
|
||||
resumeSessionId?: string
|
||||
})[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class AgentLoop extends Service implements AgentFactory {
|
||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
model: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
})).default([]),
|
||||
})
|
||||
|
||||
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()')
|
||||
for (const { id, 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 {
|
||||
this.create(id, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-driven create: an agent on a FRESH, non-colliding session id per run
|
||||
* (`${id}-session-<uuid>`, no cwd). 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.
|
||||
*
|
||||
* TODO(sub-agents): spawn/fork land here — accept a parent agent reference;
|
||||
* fork seeds the new Session with the parent's event log, spawn starts
|
||||
* fresh; the child is returned as a regular Agent handle.
|
||||
*/
|
||||
create(id: string, options: AgentOptions = {}): 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(`${id}-session-${randomUUID()}`, { meta: {} })
|
||||
const { agent } = this.start(AgentId(id), options, session)
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic factory create ({@link AgentFactory}): an agent on a
|
||||
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
|
||||
* metadata (validated `cwd`, lineage). The ACP bridge uses this so the
|
||||
* client-generated session id becomes the live/persisted session id. Returns
|
||||
* an {@link AgentHandle} the owner disposes to tear down exactly this agent.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle {
|
||||
// Check the agent id BEFORE preparing the session: register() would reject a
|
||||
// duplicate id only AFTER the session enters the store, leaving an orphaned
|
||||
// live session (and lazy persistence state) that blocks reuse of that id.
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} })
|
||||
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
return this.resumeWith(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(SessionId(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 } : {},
|
||||
},
|
||||
})
|
||||
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: string): void {
|
||||
if (this.ctx.agents.get(id) !== undefined) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
|
||||
*/
|
||||
private start(id: AgentId, options: AgentOptions, session: Session): { 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)
|
||||
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): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, session)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { agent, dispose: () => (disposing ??= disposeAgent()) }
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentLoop
|
||||
698
packages/core/agent-loop/src/loop.ts
Normal file
698
packages/core/agent-loop/src/loop.ts
Normal file
@@ -0,0 +1,698 @@
|
||||
/**
|
||||
* The agent loop driver: one `runLoop()` invocation drives one agent for its
|
||||
* whole lifetime. Error-contained at the turn level — a throwing plugin ends
|
||||
* the turn, never kills the loop. See the JSDoc on `runLoop()` for the full
|
||||
* lifecycle pseudo-code.
|
||||
*
|
||||
* @module dsh-agent-loop/loop
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
|
||||
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||
type CodedError = Error & { code?: string }
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary thrown value into a coded Error. A real Error passes
|
||||
* through (its `code`, if any, is preserved by {@link errorData}); a non-Error
|
||||
* throw is wrapped in a {@link HarnessError} with code `UNKNOWN` and the
|
||||
* original value chained as `cause`, so a bad throw still carries a routable
|
||||
* code instead of degrading to a bare message.
|
||||
*/
|
||||
function toError(error: unknown): CodedError {
|
||||
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a model-call {@link FinishReason} to the step error it should raise, or
|
||||
* `undefined` when the step completed normally.
|
||||
*
|
||||
* Adapters report provider/transport failures one of two sanctioned ways (see
|
||||
* the StreamChunk contract in dsh-llm): throw from `stream()` (handled by the
|
||||
* caller's try/catch), OR end the stream with a finish-error/aborted chunk
|
||||
* (the only option for adapters that can't throw mid-stream, e.g.
|
||||
* library-backed ones). This translates the latter into a thrown step error
|
||||
* so the turn ends error/aborted with a logged `error` event, never as a
|
||||
* normal `completed` assistant message.
|
||||
*
|
||||
* `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so
|
||||
* the switch handles the known terminal-failure kinds and treats every other
|
||||
* kind — `stop`, `tool-calls`, `max-tokens`, future additions — as success.
|
||||
*/
|
||||
function finishError(finish: FinishReason): CodedError | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
const error: CodedError = new Error(finish.message)
|
||||
if (finish.code !== undefined) error.code = finish.code
|
||||
return error
|
||||
}
|
||||
case 'aborted': {
|
||||
const error: CodedError = new Error('model stream aborted')
|
||||
error.code = 'ABORTED'
|
||||
return error
|
||||
}
|
||||
// stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `{ message, code? }` part of an error payload, omitting the
|
||||
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
|
||||
*/
|
||||
function errorData(err: CodedError): { message: string; code?: string } {
|
||||
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn-end contribution of a step's *successful* finish, or `undefined`
|
||||
* when the step finished ordinarily (a plain `completed`).
|
||||
*
|
||||
* {@link finishError} has already converted `error`/`aborted` finishes into
|
||||
* thrown step errors, so the finishes that reach here are `stop`,
|
||||
* `tool-calls`, `max-tokens`, or a future merge-extensible kind. Only
|
||||
* `max-tokens` carries forward as a distinct {@link TurnEndReason}: a step that
|
||||
* hit the output-token ceiling ended the turn cut-short rather than by the
|
||||
* model's choice. `stop`/`tool-calls`/unknown kinds contribute nothing beyond
|
||||
* the default `completed`. {@link runTurn} applies this with the rule "any
|
||||
* `max-tokens` step in the turn makes the turn end `max-tokens`".
|
||||
*/
|
||||
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'max-tokens':
|
||||
return { kind: 'max-tokens' }
|
||||
// stop / tool-calls / plugin-added kinds → no turn-end contribution
|
||||
// beyond the default `completed`. FinishReason is merge-extensible, so a
|
||||
// default (not assertNever) handles unknown kinds as ordinary success.
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambient handles the loop driver receives from the agent. Decouples the
|
||||
* pure function `runLoop` from the mutable ReactLoopAgent fields, making the
|
||||
* loop testable without a real agent.
|
||||
*/
|
||||
export interface LoopHandle {
|
||||
setStatus(status: 'idle' | 'running'): void
|
||||
setAbort(controller: AbortController | undefined): void
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
disposed: Promise<void>
|
||||
isDisposed(): boolean
|
||||
/**
|
||||
* Whether a `cancel()` is pending for the current turn. The driver checks this
|
||||
* at every decision point where a turn could start or continue (right after
|
||||
* the idle wait, after the `running` flip, before each step, and at the
|
||||
* continuation gate) and drops the about-to-run / continuing turn. Reset once
|
||||
* per loop iteration via {@link clearCancel} after the turn returns, so the
|
||||
* marker governs exactly one cancellation and never leaks to a later prompt.
|
||||
*/
|
||||
isCancelled(): boolean
|
||||
/**
|
||||
* The resolved reason for the pending cancel (`reason ?? 'cancelled'`), read
|
||||
* by the marker branches (pre-step / continuation) so a turn dropped where no
|
||||
* `AbortController` carries the reason still records the caller's
|
||||
* `cancel(reason)` value — matching the mid-step abort path. Only meaningful
|
||||
* when {@link isCancelled} is true.
|
||||
*/
|
||||
cancelReason(): string
|
||||
/** Clear the cancel marker (called once per iteration after the turn returns). */
|
||||
clearCancel(): void
|
||||
/**
|
||||
* Settle pending `whenIdle()` waiters WITHOUT a status transition. Used by the
|
||||
* pre-step cancel-skip path: it drops the about-to-run turn and re-parks at the
|
||||
* idle wait, so no `running→idle` transition fires to settle a `whenIdle()`
|
||||
* waiter that was registered in the pre-step window — this settles it directly
|
||||
* (it emits no `agent/status`, so an ACP `agent/status` listener never sees a
|
||||
* spurious idle that would resolve a freshly-queued prompt as cancelled).
|
||||
*/
|
||||
settleIdle(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent loop. One invocation drives one agent for its whole lifetime:
|
||||
*
|
||||
* ```
|
||||
* forever:
|
||||
* wait for queued messages (idle)
|
||||
* TURN (error-contained — a throwing plugin ends the turn, never the loop):
|
||||
* drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
|
||||
* STEP loop:
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
|
||||
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
||||
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
||||
* req = waterfall agent/request ⟵ hooks/compaction/model-switch
|
||||
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
|
||||
* session('assistant/chunk'); emit agent/stream-chunk
|
||||
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
|
||||
* session('assistant/message','usage') session records what actually ran
|
||||
* each tool-call in msg (sequential, abort-checked):
|
||||
* session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute
|
||||
* session('tool/result')
|
||||
* drain steering → session('steering/message'); emit agent/steering
|
||||
* emit agent/step-end
|
||||
* cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
|
||||
* if !cont && steering arrived from step-end/continuation listeners: cont = true
|
||||
* if !cont: break
|
||||
* session('turn/end'); emit agent/turn-end
|
||||
* await ctx.parallel('session/flush', session) ⟵ durability checkpoint
|
||||
* re-enqueue leftover steering as queued ⟵ steering is never stranded
|
||||
* idle (emit agent/status) unless more queued
|
||||
* ```
|
||||
*/
|
||||
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
|
||||
const { session } = agent
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
await agent.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
|
||||
// idle wait but before we flip to `running`. The cancelled queued/steering
|
||||
// work is already cleared by `cancel()`. Clear the marker, then:
|
||||
// - if NOTHING new is queued, drop the about-to-run turn and re-park,
|
||||
// settling any `whenIdle()` waiter DIRECTLY (no running→idle transition
|
||||
// fires here to settle it) and WITHOUT emitting `agent/status` (an ACP
|
||||
// listener must not see a spurious idle that resolves a freshly-queued
|
||||
// prompt as cancelled);
|
||||
// - if a NEW prompt was queued AFTER the cancel (a send() that raced in
|
||||
// before the loop resumed), the marker was for the cancelled work only —
|
||||
// fall through and run the new prompt's turn. Do NOT settle waiters here:
|
||||
// a whenIdle() waiter must wait for that new turn's running→idle, not
|
||||
// resolve before it runs (the quiescence contract).
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!agent.inbox.hasQueued) {
|
||||
handle.settleIdle()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
handle.setStatus('running')
|
||||
|
||||
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status`
|
||||
// SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the
|
||||
// check above and `runTurn`. Mirror window 1: clear the marker, then
|
||||
// - if NOTHING new is queued, drop the about-to-run turn and transition
|
||||
// back to `idle` (`running` was already emitted, so a real idle
|
||||
// transition balances the status AND settles `whenIdle()` waiters);
|
||||
// - if a NEW prompt was queued AFTER the cancel (a `running` listener that
|
||||
// cancels then sends), the marker was for the cancelled work only — fall
|
||||
// through and run the new prompt's turn (status is already `running`), so
|
||||
// a `whenIdle()` waiter resolves on THAT turn's running→idle, not before
|
||||
// it runs. Settling here would resolve quiescence while the replacement
|
||||
// is still queued and unrun (the same early-resolve race window 1 fixes).
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!agent.inbox.hasQueued) {
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Re-derive the turn number from the log each iteration (do NOT keep a local
|
||||
// counter): an idle `agent.inject()` can append its own one-shot turn while
|
||||
// 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
|
||||
try {
|
||||
await runTurn(ctx, agent, handle, turn)
|
||||
} 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
|
||||
// none is owed. A session `error` here would land outside any turn (after
|
||||
// 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.
|
||||
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)
|
||||
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
||||
}
|
||||
|
||||
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
|
||||
// before the next iteration's idle wait. NOT gated on the idle transition
|
||||
// below: a `send()` that lands during the cancelled turn's flush window makes
|
||||
// `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset
|
||||
// would never fire and the stale marker would wrongly drop that next prompt's
|
||||
// turn. Resetting per iteration scopes the marker to exactly the turn that was
|
||||
// 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)
|
||||
}
|
||||
|
||||
if (!agent.inbox.hasQueued) handle.setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number): Promise<void> {
|
||||
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 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')
|
||||
const trigger: TurnTrigger = { kind: 'message', source: first.source }
|
||||
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
let step = 0
|
||||
let turnEnded = false
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
|
||||
// Close the open step exactly once (idempotent via stepOpen). The
|
||||
// agent/step-end emit is contained: a throwing step-end listener must not
|
||||
// abort finalization and strand the turn open (turn/end balance > notifying
|
||||
// one bad listener). Appended before the emit (the event-sourcing RFC append-before-emit).
|
||||
const closeStep = (): boolean => {
|
||||
if (!stepOpen) return false
|
||||
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 — the same outcome as a throwing agent/step-end listener.
|
||||
let failure: unknown
|
||||
try {
|
||||
session.append('step/end', { turn, step })
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
try {
|
||||
ctx.emit('agent/step-end', agent, turn, step)
|
||||
} catch (error: unknown) {
|
||||
failure ??= error
|
||||
}
|
||||
// A throwing step/end session-event listener OR a throwing agent/step-end
|
||||
// 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: append the single `error` event
|
||||
// (only while the turn is still open — see below), set the error reason, and
|
||||
// emit agent/error (contained — trap: a throwing agent/error listener must not
|
||||
// re-escape and strand the turn). Disposal and abort set `reason` directly
|
||||
// without calling this (no `error` event for those — they are not failures).
|
||||
const failTurn = (err: CodedError): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
// Only append the session `error` INSIDE the turn (before turn/end). If the
|
||||
// turn has already ended — the only way here is a throwing agent/turn-end
|
||||
// listener after closeTurn(true) already appended turn/end — appending now
|
||||
// would land the error AFTER the last turn/end, where the persistence
|
||||
// backend treats it as a crash tail and drops it on resume (the turn-enclosure RFC). In
|
||||
// that case report via agent/error + the logger only; the turn is balanced.
|
||||
if (!turnEnded) {
|
||||
// Set `reason` BEFORE the append: Session.append pushes the error event
|
||||
// before notifying session/event listeners, so a throwing listener would
|
||||
// otherwise leave `reason` unset (and closeTurn would record the wrong
|
||||
// reason / the outer catch would skip closeTurn). The append is contained
|
||||
// — the error event is already in the log either way; a throwing listener
|
||||
// must not abort finalization.
|
||||
reason = { kind: 'error', ...errorData(err) }
|
||||
try {
|
||||
session.append('error', { turn, step, ...errorData(err) })
|
||||
} catch (appendError: unknown) {
|
||||
ctx.logger.warn(`agent "${agent.id}": session/event listener threw on the error event at turn ${turn}: ${toError(appendError).message}`)
|
||||
}
|
||||
} else {
|
||||
ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`)
|
||||
}
|
||||
try {
|
||||
ctx.emit('agent/error', agent, turn, step, err)
|
||||
} catch {
|
||||
// contained: the error is already logged; a throwing agent/error
|
||||
// listener must not prevent the turn from closing.
|
||||
}
|
||||
}
|
||||
|
||||
// Close the turn exactly once (idempotent via turnEnded). `emit` is false on
|
||||
// the error path (the failure was already surfaced via agent/error) and true
|
||||
// on the normal/inline-error path. A throwing agent/turn-end listener on the
|
||||
// normal path escapes to the outer catch, which surfaces it via failTurn —
|
||||
// turn/end is already appended, so balance holds either way.
|
||||
const closeTurn = (emit: boolean): void => {
|
||||
if (turnEnded) return
|
||||
turnEnded = true
|
||||
// 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's closeTurn(false) it
|
||||
// would propagate to the runLoop backstop, and from the normal-path
|
||||
// closeTurn(true) it would skip the agent/turn-end emit. Contain it: the
|
||||
// boundary is durable either way, and finalization must not abort on a bad
|
||||
// listener. (On the normal path the outer catch also re-runs closeTurn,
|
||||
// which is an idempotent no-op once turnEnded is set.)
|
||||
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}`)
|
||||
}
|
||||
if (emit) ctx.emit('agent/turn-end', agent, 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).
|
||||
session.append('turn/start', { turn, trigger })
|
||||
// Record the queued user messages INSIDE the turn (after turn/start), so
|
||||
// every event in the log is turn-enclosed. turn/end is now owed, so a throw
|
||||
// while appending these is caught below and the turn is still closed.
|
||||
for (const message of queued) {
|
||||
session.append('user/message', { content: message.content, source: message.source })
|
||||
}
|
||||
ctx.emit('agent/turn-start', agent, turn)
|
||||
|
||||
while (true) {
|
||||
step += 1
|
||||
|
||||
// Steering from the previous round's step-end/continuation listeners
|
||||
// (or turn-start listeners on the first step) joins before the request.
|
||||
drainSteering(ctx, agent, turn)
|
||||
|
||||
session.append('step/start', { turn, step })
|
||||
stepOpen = true
|
||||
ctx.emit('agent/step-start', agent, turn, step)
|
||||
|
||||
const abort = new AbortController()
|
||||
handle.setAbort(abort)
|
||||
|
||||
// Cancel landing in the step-start window: a synchronous `agent/turn-start`
|
||||
// or `agent/step-start` listener (both fire before this point) can have
|
||||
// called `cancel()`, and `runStep` would otherwise run a full extra step
|
||||
// with no AbortController having observed it. Check the marker AFTER
|
||||
// setAbort (so the next-iteration drain sees a clean controller) and before
|
||||
// `runStep`: drop the step, end the turn `aborted`. closeStep balances the
|
||||
// already-appended step/start.
|
||||
if (handle.isCancelled()) {
|
||||
handle.setAbort(undefined)
|
||||
reason = { kind: 'aborted', reason: handle.cancelReason() }
|
||||
closeStep()
|
||||
break
|
||||
}
|
||||
|
||||
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
|
||||
try {
|
||||
stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
} finally {
|
||||
handle.setAbort(undefined)
|
||||
}
|
||||
|
||||
if ('error' in stepOutcome) {
|
||||
// Steering that arrived during the failed step stays in the inbox —
|
||||
// runLoop re-enqueues it as a queued message, so an abort-then-steer
|
||||
// starts a fresh turn instead of being silently consumed.
|
||||
closeStep()
|
||||
const { error } = stepOutcome
|
||||
if (handle.isDisposed()) {
|
||||
reason = { kind: 'disposed' }
|
||||
} else if (abort.signal.aborted) {
|
||||
/* v8 ignore next -- abort.signal.reason always set by agent.abort() which provides a default */
|
||||
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
|
||||
} else {
|
||||
failTurn(error)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// The successful step's finish reason carries forward: a `max-tokens`
|
||||
// step makes the whole turn end `max-tokens` (the ACP RFC's rule "any
|
||||
// max-tokens step surfaces as max-tokens"). `stepFinishReason` returns
|
||||
// `max-tokens` or `undefined`, so a later ordinary step never resets a
|
||||
// max-tokens turn back to completed, and a never-truncated turn keeps the
|
||||
// default `completed`. The disposal/abort/error branches above and the
|
||||
// continuation-window disposal check below override this — they win.
|
||||
const stepReason = stepFinishReason(stepOutcome.finish)
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(ctx, agent, turn)
|
||||
|
||||
if (closeStep()) break
|
||||
|
||||
const defaultDecision = stepOutcome.hadToolCalls || steered
|
||||
let shouldContinue: boolean
|
||||
try {
|
||||
shouldContinue = await ctx.waterfall(
|
||||
'agent/turn-continuation', agent, turn, defaultDecision,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// A broken continuation plugin ends the turn, not the loop.
|
||||
failTurn(toError(error))
|
||||
break
|
||||
}
|
||||
|
||||
// Steering from step-end/continuation listeners (the /goal pattern)
|
||||
// demands the model see it — it overrides a negative decision; the
|
||||
// next iteration's drain records it.
|
||||
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
|
||||
|
||||
// A cancel that landed during the continuation window — after the step's
|
||||
// AbortController was cleared (setAbort(undefined)) but before the next
|
||||
// step starts — has no controller to observe it, so the turn-scoped marker
|
||||
// ends the turn here. cancel() also cleared the steering FIFO, so the
|
||||
// override above did not re-arm continuation.
|
||||
if (handle.isCancelled()) {
|
||||
reason = { kind: 'aborted', reason: handle.cancelReason() }
|
||||
break
|
||||
}
|
||||
|
||||
if (!shouldContinue || handle.isDisposed()) {
|
||||
/* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */
|
||||
if (handle.isDisposed()) reason = { kind: 'disposed' }
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Normal / inline-error loop exit: close the turn and notify.
|
||||
closeTurn(true)
|
||||
} 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
|
||||
// (or was already appended — closeTurn/failTurn are idempotent, so running
|
||||
// them again is a safe no-op that still preserves the disposed/error reason
|
||||
// chosen below). Absent means the turn/start append threw BEFORE its push (a
|
||||
// non-serializable trigger — impossible for our fixed trigger); nothing was
|
||||
// opened, so rethrow to the runLoop backstop.
|
||||
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
if (!turnStartLogged) throw error
|
||||
closeStep()
|
||||
// Choose the close reason. Disposal wins only if no error was already
|
||||
// reported: a turn disposed mid-step sets reason=disposed in the step-error
|
||||
// branch (without reporting an error), and if closeTurn(true)'s turn-end
|
||||
// emit then throws, we land here and must PRESERVE disposed rather than
|
||||
// overwrite it with the listener's throw. Otherwise a boundary-emit throw
|
||||
// on a live agent is a real failure → failTurn. (errorReported is mutated
|
||||
// only inside the failTurn closure, which the analyzer can't follow, hence
|
||||
// the inline lint-disable.)
|
||||
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
reason = { kind: 'disposed' }
|
||||
} else {
|
||||
failTurn(toError(error))
|
||||
}
|
||||
closeTurn(false)
|
||||
}
|
||||
|
||||
// Durability checkpoint: persistence plugins drain write-behind buffers.
|
||||
// A failing persistence plugin is reported but doesn't kill the agent.
|
||||
try {
|
||||
await ctx.parallel('session/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
|
||||
// for a session `error` event. Appending one here would land it after the
|
||||
// last turn/end, where the persistence backend treats it as a crash tail
|
||||
// and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report
|
||||
// the failure via agent/error + the logger only; persistence keeps the
|
||||
// buffered events for the next flush/dispose, so nothing is lost.
|
||||
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)
|
||||
} catch {
|
||||
// contained: a throwing agent/error listener must not escape the loop.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source })
|
||||
ctx.emit('agent/steering', agent, turn, message.content, message.source)
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
/** One step: assemble request → stream model → record → execute tools. */
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
agent: ReactLoopAgent,
|
||||
turn: number,
|
||||
step: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
|
||||
const { session, options } = agent
|
||||
|
||||
// --- Request assembly ---
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const system = [renderPrompt(assembly), options.systemPrompt ?? '']
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
|
||||
let request: GenerateOptions = {
|
||||
model: options.model ?? '',
|
||||
messages: session.deriveMessages(),
|
||||
...system ? { system } : {},
|
||||
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
||||
signal,
|
||||
}
|
||||
request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))
|
||||
if (!request.model) {
|
||||
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
||||
}
|
||||
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of ctx.llm.stream(request)) {
|
||||
/* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
session.append('assistant/chunk', { turn, step, chunk })
|
||||
ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
|
||||
// Adapters report provider/transport failures one of two sanctioned ways
|
||||
// (see the StreamChunk contract in dsh-llm): throw from stream() — already
|
||||
// handled by the caller's try/catch — OR end the stream with a
|
||||
// finish-error/aborted chunk. finishError() maps the latter to the step
|
||||
// error to raise (turn ends error/aborted, not a normal completed message).
|
||||
const stepError = finishError(assembler.finish)
|
||||
if (stepError) throw stepError
|
||||
|
||||
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)))
|
||||
if (message.content.length > 0) {
|
||||
session.append('assistant/message', { turn, step, content: message.content })
|
||||
}
|
||||
if (assembler.usage) {
|
||||
session.append('usage', { turn, step, usage: assembler.usage })
|
||||
}
|
||||
return { hadToolCalls: false, finish: assembler.finish }
|
||||
}
|
||||
|
||||
// The step-result waterfall runs BEFORE the session append so the log (the
|
||||
// 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))
|
||||
|
||||
session.append('assistant/message', { turn, step, content: message.content })
|
||||
if (assembler.usage) {
|
||||
session.append('usage', { turn, step, usage: assembler.usage })
|
||||
}
|
||||
|
||||
// --- Tool execution (sequential; parallel execution is a TODO) ---
|
||||
// ToolRegistry.execute converts tool failures (including aborts) into
|
||||
// isError results, so abort is re-checked around every call here.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
|
||||
let parsedArguments: unknown
|
||||
try {
|
||||
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
|
||||
} catch {
|
||||
parsedArguments = call.arguments
|
||||
}
|
||||
const result = await ctx.tools.execute({
|
||||
callId: call.id,
|
||||
name: call.name,
|
||||
arguments: parsedArguments,
|
||||
agent,
|
||||
signal,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn, step,
|
||||
// The correlation id MUST be the loop's authoritative call.id (the
|
||||
// model-transcript id that deriveMessages turns into toolCallId), NOT
|
||||
// result.callId — a tools/execute waterfall listener returning a
|
||||
// mismatched id would otherwise orphan the call↔result pairing in the
|
||||
// next model request. A listener-internal id, if ever needed, belongs in
|
||||
// a separate diagnostic field, never overloaded onto callId.
|
||||
callId: call.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
})
|
||||
// signal CAN flip during the await above (abort() inside a tool);
|
||||
// the analyzer can't see through the await boundary.
|
||||
/* v8 ignore start -- signal.reason default unreachable via agent.abort() */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
|
||||
}
|
||||
|
||||
function withoutToolCalls(message: Message): Message {
|
||||
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
|
||||
}
|
||||
|
||||
/** The last turn number in a (possibly seeded) session log, or 0. */
|
||||
export function lastTurnNumber(session: Session): number {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
return lastStart?.data.turn ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a turn is currently open in the session log (a `turn/start` with no
|
||||
* matching later `turn/end`). Decided from the LOG, not agent status: status
|
||||
* can be `running` while no turn is open (an `agent/status` listener firing
|
||||
* before `turn/start`, or the post-`turn/end` flush window before status
|
||||
* returns to idle), so status is not a reliable open-turn signal. Used by
|
||||
* `inject()` to choose between appending into an open turn vs. wrapping the
|
||||
* injection in its own one-shot turn (the turn-enclosure RFC).
|
||||
*/
|
||||
export function isTurnOpen(session: Session): boolean {
|
||||
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
|
||||
return last?.type === 'turn/start'
|
||||
}
|
||||
449
packages/core/agent-loop/tests/agent.spec.ts
Normal file
449
packages/core/agent-loop/tests/agent.spec.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
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 { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
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(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === expected) {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('ReactLoopAgent', () => {
|
||||
it('send() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create('scoped', { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
|
||||
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('steer() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create('scoped', { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
|
||||
expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('inject() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create('scoped', { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
|
||||
expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// Simulate an OPEN turn in the log while the agent is idle (status is not a
|
||||
// reliable open-turn signal). inject must append into that open turn, NOT
|
||||
// wrap a new one.
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.at(-1)!.type).toBe('context/message')
|
||||
|
||||
// Close the turn; now inject must wrap its own one-shot injection turn.
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
const starts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(starts).toHaveLength(2)
|
||||
const last = starts[1]!
|
||||
expect(last.type === 'turn/start' && last.data.trigger.kind).toBe('injection')
|
||||
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
|
||||
})
|
||||
|
||||
it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
// A persistence-like listener whose flush rejects.
|
||||
ctx.on('session/flush', () => { throw new Error('disk gone') })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
|
||||
// flush must be contained (logged), never thrown into the caller.
|
||||
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
|
||||
await new Promise(r => setTimeout(r, 20)) // let the contained flush settle
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
// Non-serializable injected content makes Session.append throw AFTER
|
||||
// turn/start was recorded. The turn/end must still be appended (finally),
|
||||
// AND the durability checkpoint must still fire — the balanced turn is in
|
||||
// memory and a crash before the next turn/dispose would otherwise lose it.
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn
|
||||
await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run
|
||||
expect(flushes).toBe(1) // checkpoint fired despite the throw
|
||||
})
|
||||
|
||||
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('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.
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
|
||||
})
|
||||
|
||||
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toEqual(['turn/start', 'context/message', 'turn/end']) // balanced
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener
|
||||
})
|
||||
|
||||
it('idle inject() reports a failing flush via agent/error (step 0) AND the logger', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
// A non-Error rejection exercises the String() normalization branch.
|
||||
ctx.on('session/flush', () => { throw 'disk gone' })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
const errors: { turn: number; step: number; message: string }[] = []
|
||||
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
|
||||
|
||||
agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
await new Promise(r => setTimeout(r, 20)) // let the contained flush settle
|
||||
|
||||
// Reported via agent/error (step 0 — the idle-injection convention) so
|
||||
// plugins monitoring agent/error see idle-injection persistence failures,
|
||||
// mirroring the loop's post-turn/end flush path. A non-Error throw is
|
||||
// normalized to an Error.
|
||||
expect(errors).toEqual([{ turn: 1, step: 0, message: 'disk gone' }])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// A non-serializable source makes the turn/start append throw BEFORE the
|
||||
// event is pushed (Session.append validates before push), so NO turn opens.
|
||||
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
|
||||
// the log stays empty, not left with a dangling turn/start.
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('steer() when idle falls through to send() and starts a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// steer while idle delegates to send
|
||||
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The message was recorded as a user-level message (send path)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
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.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create('test')
|
||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
|
||||
// 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()
|
||||
|
||||
// First dispose
|
||||
dispose()
|
||||
expect(agent.status).toBe('disposed')
|
||||
|
||||
// Second dispose — idempotent, no throw
|
||||
expect(() => { dispose() }).not.toThrow()
|
||||
expect(agent.status).toBe('disposed')
|
||||
})
|
||||
|
||||
it('setting the same status does not emit agent/status again', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// After the turn, agent is idle. Send again to trigger another attempt
|
||||
// to go idle — but it's already idle, so no emission.
|
||||
const idleTransitionCount = statuses.filter(s => s === 'idle').length
|
||||
expect(idleTransitionCount).toBe(1) // only the final transition from running
|
||||
})
|
||||
|
||||
it('whenIdle() resolves immediately when the agent is not running', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// Fresh agent is idle — whenIdle() takes the not-running fast path and
|
||||
// resolves without subscribing. await must not hang.
|
||||
await agent.whenIdle()
|
||||
expect(agent.status).not.toBe('running')
|
||||
})
|
||||
|
||||
it('whenIdle() waits for queued work that has not flipped status yet', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'queued')
|
||||
let settled = false
|
||||
const idle = agent.whenIdle().then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
await waitForStatus(ctx, agent, 'running')
|
||||
agent.abort('done')
|
||||
await idle
|
||||
expect(settled).toBe(true)
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
const other = ctx.agentLoop.create('a2', { model: 'mock' })
|
||||
|
||||
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
|
||||
// agent/status and resolves on the first transition out of running.
|
||||
const running = new Promise<void>((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'running') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
send(agent, 'go')
|
||||
await running
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// While `agent`'s whenIdle is pending, churn `other` through running→idle:
|
||||
// every status event it emits hits whenIdle's guard with `subject !== this`,
|
||||
// so the wait must ignore them and only resolve on `agent`'s own idle.
|
||||
send(other, 'go')
|
||||
|
||||
await agent.whenIdle()
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
|
||||
// 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.
|
||||
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 adapter = new MockAdapter(['hang'])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const session = ctx.sessions.create('bare')
|
||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const dispose = agent.start()
|
||||
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
|
||||
await idle
|
||||
expect(agent.status).toBe('disposed')
|
||||
await agent.done
|
||||
})
|
||||
|
||||
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
|
||||
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
|
||||
// disposing the OWNING fiber runs the agent's listener disposers, which would
|
||||
// have dropped a ctx.on-based waiter before the 'disposed' transition and
|
||||
// hung the promise. With internal waiters, the fiber disposer still settles
|
||||
// it. Regression for the round-3 whenIdle finding.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create('scoped', { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
const idle = agent.whenIdle() // queued while running
|
||||
await fiber.dispose() // tears the fiber down (drops agent listeners)
|
||||
await idle // must resolve, not hang
|
||||
expect(agent.status).toBe('disposed')
|
||||
})
|
||||
|
||||
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
|
||||
// The disposer emits agent/status('disposed') BEFORE the driver loop
|
||||
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
|
||||
// disposed path. Dispose a running agent, then assert whenIdle() resolves
|
||||
// only after `done` — i.e. the loop has actually exited.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create('scoped', { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
let doneResolved = false
|
||||
void agent.done.then(() => { doneResolved = true })
|
||||
await fiber.dispose() // sets status disposed, aborts, drains the loop
|
||||
expect(agent.status).toBe('disposed')
|
||||
|
||||
// whenIdle() must not resolve before `done` has — chaining `done` is the
|
||||
// quiescence guarantee. By here dispose() awaited the loop, so done is
|
||||
// settled; whenIdle resolves and done is observed resolved.
|
||||
await agent.whenIdle()
|
||||
expect(doneResolved).toBe(true)
|
||||
})
|
||||
|
||||
it('contains a throwing agent/status listener on the running transition', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
ctx.on('agent/status', (_subject, status) => {
|
||||
if (status === 'running') throw new Error('bad running listener')
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('contains a throwing agent/status listener on the idle transition', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
ctx.on('agent/status', (_subject, status) => {
|
||||
if (status === 'idle') throw new Error('bad idle listener')
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('abort() resolves reason to "aborted" when no reason provided', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const reasons: { kind: string; reason?: string }[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.abort() // no reason string
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons[0]).toMatchObject({ kind: 'aborted', reason: 'aborted' })
|
||||
})
|
||||
})
|
||||
337
packages/core/agent-loop/tests/cancel.spec.ts
Normal file
337
packages/core/agent-loop/tests/cancel.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the
|
||||
* broad verb — it clears queued + steering work, aborts an in-flight step, and
|
||||
* drops a turn about to start — whereas `abort()` kills only the current step.
|
||||
* These tests exercise every window where a cancel can land (idle, pre-step,
|
||||
* mid-step, continuation) and the marker's arm/reset rules that keep a cancel
|
||||
* from leaking to a later prompt or hanging `whenIdle()`.
|
||||
*
|
||||
* @module dsh-agent-loop/tests/cancel
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
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 { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
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(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** Resolve on the agent's next idle transition (event-based, not status poll). */
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** All user-message texts recorded in the log (to assert what actually ran). */
|
||||
function userTexts(agent: ReactLoopAgent): string[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'user/message')
|
||||
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
|
||||
.flatMap(b => b.type === 'text' ? [b.text] : [])
|
||||
}
|
||||
|
||||
describe('Agent.cancel()', () => {
|
||||
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// The loop is parked at the idle wait with nothing queued. A cancel here must
|
||||
// NOT arm the marker — otherwise the next legitimate prompt would be dropped.
|
||||
agent.cancel('nothing to cancel')
|
||||
|
||||
send(agent, 'real prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The prompt ran: its user message is in the log and one turn completed.
|
||||
expect(userTexts(agent)).toEqual(['real prompt'])
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
})
|
||||
|
||||
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// send() queues synchronously (status still idle, loop microtask not yet
|
||||
// resumed). Cancel in that pre-step window: the queued turn must not run.
|
||||
send(agent, 'drop me')
|
||||
agent.cancel('pre-step')
|
||||
|
||||
// Give the loop a chance to wake and process the cancel.
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
// No turn was opened — the queued prompt was dropped, never recorded.
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('x')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// Queue work, then register a whenIdle() waiter while in the pre-step window
|
||||
// (status idle, hasQueued true) — it does NOT take the fast path. Then cancel.
|
||||
// The skip path must settle this waiter directly (no running→idle transition
|
||||
// ever fires), or it would hang forever.
|
||||
send(agent, 'q')
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel('pre-step')
|
||||
|
||||
// Must resolve (not hang). A timeout makes the failure a clear test failure.
|
||||
await Promise.race([
|
||||
idle,
|
||||
new Promise((_r, reject) => setTimeout(() => { reject(new Error('whenIdle hung after pre-step cancel')) }, 1000)),
|
||||
])
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
agent.cancel('mid-step')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }])
|
||||
})
|
||||
|
||||
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.cancel() // no reason → default 'cancelled'
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }])
|
||||
})
|
||||
|
||||
it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
|
||||
const adapter = new MockAdapter(['hang', textResponse('second reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// First turn hangs; cancel it mid-step.
|
||||
send(agent, 'first')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.cancel('cancel first')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The marker must have been reset after the cancelled turn — a fresh prompt
|
||||
// runs to completion rather than being dropped by a stale marker.
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(userTexts(agent)).toContain('second')
|
||||
// The second turn completed (its reply was streamed).
|
||||
const reasons = agent.session.events.filter(e => e.type === 'turn/end')
|
||||
expect(reasons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// A turn-start listener fires BEFORE any AbortController is installed for the
|
||||
// step. Cancelling there must still drop the step (the turn-scoped marker,
|
||||
// not abort(), is what catches this) — no model step runs.
|
||||
let streamed = false
|
||||
ctx.on('agent/stream-chunk', () => { streamed = true })
|
||||
const dispose = ctx.on('agent/turn-start', (subject) => {
|
||||
if (subject === agent) agent.cancel('from turn-start')
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
dispose()
|
||||
|
||||
// No step streamed (the model never ran), and the turn ended aborted with
|
||||
// the CALLER's reason — the marker carries `cancel(reason)` through even
|
||||
// though no AbortController observed it in this window.
|
||||
expect(streamed).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
|
||||
})
|
||||
|
||||
it('cancel during the continuation window ends the turn aborted and runs no further step', async () => {
|
||||
// A continuation-waterfall listener cancels DURING the continuation decision
|
||||
// (the finished step's AbortController is already cleared), and votes to
|
||||
// continue — but the turn-scoped marker checked right after must end the turn
|
||||
// `aborted` and run NO second step.
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-start', () => { steps += 1 })
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
let continued = false
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
|
||||
if (subject === agent && !continued) {
|
||||
continued = true
|
||||
agent.cancel('from continuation')
|
||||
return true // vote to continue — the post-waterfall marker check must override
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Only ONE step ran (the second was cancelled in the continuation window),
|
||||
// and the turn ended aborted with the CALLER's reason (carried by the
|
||||
// marker, since the finished step's AbortController was already cleared).
|
||||
expect(steps).toBe(1)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from continuation' }])
|
||||
})
|
||||
|
||||
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running
|
||||
// listener can cancel in the gap between the loop's pre-step check and
|
||||
// runTurn. The second check (after the running flip) must drop the turn —
|
||||
// runTurn would otherwise throw on the now-empty queue.
|
||||
let streamed = false
|
||||
ctx.on('agent/stream-chunk', () => { streamed = true })
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'running') agent.cancel('from running listener')
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
dispose()
|
||||
|
||||
// No turn opened, no step streamed, and a later prompt still runs (the marker
|
||||
// was reset).
|
||||
expect(streamed).toBe(false)
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
|
||||
// The window-1 early-resolve race has a window-2 twin: a synchronous
|
||||
// agent/status('running') listener cancels the about-to-run turn AND queues a
|
||||
// replacement. window 2 must NOT settle waiters (via setStatus('idle')) while
|
||||
// the replacement is still queued-and-unrun — it must fall through and run it,
|
||||
// so whenIdle() resolves on the replacement turn's running→idle, not before.
|
||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let replaced = false
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== agent || status !== 'running' || replaced) return
|
||||
replaced = true
|
||||
agent.cancel('drop A')
|
||||
send(agent, 'B')
|
||||
})
|
||||
|
||||
send(agent, 'A')
|
||||
const idle = agent.whenIdle()
|
||||
await idle
|
||||
dispose()
|
||||
|
||||
// whenIdle() resolved only AFTER B's turn ran: B's user message + a turn/end
|
||||
// are in the log, and A was dropped.
|
||||
expect(userTexts(agent)).toContain('B')
|
||||
expect(userTexts(agent)).not.toContain('A')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
})
|
||||
|
||||
it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => {
|
||||
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel()
|
||||
// clears A; prompt B is queued BEFORE the loop resumes from the idle wait.
|
||||
// The window-1 cancel branch must NOT settle the waiter while B is still
|
||||
// queued-and-unrun — whenIdle() must wait for B's turn to actually run and
|
||||
// settle (the quiescence contract), not resolve before B's first event.
|
||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'A') // queues A (status still idle, loop microtask pending)
|
||||
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
|
||||
agent.cancel('drop A') // arms marker, clears A
|
||||
send(agent, 'B') // B races in before the loop resumes
|
||||
|
||||
// whenIdle() must resolve only AFTER B's turn fully ran — by which point B's
|
||||
// user message and a turn/end are in the log. (Before the fix it resolved
|
||||
// immediately, with zero events, then B ran afterward.)
|
||||
await idle
|
||||
expect(userTexts(agent)).toContain('B')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
// A was dropped (never ran); only B's turn is recorded.
|
||||
expect(userTexts(agent)).not.toContain('A')
|
||||
})
|
||||
|
||||
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
// Steer (joins the running turn's steering FIFO), then cancel: the steering
|
||||
// must be dropped, NOT re-enqueued as a new queued turn.
|
||||
agent.steer([{ type: 'text', text: 'steer text' }])
|
||||
agent.cancel('cancel with steering')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// After the cancelled turn settles, the agent is idle with NO follow-up turn
|
||||
// started from the dropped steering.
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('idle')
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts.length).toBe(1) // only the original (cancelled) turn
|
||||
// The steering text was dropped — it never reached the log.
|
||||
const flat = agent.session.events
|
||||
.filter(e => e.type === 'steering/message')
|
||||
.flatMap(e => e.type === 'steering/message' ? e.data.content : [])
|
||||
.flatMap(b => b.type === 'text' ? [b.text] : [])
|
||||
expect(flat).not.toContain('steer text')
|
||||
})
|
||||
})
|
||||
137
packages/core/agent-loop/tests/config-session-id.spec.ts
Normal file
137
packages/core/agent-loop/tests/config-session-id.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('config-driven session id', () => {
|
||||
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)
|
||||
const idPattern = /^cfg-session-[0-9a-f-]{36}$/
|
||||
// Run 1: a config agent persists a turn under a generated session id.
|
||||
const ctx1 = new Context()
|
||||
await ctx1.plugin(LlmService)
|
||||
await ctx1.plugin(SessionStore)
|
||||
await ctx1.plugin(SystemPrompt)
|
||||
await ctx1.plugin(ToolRegistry)
|
||||
await ctx1.plugin(AgentRegistry)
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
|
||||
const a1 = ctx1.agents.get('cfg') as ReactLoopAgent
|
||||
expect(a1.session.id).toMatch(idPattern)
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Run 2 over the SAME root: a fresh id means no on-disk collision (a fixed
|
||||
// ${id}-session would crash here with "already has a persisted log").
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
|
||||
const a2 = ctx2.agents.get('cfg') as ReactLoopAgent
|
||||
expect(a2.session.id).toMatch(idPattern)
|
||||
expect(a2.session.id).not.toBe(a1.session.id)
|
||||
a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx2, a2)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('config-driven resumeSessionId continues a persisted session (env-var resume)', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-resume-'))
|
||||
dirs.push(root)
|
||||
|
||||
// Run 1: a programmatically-created agent on a KNOWN session id persists a
|
||||
// completed turn, so run 2 has a concrete id to resume.
|
||||
const ctx1 = new Context()
|
||||
await ctx1.plugin(LlmService)
|
||||
await ctx1.plugin(SessionStore)
|
||||
await ctx1.plugin(SystemPrompt)
|
||||
await ctx1.plugin(ToolRegistry)
|
||||
await ctx1.plugin(AgentRegistry)
|
||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Run 2: a CONFIG agent with resumeSessionId continues that session. The
|
||||
// resume is deferred until sessionPersistence loads (ctx.inject), so wait
|
||||
// for the agent to appear, then assert it is on the resumed id with history.
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'sticky-1' }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
|
||||
|
||||
// The deferred resume runs on a microtask after the backend is available.
|
||||
let resumed: ReactLoopAgent | undefined
|
||||
for (let i = 0; i < 50 && !resumed; i++) {
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
resumed = ctx2.agents.get('main') as ReactLoopAgent | undefined
|
||||
}
|
||||
expect(resumed).toBeDefined()
|
||||
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
|
||||
// and the prior turn's user message is in the derived history.
|
||||
expect(resumed!.session.id).toBe('sticky-1')
|
||||
const derived = resumed!.session.deriveMessages()
|
||||
expect(JSON.stringify(derived)).toContain('remember me')
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('config-driven resume of a missing session is contained: logs a warning, no agent, no crash', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-resume-miss-'))
|
||||
dirs.push(root)
|
||||
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(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'does-not-exist' }] })
|
||||
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')]))
|
||||
|
||||
// The deferred resume fails (no such session on disk). It must be contained:
|
||||
// a warning is logged, no 'main' agent is registered, and the app stays up.
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
expect(ctx.agents.get('main')).toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
332
packages/core/agent-loop/tests/coverage-edges.spec.ts
Normal file
332
packages/core/agent-loop/tests/coverage-edges.spec.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
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(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
|
||||
it('a throwing agent/turn-start listener surfaces via agent/error and the loop survives', async () => {
|
||||
// The agent/turn-start emit happens AFTER turn/start is appended to the log,
|
||||
// so a throwing listener is handled inside runTurn (the turn is balanced and
|
||||
// closed via failTurn → agent/error), NOT rethrown to the runLoop backstop.
|
||||
// The second turn should proceed normally and consume the first script entry.
|
||||
const adapter = new MockAdapter([textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-start', () => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new Error('broken turn-start listener')
|
||||
}
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['broken turn-start listener'])
|
||||
// The turn is balanced: its turn/start was logged, so a turn/end was owed
|
||||
// and appended (decided from the log, not a flag).
|
||||
expect(agent.session.events.at(-1)?.type).toBe('turn/end')
|
||||
|
||||
// loop survives: second turn works fine and makes the model call
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests[0]!.messages.some(m => m.content.some(b => 'text' in b && b.text === 'second'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a throwing agent/turn-end listener surfaces via agent/error and the loop survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-end', () => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new Error('broken turn-end listener')
|
||||
}
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
// The turn-end throw happens after the model call is complete, so turn 1's
|
||||
// request is consumed. turn/end is already in the log (append pushes before
|
||||
// notifying), so the turn is balanced; the error is surfaced via agent/error.
|
||||
expect(errors.map(e => e.message)).toEqual(['broken turn-end listener'])
|
||||
|
||||
// loop survives: second turn works fine
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
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')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const errors: { turn: number; step: number; message: string }[] = []
|
||||
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
|
||||
|
||||
// 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.
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool JSON parse', () => {
|
||||
it('passes through non-JSON arguments string without crashing', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
// model emits tool-call with malformed arguments (not valid JSON)
|
||||
[
|
||||
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
|
||||
{ type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: 'not json' } },
|
||||
{ type: 'finish' as const, reason: { kind: 'tool-calls' as const } },
|
||||
] satisfies StreamChunk[],
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
async execute(args: unknown) {
|
||||
return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'use tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// tool/call event should have recorded the raw arguments string
|
||||
const callEvent = agent.session.events.find(e => e.type === 'tool/call')
|
||||
expect(callEvent).toBeDefined()
|
||||
if (callEvent!.type === 'tool/call') {
|
||||
expect(callEvent!.data.arguments).toBe('not json')
|
||||
}
|
||||
// the loop did not crash — a result was produced
|
||||
expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses empty object when tool-call arguments are empty string', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
[
|
||||
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
|
||||
{ type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'noarg', arguments: '' } },
|
||||
{ type: 'finish' as const, reason: { kind: 'tool-calls' as const } },
|
||||
] satisfies StreamChunk[],
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noarg',
|
||||
description: 'no-arg tool',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
return [{ type: 'text', text: 'ran with empty args' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'use tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toError normalization', () => {
|
||||
it('normalizes non-Error throws from turn-start listeners via toError', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-start', () => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw 'naked string error' // non-Error throw, normalized via toError
|
||||
}
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
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
|
||||
// session error event carries a routable code instead of degrading.
|
||||
const errorEvent = agent.session.events.find(e => e.type === 'error')
|
||||
expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN')
|
||||
})
|
||||
|
||||
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
|
||||
const adapter = new MockAdapter([textResponse('irrelevant')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw { code: 500 } // non-Error throw, goes through runStep catch
|
||||
}
|
||||
return _next()
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
// String() of { code: 500 } is '[object Object]'
|
||||
expect(errors[0]!.message).toBe('[object Object]')
|
||||
const errorEvent = agent.session.events.find(e => e.type === 'error')
|
||||
expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('coded error data emission', () => {
|
||||
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new LlmError('server overloaded', 'RATE_LIMIT')
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toBe('server overloaded')
|
||||
|
||||
// session error event includes the code
|
||||
const errorEvent = agent.session.events.find(e => e.type === 'error')
|
||||
expect(errorEvent).toBeDefined()
|
||||
if (errorEvent!.type === 'error') {
|
||||
expect(errorEvent!.data.code).toBe('RATE_LIMIT')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposed vs aborted branching', () => {
|
||||
it('handles dispose during model streaming producing reason "disposed"', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create('scoped', { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose() // dispose during hang
|
||||
await agent.done
|
||||
|
||||
// The review-fixes test for 'HIGH: disposed status' already covers
|
||||
// this assertion path. The reason is 'disposed' because isDisposed() is
|
||||
// checked before the abort signal check in the error path.
|
||||
expect(reasons).toContainEqual({ kind: 'disposed' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('structured tool error propagation (the runtime-validation RFC, part 2)', () => {
|
||||
it('forwards a tool HarnessError onto the tool/result session event', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
// First model turn calls the tool; second turn (after the tool result is
|
||||
// fed back) ends with plain text so the loop settles.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'boom', {}),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'boom',
|
||||
description: 'always fails',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
throw new HarnessError('exploded', 'BOOM')
|
||||
},
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
|
||||
expect(toolResult?.type === 'tool/result' && toolResult.data.isError).toBe(true)
|
||||
expect(toolResult?.type === 'tool/result' && toolResult.data.error)
|
||||
.toEqual({ name: 'HarnessError', code: 'BOOM' })
|
||||
})
|
||||
})
|
||||
110
packages/core/agent-loop/tests/inbox.spec.ts
Normal file
110
packages/core/agent-loop/tests/inbox.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Inbox } from '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
function resolverPair() {
|
||||
let r!: () => void
|
||||
const p = new Promise<void>((resolve) => { r = resolve })
|
||||
return { promise: p, resolve: r }
|
||||
}
|
||||
|
||||
describe('Inbox', () => {
|
||||
it('enqueues and drains queued messages in FIFO order', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } })
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } })
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
|
||||
const drained = inbox.drainQueued()
|
||||
expect(drained).toHaveLength(2)
|
||||
expect(drained[0]!.content[0]).toMatchObject({ text: 'first' })
|
||||
expect(drained[1]!.content[0]).toMatchObject({ text: 'second' })
|
||||
expect(inbox.hasQueued).toBe(false)
|
||||
})
|
||||
|
||||
it('pushes and drains steering messages separately from queued', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.steer({ content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } })
|
||||
expect(inbox.hasQueued).toBe(false)
|
||||
expect(inbox.hasSteering).toBe(true)
|
||||
|
||||
const steering = inbox.drainSteering()
|
||||
expect(steering).toHaveLength(1)
|
||||
expect(inbox.hasSteering).toBe(false)
|
||||
})
|
||||
|
||||
it('waitForQueued returns immediately when a queued message is already present', async () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'ready' }], source: { kind: 'user' } })
|
||||
|
||||
const started = Date.now()
|
||||
await inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
|
||||
expect(Date.now() - started).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('waitForQueued resolves when a message is enqueued', async () => {
|
||||
const inbox = new Inbox()
|
||||
const waiter = inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
|
||||
// enqueue after starting the wait
|
||||
setTimeout(() => { inbox.enqueue({ content: [{ type: 'text', text: 'wake' }], source: { kind: 'user' } }) }, 5)
|
||||
await waiter
|
||||
})
|
||||
|
||||
it('waitForQueued resolves when the cancel promise resolves', async () => {
|
||||
const inbox = new Inbox()
|
||||
const { promise, resolve } = resolverPair()
|
||||
const waiter = inbox.waitForQueued(promise)
|
||||
resolve()
|
||||
await waiter
|
||||
})
|
||||
|
||||
it('waitForQueued overwrites the previous wakeup callback (only the latest waiter is notified)', async () => {
|
||||
const inbox = new Inbox()
|
||||
const { promise: p1, resolve: r1 } = resolverPair()
|
||||
|
||||
void inbox.waitForQueued(new Promise(() => {})) // first call, never resolved
|
||||
void inbox.waitForQueued(p1) // second call overwrites wakeup
|
||||
|
||||
// Cancel p1 (the latest waiter's cancel) — the wakeup was overwritten
|
||||
// to p1's resolve, so canceling p1 triggers the finally block which
|
||||
// clears the wakeup if it matches.
|
||||
r1()
|
||||
await p1
|
||||
|
||||
// Now enqueue: the first waiter's wakeup (which was overwritten) won't
|
||||
// fire, and the second waiter's wakeup was cleared by cancel.
|
||||
// The enqueue calls wakeup?.() but wakeup was cleared — no crash, no hang.
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } })
|
||||
// The overwrite path + finally cleanup are exercised
|
||||
})
|
||||
|
||||
it('clears wakeup in finally handler when enqueue resolves', async () => {
|
||||
const inbox = new Inbox()
|
||||
void inbox.waitForQueued(new Promise(() => {})) // never-resolving cancel
|
||||
// The wakeup is set. Now trigger it via enqueue → wakeup() calls resolve,
|
||||
// promise resolves, finally clears wakeup because wakeup === resolve.
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'wake' }], source: { kind: 'user' } })
|
||||
// No explicit await needed — enqueue is synchronous, and the microtask
|
||||
// (finally) runs. The key coverage hit is finally with wakeup === resolve.
|
||||
})
|
||||
|
||||
it('finally handler does not clear wakeup when a different waiter overwrote it', async () => {
|
||||
// First waiter's cancel resolves AFTER a second waiter overwrote wakeup.
|
||||
// First waiter's finally sees wakeup !== its resolve → does not clear.
|
||||
const inbox = new Inbox()
|
||||
const { promise: c1, resolve: r1 } = resolverPair()
|
||||
|
||||
void inbox.waitForQueued(c1) // wakeup = resolve1, c1.then(resolve1)
|
||||
void inbox.waitForQueued(new Promise(() => {})) // wakeup = resolve2, cancel never resolves
|
||||
|
||||
// Resolve c1 (the first cancel). c1.then(resolve1) fires → resolve1() called
|
||||
// → waiter1's promise resolves → finally: wakeup === resolve1? NO (it's resolve2)
|
||||
// → wakeup is NOT cleared.
|
||||
r1()
|
||||
await c1
|
||||
|
||||
// Now enqueue: wakeup() calls resolve2 → waiter2 resolves
|
||||
// But waiter2's cancel never resolves — that's fine, enqueue resolves it.
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } })
|
||||
// No need to await anything further — enqueue is synchronous wakeup
|
||||
})
|
||||
})
|
||||
639
packages/core/agent-loop/tests/loop.spec.ts
Normal file
639
packages/core/agent-loop/tests/loop.spec.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
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(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the agent's NEXT transition to idle. Always event-based: callers
|
||||
* invoke this right after send(), when the loop hasn't woken yet (status is
|
||||
* still 'idle' synchronously), so polling the current status would lie.
|
||||
*/
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('agent loop', () => {
|
||||
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hello there')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const order: string[] = []
|
||||
for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) {
|
||||
ctx.on(name, () => void order.push(name))
|
||||
}
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(order).toEqual(['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'])
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
// turn/start opens the turn, THEN the queued user message is recorded inside
|
||||
// it (every event is turn-enclosed), then assembled message + usage.
|
||||
expect(types[0]).toBe('turn/start')
|
||||
expect(types[1]).toBe('user/message')
|
||||
expect(types).toContain('assistant/message')
|
||||
expect(types).toContain('usage')
|
||||
expect(types.at(-1)).toBe('turn/end')
|
||||
|
||||
// derived history: user + assistant
|
||||
const messages = agent.session.deriveMessages()
|
||||
expect(messages.map(m => m.role)).toEqual(['user', 'assistant'])
|
||||
expect(messages[1]!.content).toEqual([{ type: 'text', text: 'hello there' }])
|
||||
})
|
||||
|
||||
it('round-trips tool calls: model requests tool → executes → result in next request', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'ping' }, 'calling echo'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo back',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `echo: ${args.text}` }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// two model calls happened (tool-call step, then final step)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
|
||||
// the second request's derived history contains the tool result
|
||||
const secondMessages = adapter.requests[1]!.messages
|
||||
const toolResultMessage = secondMessages.find(m =>
|
||||
m.content.some(b => b.type === 'tool-result'))
|
||||
expect(toolResultMessage).toBeDefined()
|
||||
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
|
||||
expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
|
||||
expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }])
|
||||
|
||||
// session log records call + result
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toContain('tool/call')
|
||||
expect(types).toContain('tool/result')
|
||||
})
|
||||
|
||||
it('passes assembled system prompt and tool schemas into the request', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are a test agent.' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noop',
|
||||
description: 'does nothing',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
return []
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const request = adapter.requests[0]
|
||||
expect(request!.system).toBe('You are a test agent.\n\nAgent-specific suffix.')
|
||||
expect(request!.tools?.map(t => t.name)).toEqual(['noop'])
|
||||
})
|
||||
|
||||
it('records raw chunks for replay and emits agent/stream-chunk', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const streamed: StreamChunk[] = []
|
||||
ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk))
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk')
|
||||
// textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7
|
||||
expect(chunkEvents).toHaveLength(7)
|
||||
expect(streamed).toHaveLength(7)
|
||||
// replay: chunk events alone re-assemble to the recorded assistant message
|
||||
const deltaText = chunkEvents
|
||||
.flatMap(e => e.type === 'assistant/chunk' ? [e.data.chunk] : [])
|
||||
.filter((c: StreamChunk): c is Extract<StreamChunk, { type: 'text-delta' }> => c.type === 'text-delta')
|
||||
.map(c => c.text)
|
||||
.join('')
|
||||
expect(deltaText).toBe('abc')
|
||||
})
|
||||
|
||||
it('injects steering between steps and continues the turn', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'slow', {}),
|
||||
textResponse('addressed the steering'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'slow',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
// steer while the turn is running (during tool execution)
|
||||
agent.steer([{ type: 'text', text: 'change of plans' }])
|
||||
return [{ type: 'text', text: 'tool done' }]
|
||||
},
|
||||
}))
|
||||
|
||||
send(agent, 'start')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toContain('steering/message')
|
||||
// steering recorded before the second step's request derived its history
|
||||
const steeringSeq = agent.session.events.find(e => e.type === 'steering/message')!.seq
|
||||
const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1]
|
||||
expect(secondStepStart).toBeDefined()
|
||||
expect(steeringSeq).toBeLessThan(secondStepStart!.seq)
|
||||
|
||||
// the second model request saw the steering content
|
||||
const secondRequest = adapter.requests[1]
|
||||
const flat = JSON.stringify(secondRequest!.messages)
|
||||
expect(flat).toContain('change of plans')
|
||||
})
|
||||
|
||||
it('steering while idle behaves like send (starts a turn)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
agent.steer([{ type: 'text', text: 'hello' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
|
||||
})
|
||||
|
||||
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
|
||||
// The idle inject records a self-contained turn (turn/start → context/message
|
||||
// → turn/end) so the event stays turn-enclosed, but does NOT run the model.
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
const injectedTurn = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(injectedTurn).toHaveLength(1)
|
||||
const it0 = injectedTurn[0]!
|
||||
expect(it0.type === 'turn/start' && it0.data.trigger.kind).toBe('injection')
|
||||
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
const flat = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(flat).toContain('file changed: a.ts')
|
||||
expect(flat).toContain('<context source=\\"plugin\\">')
|
||||
})
|
||||
|
||||
it('inject() while running appends into the open turn (no extra synthetic turn)', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'noticer', {}, 'calling'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
// A tool that injects mid-execution: at this point the agent is running, so
|
||||
// inject must append the context/message into the ALREADY-open turn rather
|
||||
// than wrap it in its own one-shot turn.
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noticer',
|
||||
description: 'injects a notice',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
agent.inject([{ type: 'text', text: 'mid-turn notice' }], { source: { kind: 'plugin', plugin: 'x' } })
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Exactly ONE turn ran (no synthetic injection turn), and the mid-turn
|
||||
// context/message sits inside it.
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
const ts0 = turnStarts[0]!
|
||||
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
|
||||
expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true)
|
||||
})
|
||||
|
||||
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
|
||||
// force-continue: model never calls tools, but a plugin forces 3 steps
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('step 1'),
|
||||
textResponse('step 2'),
|
||||
textResponse('step 3'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-end', () => void steps++)
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
if (steps < 3) return true
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(steps).toBe(3)
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
ctx.on('agent/turn-continuation', async () => false as const)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
// only one model call despite the tool call requesting a follow-up
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
// tool still executed before the decision
|
||||
expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true)
|
||||
})
|
||||
|
||||
it('agent/request waterfall can rewrite the request (model-switch pattern)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.llm.registerAdapter(['other-model'], adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
|
||||
options.model = 'other-model'
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests[0]!.model).toBe('other-model')
|
||||
})
|
||||
|
||||
it('abort() mid-stream ends the turn with reason aborted', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
// wait until the stream is hanging, then abort
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
agent.abort('user interrupt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
|
||||
})
|
||||
|
||||
it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => {
|
||||
// A single step that ends with a max-tokens finish (no tool calls): the
|
||||
// turn stops by default and ends max-tokens, not completed.
|
||||
const adapter = new MockAdapter([maxTokensResponse('truncat')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }])
|
||||
// and the reason is recorded in the log's turn/end event
|
||||
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' })
|
||||
})
|
||||
|
||||
it('a max-tokens step earlier in a turn still surfaces as max-tokens after a later completed step', async () => {
|
||||
// Step 1 is cut off (max-tokens, no tool calls → would stop by default), so
|
||||
// continuation must be FORCED to reach step 2 which finishes normally
|
||||
// (stop). The rule "any max-tokens step surfaces as max-tokens" means the
|
||||
// turn ends max-tokens even though the LAST step completed cleanly.
|
||||
const adapter = new MockAdapter([
|
||||
maxTokensResponse('first half'),
|
||||
textResponse('second half'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('agent/step-end', () => void steps++)
|
||||
// Force exactly one continuation (step 1 → step 2), then defer to default
|
||||
// (step 2 is a plain stop with no tool calls → stops).
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||
if (steps < 2) return true
|
||||
return next()
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(steps).toBe(2)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(adapter.requests[1]!.messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'first half' }] },
|
||||
])
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }])
|
||||
})
|
||||
|
||||
it('a completed step after no max-tokens keeps the turn completed (max-tokens does not leak across turns)', async () => {
|
||||
// Two consecutive turns: turn 1 is cut off (max-tokens), turn 2 is a clean
|
||||
// stop. The per-turn reason must be independent — turn 2 ends completed.
|
||||
const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }, { kind: 'completed' }])
|
||||
})
|
||||
|
||||
it('does not dispatch tool calls from a max-tokens-truncated step', async () => {
|
||||
const callId = CallId('c1')
|
||||
const adapter = new MockAdapter([[
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: callId, name: 'echo', argumentsDelta: '{"text":"x"}' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'echo', arguments: '{"text":"x"}' } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]])
|
||||
const ctx = await harness(adapter)
|
||||
let executions = 0
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute() {
|
||||
executions += 1
|
||||
return [{ type: 'text', text: 'should not run' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(executions).toBe(0)
|
||||
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
|
||||
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }])
|
||||
})
|
||||
|
||||
it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => {
|
||||
const callId = CallId('c1')
|
||||
const adapter = new MockAdapter([[
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'partial text' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'partial text' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 1, id: callId, name: 'echo', argumentsDelta: '{"text"' },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]])
|
||||
const ctx = await harness(adapter)
|
||||
let stepResults = 0
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, message, next) => {
|
||||
stepResults += 1
|
||||
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(stepResults).toBe(1)
|
||||
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
|
||||
expect(agent.session.deriveMessages()).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'partial text' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('stops the turn when agent/step-end listener failure has recorded an error', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('should not run'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
let threw = false
|
||||
ctx.on('agent/step-end', () => {
|
||||
if (!threw) { threw = true; throw new Error('bad step-end listener') }
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
|
||||
})
|
||||
|
||||
it('chains queued messages into consecutive turns', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const turns: number[] = []
|
||||
ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
|
||||
|
||||
// queue two messages while idle — first starts turn 1 immediately;
|
||||
// queue the second during turn 1 via a stream-chunk hook
|
||||
let queued = false
|
||||
ctx.on('agent/stream-chunk', () => {
|
||||
if (!queued) {
|
||||
queued = true
|
||||
send(agent, 'second message')
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'first message')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
let flushed = 0
|
||||
let flushedBeforeIdle = false
|
||||
ctx.on('session/flush', async (session) => {
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
flushed++
|
||||
flushedBeforeIdle = agent.status !== 'idle'
|
||||
void session
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(flushed).toBe(1)
|
||||
expect(flushedBeforeIdle).toBe(true)
|
||||
})
|
||||
|
||||
it('errors from the model surface as agent/error and end the turn', async () => {
|
||||
const adapter = new MockAdapter([]) // script exhausted → throws
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toContain('script exhausted')
|
||||
expect(reasons[0]).toMatchObject({ kind: 'error' })
|
||||
expect(agent.session.events.some(e => e.type === 'error')).toBe(true)
|
||||
})
|
||||
|
||||
it('disposing the loop fiber mid-turn stops the loop (HMR safety)', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create('scoped', { model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
expect(ctx.agents.get('scoped')).toBe(agent)
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get('scoped')).toBeUndefined()
|
||||
expect(() => { send(agent, 'too late') }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('creates agents from config on startup', async () => {
|
||||
const adapter = new MockAdapter([textResponse('from config')])
|
||||
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(AgentLoop, {
|
||||
agents: [{ id: 'config-agent', model: 'mock', systemPrompt: 'Config prompt' }],
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const agent = ctx.agents.get('config-agent')! as ReactLoopAgent
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent.id).toBe('config-agent')
|
||||
expect(agent.options.model).toBe('mock')
|
||||
|
||||
// the agent is alive: send triggers a turn
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('replays a session log into an identical derived history', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: '',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
send(agent, 'run')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const replayed = ctx.sessions.create('replayed', { seed: [...agent.session.events] })
|
||||
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
||||
// event-by-event identity of types
|
||||
expect(replayed.events.map(e => e.type)).toEqual(
|
||||
agent.session.events.map(e => e.type))
|
||||
})
|
||||
})
|
||||
90
packages/core/agent-loop/tests/mock-adapter.ts
Normal file
90
packages/core/agent-loop/tests/mock-adapter.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Helpers to write scripted responses tersely. */
|
||||
export function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link textResponse} but the stream ends with a `max-tokens` finish —
|
||||
* the model was cut off at the output-token ceiling (DeepSeek's `length`).
|
||||
* Used to exercise the turn-end `max-tokens` surfacing rule.
|
||||
*/
|
||||
export function maxTokensResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]
|
||||
}
|
||||
|
||||
export function toolCallResponse(rawCallId: string, name: string, args: object, text?: string): StreamChunk[] {
|
||||
const callId = CallId(rawCallId)
|
||||
const argumentsJson = JSON.stringify(args)
|
||||
const chunks: StreamChunk[] = []
|
||||
let index = 0
|
||||
if (text) {
|
||||
chunks.push(
|
||||
{ type: 'block-start', index, blockType: 'text' },
|
||||
{ type: 'text-delta', index, text },
|
||||
{ type: 'block-end', index, block: { type: 'text', text } },
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
chunks.push(
|
||||
{ type: 'block-start', index, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index, id: callId, name, argumentsDelta: argumentsJson.slice(0, 5) },
|
||||
{ type: 'tool-call-delta', index, id: callId, argumentsDelta: argumentsJson.slice(5) },
|
||||
{
|
||||
type: 'block-end',
|
||||
index,
|
||||
block: { type: 'tool-call', id: callId, name, arguments: argumentsJson },
|
||||
},
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock adapter driven by a script: each model call consumes the next entry.
|
||||
* Records every request it receives for assertions. An entry may be a
|
||||
* function to compute chunks from the request, or a 'hang' marker that
|
||||
* streams one chunk then waits until aborted.
|
||||
*/
|
||||
export class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('MockAdapter: script exhausted')
|
||||
if (entry === 'hang') {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (options.signal?.aborted) { reject(new Error('aborted')); return }
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
const chunks = typeof entry === 'function' ? entry(options) : entry
|
||||
for (const chunk of chunks) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
176
packages/core/agent-loop/tests/properties.spec.ts
Normal file
176
packages/core/agent-loop/tests/properties.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Property-based tests for the agent loop's inbox/turn scheduling (the
|
||||
* property-testing RFC). Deterministic by construction: schedules are driven
|
||||
* through the `agent/status` settle signal (no wall-clock sleeps), so a flake
|
||||
* is a finding, not timing noise.
|
||||
*
|
||||
* Invariants: every sent message appears exactly once in the log (none lost);
|
||||
* turn numbers strictly increase; status transitions follow the legal machine
|
||||
* idle→running→idle (and →disposed at teardown).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import fc from 'fast-check'
|
||||
|
||||
/** A never-exhausting adapter: every model call returns the same short reply. */
|
||||
class EchoAdapter extends LlmAdapter {
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
const text = 'ok'
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
|
||||
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
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(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], new EchoAdapter())
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Resolve on the agent's next transition to idle (event-based, not polled). */
|
||||
function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Record every status transition for the legal-machine assertion. Returns
|
||||
* the seen list plus a disposer for the listener (per the registry convention). */
|
||||
function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
|
||||
const seen: string[] = []
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) seen.push(status)
|
||||
})
|
||||
return { seen, dispose }
|
||||
}
|
||||
|
||||
function userMessageTexts(agent: ReactLoopAgent): string[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'user/message')
|
||||
.map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
|
||||
}
|
||||
|
||||
function turnNumbers(agent: ReactLoopAgent): number[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'turn/start')
|
||||
.map(e => (e.data as { turn: number }).turn)
|
||||
}
|
||||
|
||||
/** Assert a status trace is a legal run: idle/running alternating, ending idle. */
|
||||
function assertLegalStatusTrace(trace: string[]): void {
|
||||
for (let i = 1; i < trace.length; i++) {
|
||||
expect(trace[i]).not.toBe(trace[i - 1]) // no repeats (setStatus dedups)
|
||||
}
|
||||
for (const s of trace) expect(['idle', 'running']).toContain(s)
|
||||
}
|
||||
|
||||
describe('agent loop scheduling properties', () => {
|
||||
it('a synchronous burst loses no message and uses strictly increasing turns', async () => {
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }),
|
||||
async (texts) => {
|
||||
const ctx = await harness()
|
||||
try {
|
||||
const agent = ctx.agentLoop.create('a', { model: 'mock' })
|
||||
const { seen: trace } = recordStatus(ctx, agent)
|
||||
const idle = nextIdle(ctx, agent)
|
||||
// Send all in one synchronous tick: they queue before the loop wakes.
|
||||
for (const text of texts) agent.send([{ type: 'text', text }])
|
||||
await idle
|
||||
|
||||
// No message lost: every send appears as a user/message, in order.
|
||||
expect(userMessageTexts(agent)).toEqual(texts)
|
||||
// A synchronous burst batches into exactly one turn.
|
||||
expect(turnNumbers(agent)).toEqual([1])
|
||||
assertLegalStatusTrace(trace)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
},
|
||||
), { numRuns: 25, timeout: 2000 })
|
||||
})
|
||||
|
||||
it('sequential sends each get their own turn with increasing numbers', async () => {
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 5 }),
|
||||
async (texts) => {
|
||||
const ctx = await harness()
|
||||
try {
|
||||
const agent = ctx.agentLoop.create('a', { model: 'mock' })
|
||||
for (const text of texts) {
|
||||
const idle = nextIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text }])
|
||||
await idle
|
||||
}
|
||||
// Each send was drained at a separate turn start: N turns, 1..N.
|
||||
expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
|
||||
expect(userMessageTexts(agent)).toEqual(texts)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
},
|
||||
), { numRuns: 20, timeout: 2000 })
|
||||
})
|
||||
|
||||
it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => {
|
||||
// Each step is a (text, settle?) pair: settle=true awaits idle before the
|
||||
// next send (own turn); settle=false sends in the same tick (batches).
|
||||
const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() })
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(stepArb, { minLength: 1, maxLength: 6 }),
|
||||
async (steps) => {
|
||||
const ctx = await harness()
|
||||
try {
|
||||
const agent = ctx.agentLoop.create('a', { model: 'mock' })
|
||||
// Capture an idle waiter before EACH send; the last one is guaranteed
|
||||
// to resolve because the final send always triggers (or joins) a turn
|
||||
// that ends idle. Awaiting an already-resolved waiter is a no-op, so a
|
||||
// trailing settle step can't cause a hang.
|
||||
let lastIdle: Promise<void> | undefined
|
||||
for (const step of steps) {
|
||||
const idle = nextIdle(ctx, agent)
|
||||
lastIdle = idle
|
||||
agent.send([{ type: 'text', text: step.text }])
|
||||
if (step.settle) await idle
|
||||
}
|
||||
await lastIdle
|
||||
|
||||
// No message lost or reordered, regardless of batching.
|
||||
expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text))
|
||||
// Turn numbers are a strictly increasing 1..N prefix (N = turn count).
|
||||
const turns = turnNumbers(agent)
|
||||
expect(turns).toEqual(turns.map((_, i) => i + 1))
|
||||
// Every message landed in some turn; turns never exceed messages.
|
||||
expect(turns.length).toBeLessThanOrEqual(steps.length)
|
||||
expect(turns.length).toBeGreaterThanOrEqual(1)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
},
|
||||
), { numRuns: 25, timeout: 3000 })
|
||||
})
|
||||
})
|
||||
241
packages/core/agent-loop/tests/resume.spec.ts
Normal file
241
packages/core/agent-loop/tests/resume.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
|
||||
|
||||
async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
|
||||
dirs.push(root)
|
||||
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(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, root }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
|
||||
expect(agent.session.id).toBe('custom-session')
|
||||
expect(agent.session.header.cwd).toBe('/w')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
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: 'dup', 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: 'dup', sessionId: 'sess-b' })).toThrow(/already registered/)
|
||||
expect(ctx.sessions.get('sess-b')).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
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: 'a-nometa', sessionId: 'nometa-session' })
|
||||
expect(agent.session.id).toBe('nometa-session')
|
||||
expect(agent.session.header.cwd).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a session with no cwd carries an undefined cwd header', async () => {
|
||||
// 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: 'm', sessionId: 'nocwd-sess' }).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: resume it; the header cwd stays undefined (no-cwd branch).
|
||||
const adapter2 = new MockAdapter([textResponse('b')])
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' })).agent as ReactLoopAgent
|
||||
expect(a2.session.header.cwd).toBeUndefined()
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession in its
|
||||
// header) by creating it with a complete-turn seed — the write path
|
||||
// materializes the fork (header + seed) on disk.
|
||||
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 adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const forked = ctx1.sessions.create('forked-sess', { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } })
|
||||
await ctx1.parallel('session/flush', forked)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: resume it; the parentSession header survives the round-trip
|
||||
// (exercises resume's parentSession-present branch).
|
||||
const adapter2 = new MockAdapter([textResponse('b')])
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' })).agent as ReactLoopAgent
|
||||
expect(a2.session.header.parentSession).toBe('parent-sess')
|
||||
expect(a2.session.header.cwd).toBe('/w')
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
|
||||
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
|
||||
// wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)
|
||||
// — without an explicit flush or clean dispose, the notice must still reach
|
||||
// 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: 'm', 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' } })
|
||||
// Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose).
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
// A SEPARATE backend reads the on-disk log — proving the inject persisted
|
||||
// itself, not a later dispose drain.
|
||||
const probe = new Context()
|
||||
await probe.plugin(SessionStore)
|
||||
await probe.plugin(SessionPersistenceJsonl, { root })
|
||||
const loaded = await probe.sessionPersistence.load(SessionId('inject-sess'))
|
||||
expect(JSON.stringify(loaded.events)).toContain('background task 42 finished')
|
||||
await probe.fiber.dispose()
|
||||
await ctx1.fiber.dispose()
|
||||
})
|
||||
|
||||
it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => {
|
||||
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
|
||||
// wraps its context/message in a one-shot turn so it is turn-enclosed —
|
||||
// otherwise scanLog would treat the trailing context as a crash tail and
|
||||
// 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: 'm', 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' } })
|
||||
await ctx1.parallel('session/flush', a1.session)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: resume; the injected context is still in the derived history.
|
||||
const adapter2 = new MockAdapter([textResponse('next')])
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' })).agent as ReactLoopAgent
|
||||
const flat = JSON.stringify(a2.session.deriveMessages())
|
||||
expect(flat).toContain('background task 42 finished')
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
|
||||
// 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: 'main', 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]
|
||||
const seqs1 = events1.map(e => e.seq)
|
||||
expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: a brand-new context over the SAME root; resume the session.
|
||||
const adapter2 = new MockAdapter([textResponse('second answer')])
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
|
||||
const a2 = (await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' })).agent as ReactLoopAgent
|
||||
// The resumed session carries the prior history…
|
||||
expect(a2.session.id).toBe('sess-resume')
|
||||
expect(a2.session.events.length).toBe(events1.length)
|
||||
const replay = new Session(SessionId('replay'), events1)
|
||||
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
|
||||
|
||||
// …and a new turn continues numbering (turn 2) with contiguous seqs.
|
||||
a2.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx2, a2)
|
||||
const allSeqs = a2.session.events.map(e => e.seq)
|
||||
expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates
|
||||
const turnStarts = a2.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts.map(e => e.type === 'turn/start' && e.data.turn)).toEqual([1, 2])
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume rejects when session persistence is not configured', async () => {
|
||||
// A harness WITHOUT the persistence plugin.
|
||||
const adapter = new MockAdapter([textResponse('x')])
|
||||
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(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
await expect(ctx.agents.resume({ agentId: 'm', resumeSessionId: 'nope' }))
|
||||
.rejects.toThrow(/session persistence is not configured/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
1124
packages/core/agent-loop/tests/review-fixes.spec.ts
Normal file
1124
packages/core/agent-loop/tests/review-fixes.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
39
packages/core/agent-loop/tsconfig.json
Normal file
39
packages/core/agent-loop/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user