From 05b75abbca004f263191f803873a87697fdb7290 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:32:55 +0800 Subject: [PATCH 1/7] refactor(events): document event-domain semantics, drop step-boundary mirror emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the three-domain rule (session = durable fact log, agent = live runtime surface, tools = registry/exec): a durable replayable fact is a SessionEvent; a live interception or transient/live-object signal is an agent/tools Cordis event. A boundary that is both is mirrored as an agent/* emit ONLY where a live consumer needs the Agent handle. Apply it to the boundary twins: drop agent/step-start and agent/step-end (no production consumer needs the live Agent at a step boundary — consumers read the durable step/start/step/end session events). Keep agent/turn-start/turn-end (the stdio UI labels output by agent.id). Tests that observed step boundaries via the removed emits now observe the durable session events; the pinned behavior is unchanged. Conservative subset of the proposed "remove boundary mirror events" simplification; foundation for the Hooks subsystem's canonical event surface. --- docs/architecture.md | 8 +- docs/cordis-catalog/events-and-services.md | 50 +++------ docs/rfc/README.md | 1 + .../2026-06-30-event-domain-semantics.md | 39 +++++++ packages/core/agent-loop/src/loop.ts | 47 ++++---- packages/core/agent-loop/tests/cancel.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 23 ++-- .../agent-loop/tests/review-fixes.spec.ts | 105 +++++++----------- packages/core/agent/src/types.ts | 52 ++++++--- 9 files changed, 176 insertions(+), 151 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md diff --git a/docs/architecture.md b/docs/architecture.md index 315715c828..c0d6351949 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,7 +133,7 @@ forever: drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start STEP loop: drain steering (late steering from previous step's listeners) - session('step/start'); emit agent/step-start + session('step/start') ⟵ durable step boundary (no agent/* mirror) assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble req = {model, system, tools, messages: session.deriveMessages(), signal} req = waterfall agent/request ⟵ hooks, compaction, model switch @@ -149,9 +149,9 @@ forever: tool execution may append tool-owned session events, e.g. `todo/write` session('tool/result') drain steering → session('steering/message'); emit agent/steering - emit agent/step-end + session('step/end') ⟵ durable step boundary (no agent/* mirror) cont = waterfall agent/turn-continuation(default = hadToolCalls || steered) - steering pending from step-end/continuation listeners forces cont = true + steering pending from continuation listeners forces cont = true if !cont: break session('turn/end'); emit agent/turn-end await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure @@ -191,7 +191,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl | Hook system (user + project level) | listeners on `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`; a hooks plugin bridges config files to shell commands | | `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | | `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | -| Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) | +| Dynamic workflow | orchestrator plugin on `agent/turn-end` (or the `step/end` session event) driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | | Context compaction (auto + manual) | the `ctx.compact` seam ([dsh-compact](../packages/compact/compact)): a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure at turn boundaries, manual = a `/compact` tool. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 42c5f7440c..2ce45b4051 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,19 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) - -#### `agent/step-end` — emit - -A step ended. - -```ts cordis-catalog -'agent/step-end'(agent: Agent, turn: number, step: number): void -``` - -Types: [Agent](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,19 +109,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) - -#### `agent/step-start` — emit - -A step (one model call plus its tool dispatch) began. `step` is 1-based within the turn; a turn runs one or more steps. - -```ts cordis-catalog -'agent/step-start'(agent: Agent, turn: number, step: number): void -``` - -Types: [Agent](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +121,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,11 +133,11 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit -A turn ended. `reason` distinguishes a clean stop from a truncated or aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`). +A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, or hook-rejected one (`completed` | `aborted` | `error` | `disposed` | `max-tokens` | `rejected` | `interrupted`). ```ts cordis-catalog 'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void @@ -169,7 +145,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +157,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 52676cdebf..e27e83842e 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -120,6 +120,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | +| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md new file mode 100644 index 0000000000..0cf1d68d7f --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -0,0 +1,39 @@ +# RFC: Event-domain semantics — session is the fact log, agent is the live surface + +Status: implemented (accepted 2026-06-30) + +## Context + +The harness extends the agent loop through a Cordis event taxonomy (see [the microkernel event-taxonomy RFC](2026-06-11-microkernel-event-taxonomy.md)). As that taxonomy grew, the line between the three event domains blurred: + +- `session/*` carries the durable, event-sourced log (`SessionEventMap`). +- `agent/*` carries live runtime signals that hand a plugin the `Agent` handle. +- `tools/*` carries the tool registry + execution seam. + +Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable `SessionEvent` (`turn/start`, `turn/end`, `step/start`, `step/end`) AND as a mirrored `agent/*` emit (`agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the upcoming Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why. + +This is the foundational change in a stack that adds a Hooks subsystem; it establishes the vocabulary the later PRs (interception-Decision reshape, the `hook/*` durable log, the bridges) build on. + +## Decision + +**Three domains, one job each, with a single boundary rule.** + +- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and `session/load` replay share one path. +- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`, and the turn boundaries) that notify with the `Agent` in hand. +- **`tools/*` — the tool registry + execution seam.** + +**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A datum that is BOTH — a turn or step boundary — lives in the session log, and is mirrored as an `agent/*` emit ONLY where a live consumer provably needs the `Agent` handle at that instant. + +**Applying the rule to the boundary twins (prune case-by-case):** + +- `agent/turn-start` — **KEPT.** The stdio UI (`dsh-ui-stdio`) labels turn output by `agent.id`, which the `turn/start` session event does not carry. A genuine live-object need. +- `agent/turn-end` — **KEPT.** The stdio UI listens to print the next-prompt glyph. (Note: the ACP bridge does NOT settle on this event — it settles from `session/event` `turn/end` plus `agent/status`; the surviving justification is the stdio UI alone.) +- `agent/step-start`, `agent/step-end` — **REMOVED.** No production consumer needs the live `Agent` at a step boundary; a consumer that wants per-step boundaries reads the durable `step/start`/`step/end` session events. Removing the two emits also simplifies the loop's `closeStep` (one append, no paired emit). + +## Consequences + +- The loop no longer emits `agent/step-start`/`agent/step-end`; `closeStep` appends `step/end` only, and a throwing `step/end` session-event listener is the surviving step-boundary-listener failure path (contained by `closeStep` → `failTurn`, the turn closes balanced). +- Tests that observed step boundaries via the removed emits now observe the durable `step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting, a throwing boundary listener failing the turn balanced) is unchanged; only the feed they read moved to the canonical one. Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved together. +- One behavior genuinely shifts and is documented in its test: a throwing `step/start` session-event listener throws INSIDE `session.append('step/start')`, before the loop marks the step open, so no `step/end` is owed (the old `agent/step-start` emit fired after the step was open). The turn still closes balanced with an error. +- This is a partial, conservative realization of the broader [proposed simplification "Stop mirroring durable boundaries as agent events"](../../proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md): that RFC proposes removing ALL boundary mirrors (including the turn boundaries and `agent/steering`) and migrating the stdio UI's turn rendering onto `session/event`. This RFC removes only the two step mirrors that have no live consumer; the turn mirrors stay until the stdio UI is migrated. The proposed RFC remains the home for finishing that migration. +- The cordis catalog (`docs/cordis-catalog/events-and-services.md`) is regenerated to drop the two events. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index ceef1bca8e..28af01a027 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -147,7 +147,7 @@ export interface LoopHandle { * 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) + * session('step/start') ⟵ durable step boundary (no agent/* mirror) * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble * req = {model, system, tools, messages: session.deriveMessages(), signal} * req = waterfall agent/request ⟵ hooks/compaction/model-switch @@ -159,7 +159,7 @@ export interface LoopHandle { * session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute * session('tool/result') * drain steering → session('steering/message'); emit agent/steering - * emit agent/step-end + * session('step/end') ⟵ durable step boundary (no agent/* mirror) * cont = waterfall agent/turn-continuation(default = hadToolCalls || steered) * if !cont && steering arrived from step-end/continuation listeners: cont = true * if !cont: break @@ -279,33 +279,29 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, 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). + // Close the open step exactly once (idempotent via stepOpen). Step boundaries + // are durable session events only — there is no agent/* step emit to mirror + // them (see the agent event-domain rule). A throwing step/end session-event + // listener must not abort finalization and strand the turn open (turn/end + // balance > notifying one bad listener); it is contained and surfaced as a + // turn error below. const closeStep = (): boolean => { if (!stepOpen) return false 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. + // error below. 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. + // A throwing step/end session-event listener surfaces as a turn error via + // failTurn (idempotent). This prevents a throwing listener from producing a + // silent "completed" turn when the step itself succeeded, AND keeps + // finalization going when closeStep runs from the outer catch. if (failure !== undefined) { failTurn(toError(failure)) return true @@ -382,24 +378,23 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, 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. + // Steering from the previous round's 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. + // listener (fires 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() } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 9cdaa1973b..775e08e6de 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -204,7 +204,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 - ctx.on('agent/step-start', () => { steps += 1 }) + ctx.on('session/event', (_session, event) => { if (event.type === 'step/start') steps += 1 }) const reasons: TurnEndReason[] = [] ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index d018eff7a2..8d8224ad5f 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -46,15 +46,21 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + // Turn boundaries are live agent/* emits; step boundaries are durable + // session events only (no agent/* mirror). Interleave both feeds in fire + // order to assert the full boundary nesting. const order: string[] = [] - for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) { + for (const name of ['agent/turn-start', 'agent/turn-end'] as const) { ctx.on(name, () => void order.push(name)) } + ctx.on('session/event', (_session, event) => { + if (event.type === 'step/start' || event.type === 'step/end') order.push(event.type) + }) send(agent, 'hi') await waitForIdle(ctx, agent) - expect(order).toEqual(['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end']) + expect(order).toEqual(['agent/turn-start', 'step/start', '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 @@ -269,7 +275,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 - ctx.on('agent/step-end', () => void steps++) + ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => { if (steps < 3) return true return next() @@ -371,7 +377,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 - ctx.on('agent/step-end', () => void steps++) + ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') 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) => { @@ -536,7 +542,7 @@ describe('agent loop', () => { ]) }) - it('stops the turn when agent/step-end listener failure has recorded an error', async () => { + it('stops the turn when a step/end session-event listener failure has recorded an error', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', { text: 'x' }), textResponse('should not run'), @@ -552,8 +558,11 @@ describe('agent loop', () => { })) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threw = false - ctx.on('agent/step-end', () => { - if (!threw) { threw = true; throw new Error('bad step-end listener') } + // A throwing step/end session-event listener is the surviving boundary-listener + // failure path (step boundaries have no agent/* mirror): closeStep contains it + // and surfaces it as a turn error rather than stranding the turn open. + ctx.on('session/event', (_session, event) => { + if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') } }) send(agent, 'go') diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index a092bd8419..7ee4923ce8 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -144,36 +144,6 @@ describe('HIGH: abort during tool execution ends the turn', () => { }) describe('HIGH: steering from late extension points is never stranded', () => { - it('steer() from an agent/step-end listener reaches the next request (/goal pattern)', async () => { - const adapter = new MockAdapter([ - toolCallResponse('c1', 'echo', { text: 'x' }), - textResponse('after steering'), - ]) - 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(AgentId('a1'), { model: 'mock' }) - - let steeredOnce = false - ctx.on('agent/step-end', () => { - if (steeredOnce) return - steeredOnce = true - agent.steer([{ type: 'text', text: 'goal reminder from step-end' }]) - }) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step-end') - }) - it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => { const adapter = new MockAdapter([ textResponse('no tools, would stop here'), @@ -539,24 +509,26 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete }) }) -describe('P1-6: step/start is appended before agent/step-start is emitted', () => { - it('a step-start listener sees the step/start event already in session.events', async () => { +describe('P1-6: a step/start session-event listener sees the event already in the log', () => { + it('the step/start event is in session.events when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' }) - // Capture, at the moment agent/step-start fires, whether the matching - // step/start event is already in the log (append-before-emit, the event-sourcing RFC). + // Session.append pushes the event BEFORE notifying session/event listeners, + // so a step/start listener always finds the matching event already in the + // log. (Step boundaries have no agent/* mirror — the session log is the live + // feed.) const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] - ctx.on('agent/step-start', (subject, turn, step) => { - if (subject !== agent) return - const events = [...subject.session.events] + ctx.on('session/event', (subject, event) => { + if (subject !== agent.session || event.type !== 'step/start') return + const events = [...subject.events] const last = events.at(-1) observed.push({ - turn, - step, + turn: event.data.turn, + step: event.data.step, lastEventType: last?.type, - sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === turn && e.data.step === step), + sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === event.data.turn && e.data.step === event.data.step), }) }) @@ -621,29 +593,35 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(adapter.requests).toHaveLength(0) }) - it('a throwing agent/step-start listener closes the open step then the turn (step/end before turn/end)', async () => { + it('a throwing step/start session-event listener fails the turn balanced (no step stranded open)', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' }) + // Step boundaries have no agent/* mirror; a throwing step/start session-event + // listener is the surviving step-boundary-listener failure. The throw fires + // INSIDE session.append('step/start') — before the loop marks the step open — + // so the loop never had an open step to close (no step/end is owed). The + // throw drives the outer catch, which fails the turn balanced. The invariants + // oracle (balancedHarness) rejects any imbalance, so a green run proves the + // turn/start..turn/end nesting holds with a lone step/start and no step/end. let threw = false - ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } }) + ctx.on('session/event', (_s, event) => { + if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } + }) const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.events] const c = boundaryCounts(agent) - expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 }) + // step/start was appended (Session.append pushes before notifying), but the + // listener throw pre-empted the loop marking the step open, so no step/end is + // owed; the turn still closes exactly once with an error, balanced. + expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 0, errors: 1 }) expect(errors.map(x => x.message)).toEqual(['boom step-start']) - // step/end must precede turn/end (the invariants oracle would reject - // turn/end-while-step-open, but assert the order explicitly too). - const stepEndIdx = e.findIndex(x => x.type === 'step/end') - const turnEndIdx = e.findIndex(x => x.type === 'turn/end') - expect(stepEndIdx).toBeGreaterThanOrEqual(0) - expect(stepEndIdx).toBeLessThan(turnEndIdx) + expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason.kind).toBe('error') }) it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => { @@ -829,17 +807,20 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(boundaryCounts(agent).turnEnd).toBe(2) }) - it('a throwing agent/step-end listener during a successful step ends the turn as error, not completed', async () => { - // closeStep() must surface a throwing step-end listener via failTurn so the + it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => { + // closeStep() must surface a throwing step/end listener via failTurn so the // turn ends with reason error, not a silent "completed" with the throw // swallowed. Regression test for the closeStep() catch that previously - // swallowed the throw in the normal (no-tool, no-steering) path. + // swallowed the throw in the normal (no-tool, no-steering) path. (Step + // boundaries have no agent/* mirror; the session-event listener is the path.) const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' }) let threw = false - ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('boom step-end') } }) + ctx.on('session/event', (_s, event) => { + if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') } + }) const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) @@ -903,18 +884,18 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar }) it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => { - // A throwing agent/step-start listener drives the outer catch, which calls - // closeStep() during finalization. closeStep appends step/end; a + // A finish-error stream opens a step then fails it, driving finalization + // through closeStep() with the step open. closeStep appends step/end; a // session/event listener throwing on THAT must not abort the catch before - // closeTurn(false) — step/end is already logged (balance holds) and the - // throw is contained + surfaced via failTurn, so turn/end is still appended. - const adapter = new MockAdapter([textResponse('never reached')]) + // closeTurn — step/end is already logged (balance holds) and the throw is + // contained + surfaced via failTurn, so turn/end is still appended. (The + // failed step itself also routes through failTurn; the step/end-listener + // throw is the second, contained, failure.) + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] + const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' }) - // Open a step, then make the agent/step-start emit throw (boundary throw → - // outer catch → closeStep during finalization). - ctx.on('agent/step-start', () => { throw new Error('boom step-start') }) let threw = false ctx.on('session/event', (_s, event) => { if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index efe392155c..2bd550a109 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -6,6 +6,34 @@ * Merge-extensible: `AgentOptions` supports declaration merging for * plugin-specific creation options. * + * ## Event-domain semantics (the boundary rule) + * + * The harness has three event domains, each with one job: + * + * - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT + * log. Owns `SessionEventMap`; every entry is JSON-only (no live objects). + * One `session/event` emit per append, plus the `session/flush` parallel + * durability checkpoint. Answers "what happened, durably/replayably." A + * consumer that wants the live transcript subscribes here. + * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the + * live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, + * `agent/step-result`, `agent/turn-continuation`) that mutate/veto, and + * TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, + * `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`, and the + * turn boundaries) that notify with the `Agent` in hand. Answers "right now, + * with the agent object — intercept or observe." + * - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution. + * + * **The rule:** a durable, replayable fact is a SessionEvent; a live + * interception or a transient/live-object signal is an `agent`/`tools` Cordis + * event. A datum that is BOTH (a turn/step boundary) lives in the session log, + * and is mirrored as an `agent/*` emit ONLY where a live consumer provably + * needs the `Agent` handle at that instant. Turn boundaries are so mirrored + * (the stdio UI labels output by `agent.id`); step boundaries are NOT (no live + * consumer needs them — read `step/start`/`step/end` from the session log). + * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` + * and the related `docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. + * * @module @deepseek-ai/dsh-agent/types */ @@ -155,29 +183,25 @@ declare module 'cordis' { */ 'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void - // ---- turn/step boundaries (emit) ---- + // ---- turn boundaries (emit) — the live boundary surface ---- + // Step boundaries are NOT mirrored here: a consumer that needs per-step + // boundaries reads the durable `step/start`/`step/end` session events (the + // session log is the live transcript feed). The TURN boundaries stay as + // agent/* emits because the only live consumer (the stdio UI) needs the + // `Agent` handle at the boundary to label output, which the session event + // does not carry. See the module doc's three-domain rule. /** * A turn began. `turn` is the 1-based turn number within the session. * @mode emit */ 'agent/turn-start'(agent: Agent, turn: number): void /** - * A turn ended. `reason` distinguishes a clean stop from a truncated or - * aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`). + * A turn ended. `reason` distinguishes a clean stop from a truncated, + * aborted, or hook-rejected one (`completed` | `aborted` | `error` | + * `disposed` | `max-tokens` | `rejected` | `interrupted`). * @mode emit */ 'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void - /** - * A step (one model call plus its tool dispatch) began. `step` is 1-based - * within the turn; a turn runs one or more steps. - * @mode emit - */ - 'agent/step-start'(agent: Agent, turn: number, step: number): void - /** - * A step ended. - * @mode emit - */ - 'agent/step-end'(agent: Agent, turn: number, step: number): void // ---- interception seams (waterfall) ---- /** From 8df89d8e3334b75a47b8c358990a69a3cfa13a49 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:19:18 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(events):=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20balance=20step=20on=20step/start-listener=20throw,?= =?UTF-8?q?=20restore=20/goal=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of PR-A found three blockers: - A throwing step/start session-event listener left an unbalanced log (turn/start → step/start → turn/end with no step/end), which the invariants oracle rejects — masked because that rejection was itself contained as a throwing turn/end listener. Fix the root cause in the loop: mark the step open BEFORE appending step/start (Session.append pushes before notifying), so the outer catch's closeStep() appends the balancing step/end. The test now asserts the balanced outcome (stepEnd:1, step/end before turn/end); proven load-bearing (revert the reorder → the test goes red with stepEnd:0). - Reintroduce the /goal-pattern guard deleted in the prior commit, migrated to a step/end session-event listener (the surviving step-boundary hook point), with a no-tools first step so it exercises the hasSteering continuation override. - Update packages/core/agent/README.md: step boundaries are no longer agent/* emits. --- packages/core/agent-loop/src/loop.ts | 7 ++- .../agent-loop/tests/review-fixes.spec.ts | 57 +++++++++++++++---- packages/core/agent/README.md | 5 +- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 28af01a027..dad1ed9320 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -382,8 +382,13 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // turn-start listeners on the first step) joins before the request. drainSteering(ctx, agent, turn) - session.append('step/start', { turn, step }) + // Mark the step open BEFORE the append: Session.append pushes the event + // to the log before notifying session/event listeners, so a THROWING + // step/start listener leaves step/start in the log. Setting stepOpen first + // means the outer catch's closeStep() then appends the balancing step/end + // (turn stays enclosed) instead of stranding an open step under turn/end. stepOpen = true + session.append('step/start', { turn, step }) const abort = new AbortController() handle.setAbort(abort) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 7ee4923ce8..28d5134cf8 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -169,6 +169,35 @@ describe('HIGH: steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing') }) + it('steer() from a step/end session-event listener reaches the next request (/goal pattern)', async () => { + // The /goal pattern steers from a step boundary so the model addresses a + // standing goal before stopping. Step boundaries have no agent/* mirror, so + // the surviving hook point is the durable step/end session event. With a + // no-tools first step the default continuation is stop; the steering queued + // here must force the hasSteering override and reach the next request. + const adapter = new MockAdapter([ + textResponse('no tools, would stop'), + textResponse('after goal reminder'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let steeredOnce = false + ctx.on('session/event', (subject, event) => { + if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return + steeredOnce = true + agent.steer([{ type: 'text', text: 'goal reminder from step/end' }]) + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // steering from the step/end listener forced a second step (hasSteering + // override) and reached the next model request. + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end') + }) + it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) @@ -593,18 +622,19 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(adapter.requests).toHaveLength(0) }) - it('a throwing step/start session-event listener fails the turn balanced (no step stranded open)', async () => { + it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' }) // Step boundaries have no agent/* mirror; a throwing step/start session-event - // listener is the surviving step-boundary-listener failure. The throw fires - // INSIDE session.append('step/start') — before the loop marks the step open — - // so the loop never had an open step to close (no step/end is owed). The - // throw drives the outer catch, which fails the turn balanced. The invariants - // oracle (balancedHarness) rejects any imbalance, so a green run proves the - // turn/start..turn/end nesting holds with a lone step/start and no step/end. + // listener is the surviving step-boundary-listener failure. The loop marks + // the step open BEFORE appending step/start (Session.append pushes before + // notifying, so a post-push listener throw still leaves stepOpen=true), so + // the outer catch's closeStep() appends the balancing step/end — the turn + // stays enclosed. The invariants oracle (balancedHarness) rejects any + // imbalance, so a green run proves turn/start → step/start → step/end → + // turn/end nesting holds. let threw = false ctx.on('session/event', (_s, event) => { if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } @@ -615,13 +645,16 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar send(agent, 'go') await waitForIdle(ctx, agent) + const e = [...agent.session.events] const c = boundaryCounts(agent) - // step/start was appended (Session.append pushes before notifying), but the - // listener throw pre-empted the loop marking the step open, so no step/end is - // owed; the turn still closes exactly once with an error, balanced. - expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 0, errors: 1 }) + expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 }) expect(errors.map(x => x.message)).toEqual(['boom step-start']) - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason.kind).toBe('error') + // step/end precedes turn/end (the invariants oracle would reject + // turn/end-while-step-open, but assert the order explicitly too). + const stepEndIdx = e.findIndex(x => x.type === 'step/end') + const turnEndIdx = e.findIndex(x => x.type === 'turn/end') + expect(stepEndIdx).toBeGreaterThanOrEqual(0) + expect(stepEndIdx).toBeLessThan(turnEndIdx) }) it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index d0ec0ee614..1d1b839da3 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -32,10 +32,11 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag - `agent/status` — idle / running / disposed transition - `agent/queued` — message entered inbox (source-resolved, steering flag) -#### Turn/step boundaries (emit) +#### Turn boundaries (emit) - `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`) -- `agent/step-start`, `agent/step-end` + +Step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs per-step boundaries reads the durable `step/start`/`step/end` session events (the session log is the live boundary feed). The turn boundaries stay as `agent/*` emits because the stdio UI needs the `Agent` handle (`agent.id`) at the boundary, which the session event does not carry. See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md). #### Interception seams (waterfall) From a821dcbe0d4154394b0265737fba1643506e191c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:46:59 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(events):=20address=20Codex=20confirmati?= =?UTF-8?q?on=20review=20=E2=80=94=20strengthen=20/goal=20guard,=20fix=20d?= =?UTF-8?q?oc=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-round Codex review of the PR-A taxonomy change found four issues, all verified against the code: - The /goal regression guard asserted only that the steered content reached requests[1], which passes even with the hasSteering override (loop.ts) disabled: leftover steering is re-enqueued as a next-turn queued message and also lands in requests[1], one turn later. The guard now asserts the same-turn shape — ONE turn, TWO steps, a steering/message recorded before step 2 — which is the mechanism the override drives. Proven to fail red with the override disabled. - The event-domain-semantics RFC's consequence list still described the pre-fix behavior (step marked open AFTER step/start, so no step/end owed). It now states the shipped behavior: the loop marks the step open BEFORE the append, so a throwing step/start listener gets a balancing step/end via closeStep(). - architecture.md's loop pseudocode said only continuation listeners force continuation; step/end session-event listeners (the /goal pattern) do too. - The agent/turn-end JSDoc listed a `rejected` TurnEndReason that does not exist on this branch (it belongs to the later interception work). Removed it and regenerated the cordis catalog; `interrupted` (a real variant) stays. --- docs/architecture.md | 3 ++- docs/cordis-catalog/events-and-services.md | 16 +++++------ .../2026-06-30-event-domain-semantics.md | 2 +- .../agent-loop/tests/review-fixes.spec.ts | 27 ++++++++++++++++--- packages/core/agent/src/types.ts | 5 ++-- 5 files changed, 37 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c0d6351949..67d6a06a20 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,7 +151,8 @@ forever: drain steering → session('steering/message'); emit agent/steering session('step/end') ⟵ durable step boundary (no agent/* mirror) cont = waterfall agent/turn-continuation(default = hadToolCalls || steered) - steering pending from continuation listeners forces cont = true + steering pending forces cont = true (from continuation listeners OR from + step/end session-event listeners — the /goal pattern; hasSteering override) if !cont: break session('turn/end'); emit agent/turn-end await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 2ce45b4051..3fac9263db 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -109,7 +109,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -121,7 +121,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -133,11 +133,11 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit -A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, or hook-rejected one (`completed` | `aborted` | `error` | `disposed` | `max-tokens` | `rejected` | `interrupted`). +A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, failed, disposed, or crash-interrupted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens` | `interrupted`); the reason union is merge-extensible, so a plugin can add further variants. ```ts cordis-catalog 'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void @@ -145,7 +145,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, or Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index 0cf1d68d7f..c33ff3d5b3 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -34,6 +34,6 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab - The loop no longer emits `agent/step-start`/`agent/step-end`; `closeStep` appends `step/end` only, and a throwing `step/end` session-event listener is the surviving step-boundary-listener failure path (contained by `closeStep` → `failTurn`, the turn closes balanced). - Tests that observed step boundaries via the removed emits now observe the durable `step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting, a throwing boundary listener failing the turn balanced) is unchanged; only the feed they read moved to the canonical one. Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved together. -- One behavior genuinely shifts and is documented in its test: a throwing `step/start` session-event listener throws INSIDE `session.append('step/start')`, before the loop marks the step open, so no `step/end` is owed (the old `agent/step-start` emit fired after the step was open). The turn still closes balanced with an error. +- The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first. - This is a partial, conservative realization of the broader [proposed simplification "Stop mirroring durable boundaries as agent events"](../../proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md): that RFC proposes removing ALL boundary mirrors (including the turn boundaries and `agent/steering`) and migrating the stdio UI's turn rendering onto `session/event`. This RFC removes only the two step mirrors that have no live consumer; the turn mirrors stay until the stdio UI is migrated. The proposed RFC remains the home for finishing that migration. - The cordis catalog (`docs/cordis-catalog/events-and-services.md`) is regenerated to drop the two events. diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 28d5134cf8..ab421aaa2e 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -169,12 +169,22 @@ describe('HIGH: steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing') }) - it('steer() from a step/end session-event listener reaches the next request (/goal pattern)', async () => { + it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => { // The /goal pattern steers from a step boundary so the model addresses a // standing goal before stopping. Step boundaries have no agent/* mirror, so // the surviving hook point is the durable step/end session event. With a // no-tools first step the default continuation is stop; the steering queued - // here must force the hasSteering override and reach the next request. + // here must force the `!shouldContinue && hasSteering` override so the SAME + // turn runs another step. + // + // The override is what this test guards, so it asserts the same-turn shape — + // NOT merely that the content reaches requests[1]. Without the override the + // turn would stop, and leftover steering is re-enqueued as a next-turn queued + // message, which ALSO lands in requests[1] (just one turn later). So a + // content-only assertion passes with the override disabled and guards + // nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with + // TWO steps and the steering recorded as a `steering/message` BEFORE step 2; + // re-enqueue fallback ⇒ TWO turns. const adapter = new MockAdapter([ textResponse('no tools, would stop'), textResponse('after goal reminder'), @@ -192,8 +202,17 @@ describe('HIGH: steering from late extension points is never stranded', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // steering from the step/end listener forced a second step (hasSteering - // override) and reached the next model request. + // Same-turn continuation: the steering forced step 2 within turn 1. + const events = [...agent.session.events] + expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(events.filter(e => e.type === 'step/start')).toHaveLength(2) + // The steered content is recorded as steering (same turn), BEFORE step 2 — + // not as a fresh turn's user/message. This is the mechanism the override uses. + const steeringIdx = events.findIndex(e => e.type === 'steering/message') + const step2Idx = events.map(e => e.type).lastIndexOf('step/start') + expect(steeringIdx).toBeGreaterThanOrEqual(0) + expect(steeringIdx).toBeLessThan(step2Idx) + // and it reached the next model request. expect(adapter.requests).toHaveLength(2) expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end') }) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 2bd550a109..6c3f2b8226 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -197,8 +197,9 @@ declare module 'cordis' { 'agent/turn-start'(agent: Agent, turn: number): void /** * A turn ended. `reason` distinguishes a clean stop from a truncated, - * aborted, or hook-rejected one (`completed` | `aborted` | `error` | - * `disposed` | `max-tokens` | `rejected` | `interrupted`). + * aborted, failed, disposed, or crash-interrupted one (`completed` | + * `aborted` | `error` | `disposed` | `max-tokens` | `interrupted`); the + * reason union is merge-extensible, so a plugin can add further variants. * @mode emit */ 'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void From b8d0da9f8c493fadbf4fdfd013ce1378bfc357e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:02:56 +0800 Subject: [PATCH 4/7] docs(loop): clarify the /goal steering comment names the step/end session event The continuation-override comment said "step-end/continuation listeners". With no agent/step-end emit, the surviving step-boundary listener is the durable step/end SESSION event, so spell it "step/end session-event/continuation listeners" to avoid implying a removed agent/* mirror. Comment-only; no behavior change. --- packages/core/agent-loop/src/loop.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index dad1ed9320..7ee480d570 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -161,7 +161,7 @@ export interface LoopHandle { * drain steering → session('steering/message'); emit agent/steering * session('step/end') ⟵ durable step boundary (no agent/* mirror) * cont = waterfall agent/turn-continuation(default = hadToolCalls || steered) - * if !cont && steering arrived from step-end/continuation listeners: cont = true + * if !cont && steering arrived from step/end session-event/continuation listeners: cont = true * if !cont: break * session('turn/end'); emit agent/turn-end * await ctx.parallel('session/flush', session) ⟵ durability checkpoint @@ -461,9 +461,9 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, 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. + // Steering from step/end session-event or 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 From 140f818a4245f53670bf3cc5275a0ccfc0031c87 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:26:45 +0800 Subject: [PATCH 5/7] refactor(events): remove the turn boundary mirror events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the boundary-mirror removal begun with the step mirrors: drop `agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and step boundaries are now read exclusively off the durable `session/event` feed (`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror for any boundary. - loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and its now-unreachable idempotency guard (it is called exactly once per turn, on mutually exclusive normal/catch paths); `failTurn` loses the dead post-close branch that only a throwing turn-end LISTENER could reach. - ui-stdio: render turn boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map (the `turn/start` event carries only the turn number, and the session id is not reliably the agent id). ui-stdio is a disposable test REPL, so this migration retires the sole justification the event-domain-semantics RFC gave for KEEPING the turn mirrors. - Tests: reason/turn-number collectors and the boundary-ordering test now read `session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that code path no longer exists). A new test covers the outer-catch disposed branch via a pre-step listener that disposes-then-throws (the surviving real path). - Docs: promote the "remove agent boundary mirror events" RFC to implemented (amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror); update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog. `agent/steering` and `agent/stream-chunk` are explicitly out of scope (not durable-boundary mirrors). ACP is unaffected — it already settles from the log's `turn/end` + `agent/status`; snapshot goldens are byte-unchanged. --- docs/architecture.md | 8 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cordis-catalog/events-and-services.md | 46 +--- docs/rfc/README.md | 2 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- .../2026-06-30-event-domain-semantics.md | 16 +- ...-20-remove-agent-boundary-mirror-events.md | 37 ++++ ...-20-remove-agent-boundary-mirror-events.md | 31 --- packages/core/agent-loop/src/loop.ts | 73 +++---- packages/core/agent-loop/tests/cancel.spec.ts | 27 ++- .../agent-loop/tests/coverage-edges.spec.ts | 71 +----- packages/core/agent-loop/tests/loop.spec.ts | 33 ++- .../agent-loop/tests/review-fixes.spec.ts | 205 +++++------------- packages/core/agent/README.md | 6 +- packages/core/agent/src/types.ts | 38 +--- packages/support/ui-stdio/README.md | 7 +- packages/support/ui-stdio/src/index.ts | 33 ++- .../support/ui-stdio/tests/ui-stdio.spec.ts | 52 ++++- packages/ui/acp/README.md | 2 +- packages/ui/acp/src/index.ts | 32 +-- 20 files changed, 282 insertions(+), 441 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md delete mode 100644 docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md diff --git a/docs/architecture.md b/docs/architecture.md index da6641440d..8f83625ab0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -159,7 +159,7 @@ forever: steering pending forces cont = true (from continuation listeners OR from step/end session-event listeners — the /goal pattern; hasSteering override) if !cont: break - session('turn/end'); emit agent/turn-end + session('turn/end') ⟵ durable turn boundary (no agent/* mirror) await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure reported via agent/error, not fatal) leftover steering re-enqueued as queued messages ⟵ steering is never stranded @@ -170,7 +170,7 @@ Error containment: a throwing `agent/turn-continuation` listener or a broken ste Turn-end reasons: a turn ends with one `TurnEndReason` — `completed`, `aborted`, `error`, `disposed`, or `max-tokens`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). This lets a consumer distinguish a clean stop from a truncated one (the ACP bridge maps it to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them. -A failure that happens once the turn is already closed has no in-turn position for a turn-end error reason (the turn already ended). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) and a throwing `agent/turn-end` listener are reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. +A failure that happens once the turn is already closed has no in-turn position for a turn-end error reason (the turn already ended). So a rejecting `session/flush` (the post-`turn/end` durability checkpoint) is reported via `agent/error` + the logger only, NOT as a session event; the turn stays balanced and the persistence backend keeps its buffered events for the next flush. **Turn-enclosure invariant**: every session event lives inside a turn (between a `turn/start` and its `turn/end`). The loop appends queued `user/message` events *after* `turn/start`, and an idle `agent.inject()` wraps its `context/message` in a one-shot `injection` turn. This makes the turn the single durability/replay boundary: a persistence backend can treat anything after the last `turn/end` as an interrupted-crash tail without risking the loss of legitimately-recorded between-turn context. The `dsh-invariants` plugin enforces it in dev (a message event outside an open turn throws). See [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). @@ -196,8 +196,8 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl |---|---| | Hook system (user + project level) | listeners on `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`; a hooks plugin bridges config files to shell commands | | `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | -| `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue | -| Dynamic workflow | orchestrator plugin on `agent/turn-end` (or the `step/end` session event) driving `send`/`steer` (+ sub-agents later) | +| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | +| Dynamic workflow | orchestrator plugin on the `turn/end` (or `step/end`) session event driving `send`/`steer` (+ sub-agents later) | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | | Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each step — runaway-turn survival, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering | diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index e6c0378361..48b3874bd4 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. +A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (settle from the durable `turn/end` session event — the boundary is a session event, not an `agent/*` mirror — with `agent/status` as the fallback if a peer listener starved yours), and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index c26b453a4f..e9a4447aec 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:164`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:170`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:263`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -75,7 +75,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:183`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:232`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -99,7 +99,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:257`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -123,7 +123,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -135,7 +135,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -147,31 +147,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) - -#### `agent/turn-end` — emit - -A turn ended. `reason` distinguishes a clean stop from a truncated, aborted, failed, disposed, or crash-interrupted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens` | `interrupted`); the reason union is merge-extensible, so a plugin can add further variants. - -```ts cordis-catalog -'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void -``` - -Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) - -Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) - -#### `agent/turn-start` — emit - -A turn began. `turn` is the 1-based turn number within the session. - -```ts cordis-catalog -'agent/turn-start'(agent: Agent, turn: number): void -``` - -Types: [Agent](../core-data-structures/core.md) - -Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 30dbb7702c..64868d475e 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -50,7 +50,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | ### Architecture @@ -97,6 +96,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | +| [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 42e231430b..63ebc1c875 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -37,4 +37,4 @@ Costs: `agent.inject()` while idle now writes three log lines instead of one, an The rule is intentionally producer-enforced and dev-checked rather than reader-tolerated: a future backend (SQLite/WAL) inherits the same clean boundary for free, and a plugin that records an event outside a turn fails loudly in dev instead of silently losing data on the next reload. -The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush` (which runs as the post-`turn/end` durability checkpoint) or a throwing `agent/turn-end` listener (after `closeTurn` already appended `turn/end`) — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So those post-turn failures are reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log. +The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush`, which runs as the post-`turn/end` durability checkpoint — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So that post-turn failure is reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log. diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index c33ff3d5b3..5ca6f874b5 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -22,18 +22,14 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab - **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`, and the turn boundaries) that notify with the `Agent` in hand. - **`tools/*` — the tool registry + execution seam.** -**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A datum that is BOTH — a turn or step boundary — lives in the session log, and is mirrored as an `agent/*` emit ONLY where a live consumer provably needs the `Agent` handle at that instant. +**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. -**Applying the rule to the boundary twins (prune case-by-case):** - -- `agent/turn-start` — **KEPT.** The stdio UI (`dsh-ui-stdio`) labels turn output by `agent.id`, which the `turn/start` session event does not carry. A genuine live-object need. -- `agent/turn-end` — **KEPT.** The stdio UI listens to print the next-prompt glyph. (Note: the ACP bridge does NOT settle on this event — it settles from `session/event` `turn/end` plus `agent/status`; the surviving justification is the stdio UI alone.) -- `agent/step-start`, `agent/step-end` — **REMOVED.** No production consumer needs the live `Agent` at a step boundary; a consumer that wants per-step boundaries reads the durable `step/start`/`step/end` session events. Removing the two emits also simplifies the loop's `closeStep` (one append, no paired emit). +**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) was migrated to render boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). ## Consequences -- The loop no longer emits `agent/step-start`/`agent/step-end`; `closeStep` appends `step/end` only, and a throwing `step/end` session-event listener is the surviving step-boundary-listener failure path (contained by `closeStep` → `failTurn`, the turn closes balanced). -- Tests that observed step boundaries via the removed emits now observe the durable `step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting, a throwing boundary listener failing the turn balanced) is unchanged; only the feed they read moved to the canonical one. Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved together. +- The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. A throwing `step/end`/`turn/end` session-event listener is the surviving boundary-listener failure path (contained inside `closeStep`/`closeTurn` — `Session.append` pushes the event before notifying listeners, so the boundary is durable and the turn closes balanced regardless). +- Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together. - The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first. -- This is a partial, conservative realization of the broader [proposed simplification "Stop mirroring durable boundaries as agent events"](../../proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md): that RFC proposes removing ALL boundary mirrors (including the turn boundaries and `agent/steering`) and migrating the stdio UI's turn rendering onto `session/event`. This RFC removes only the two step mirrors that have no live consumer; the turn mirrors stay until the stdio UI is migrated. The proposed RFC remains the home for finishing that migration. -- The cordis catalog (`docs/cordis-catalog/events-and-services.md`) is regenerated to drop the two events. +- The full realization of this is [the simplification RFC "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (a live control signal, not a boundary mirror) is retained; see that RFC's scope section. +- The cordis catalog (`docs/cordis-catalog/events-and-services.md`) is regenerated to drop the mirror events. diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md new file mode 100644 index 0000000000..d4cf8bbfe9 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -0,0 +1,37 @@ +# RFC: Stop mirroring durable boundaries as agent events + +Status: implemented (accepted 2026-07-01) + + + +## Problem + +The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`. + +This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. + +## Decision + +Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. + +The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle (or its short id) at a boundary keeps a small map from session id to agent id built from `agent/created`/`agent/disposed`; `dsh-ui-stdio` does exactly this to label its `[ turn N]` header, since the `turn/start` session event carries only the turn number. The canonical record remains the event-sourced session log. + +The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it was migrated to `session/event` + the id map, and the turn mirrors were removed too. + +## Scope: what is and isn't removed + +Removed (durable-boundary mirrors — the session log is authoritative for each): `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`. + +RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: + +- `agent/steering` — a live control signal, not a boundary. (The original proposal bundled it into the removal; validating against the code, it is not a duplicate of a durable boundary, so removing it here would have been scope creep. Its fate is a separate future decision.) +- `agent/stream-chunk` — the live token stream. `assistant/chunk` persistence remains load-bearing, so the chunk stream could later be evaluated as a mirror, but that is a separate decision. +- `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only. + +## What we give up + +A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. diff --git a/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md deleted file mode 100644 index 4b1cd75a56..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ /dev/null @@ -1,31 +0,0 @@ -# RFC: Stop mirroring durable boundaries as agent events - -Status: proposed - -## Problem - -The loop records the canonical transcript in `SessionEvent` and also emits a parallel set of live `agent/*` mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, `agent/stream-chunk`, and `agent/steering`. The mirrors make consumers choose between two sources of truth. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI is the only production consumer that still renders turn boundaries and the token stream from the mirror events; it already renders tool calls and results from `session/event`. - -This duplication is not free. Every lifecycle change has to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also make failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. - -## Proposal - -Make `session/event` the live transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. Keep agent lifecycle/control events that are not transcript data: `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued`. `agent/queued` is an inbox acknowledgement rather than a transcript mirror: it fires before any durable event exists, and cancelled queued work may never enter the log. - -Remove the duplicate durable-boundary mirrors from the agent event taxonomy. If a UI wants an agent handle from a session event, it can keep a small map from session id to agent built from `agent/created`/`agent/disposed`, or the registry can offer an explicit lookup. The canonical record remains the event-sourced session log. - -## Acceptance criteria - -- ACP and stdio render transcript content from `session/event`. -- `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`, and `agent/steering` are removed or reduced to private implementation details. -- `agent/queued` is either retained and documented as live-only inbox/control state, or deleted in a separate proposal that names the queue-acknowledgement capability loss. -- Tests assert the persisted event stream, not a second mirror stream, for turn and step ordering. -- Documentation presents `SessionEvent` as both the durable source and the live transcript feed. - -## What we give up - -A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: transcript consumers should not depend on a second event feed that can drift from the durable log. - -## Related - -Because high-fidelity `assistant/chunk` persistence remains load-bearing, `agent/stream-chunk` can be evaluated as another mirror of durable session data rather than as the only token stream. If a future proposal moves chunks out of the canonical log, `agent/stream-chunk` would need a fresh decision as a deliberately live-only UI signal. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index f71d6167b8..eba75f9615 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -145,7 +145,7 @@ export interface LoopHandle { * 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 + * drain queued → 'turn/start' → session('user/message'…) ⟵ durable turn boundary (no agent/* mirror) * STEP loop: * drain steering → session('steering/message') ⟵ catches late steering * assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble @@ -165,7 +165,7 @@ export interface LoopHandle { * cont = waterfall agent/turn-continuation(default = hadToolCalls || steered) * if !cont && steering arrived from step/end session-event/continuation listeners: cont = true * if !cont: break - * session('turn/end'); emit agent/turn-end + * session('turn/end') ⟵ durable turn boundary (no agent/* mirror) * 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 @@ -277,7 +277,6 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, let reason: TurnEndReason = { kind: 'completed' } let step = 0 - let turnEnded = false let stepOpen = false let errorReported = false @@ -320,47 +319,38 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // Set the error reason ONLY while the turn is still open — closeTurn appends - // turn/end with it. If the turn has already ended (the only way here: a - // throwing agent/turn-end listener after closeTurn(true) already appended - // turn/end), the reason can no longer affect the durable log, so log the late - // throw directly instead — otherwise the listener exception would vanish. - if (!turnEnded) { - reason = { kind: 'error', step, ...errorData(err) } - } else { - ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`) - } + // The turn is always still open here: the only failure that can reach + // failTurn once turn/end is appended would be a throwing turn-boundary + // listener, and turn boundaries are durable session events with no agent/* + // mirror to throw. A throwing `turn/end` session-event listener is already + // contained inside closeTurn (append pushes before notifying, so the + // boundary is durable). So set the error reason for closeTurn to append. + reason = { kind: 'error', step, ...errorData(err) } try { ctx.emit('agent/error', agent, turn, step, err) } catch { - // contained: the error is already captured (on `reason`, or via the logger - // above); a throwing agent/error listener must not prevent the turn from - // closing. + // contained: the error is already captured on `reason`; 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 + // Close the turn. Called exactly once per turn — the normal loop exit and the + // outer catch are mutually exclusive paths, and this never throws (the append + // is contained below), so there is no re-entry to guard against (unlike + // closeStep, which the cancel branches and the outer catch can both reach). + // Turn boundaries are durable session events only — there is no agent/* turn + // emit to mirror them (see the agent event-domain rule). + const closeTurn = (): void => { // Session.append pushes turn/end BEFORE notifying session/event listeners, // so a throwing listener leaves turn/end in the log (the turn is balanced) - // but would otherwise escape — from the outer catch'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.) + // but would otherwise escape — from the outer catch it would propagate to + // the runLoop backstop. Contain it: the boundary is durable either way, and + // finalization must not abort on a bad listener. try { session.append('turn/end', { turn, reason }) } catch (error: unknown) { ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`) } - if (emit) ctx.emit('agent/turn-end', agent, turn, reason) } try { @@ -375,13 +365,12 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, for (const message of queued) { session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' }) } - ctx.emit('agent/turn-start', agent, turn) while (true) { step += 1 - // Steering from the previous round's continuation listeners (or - // turn-start listeners on the first step) joins before the request. + // Steering from the previous round's continuation listeners joins before + // the request. drainSteering(ctx, agent, turn) // The step's AbortController exists BEFORE any async pre-step work so a @@ -530,8 +519,8 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, } } - // Normal / inline-error loop exit: close the turn and notify. - closeTurn(true) + // Normal / inline-error loop exit: close the turn. + closeTurn() } catch (error: unknown) { // Decide whether this turn was ever opened from the LOG, not a flag. // Session.append pushes the event BEFORE notifying session/event listeners, @@ -550,18 +539,16 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, 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.) + // branch (without reporting an error), so preserve disposed rather than + // overwrite it. Otherwise a mid-step 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) + closeTurn() } // Durability checkpoint: persistence plugins drain write-behind buffers. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 7950cf80a8..df94c6c503 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -117,7 +117,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -134,7 +134,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -166,22 +166,23 @@ describe('Agent.cancel()', () => { expect(reasons.length).toBe(2) }) - it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => { + it('cancel from a synchronous turn/start session-event 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(AgentId('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 the step AbortController, is what catches this) — no model step runs. + // A turn/start listener fires right after turn/start is appended, BEFORE any + // AbortController is installed for the step. Cancelling there must still drop + // the step (the turn-scoped marker, not the step AbortController, 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 dispose = ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start') }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -210,7 +211,7 @@ describe('Agent.cancel()', () => { }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -271,9 +272,11 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steps = 0 - ctx.on('session/event', (_session, event) => { if (event.type === 'step/start') steps += 1 }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_session, event) => { + if (event.type === 'step/start') steps += 1 + if (event.type === 'turn/end') reasons.push(event.data.reason) + }) let continued = false ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => { diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 3eefbf6986..7a044bfb57 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -36,69 +36,6 @@ function send(agent: ReactLoopAgent, text: string) { } 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(AgentId('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(AgentId('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 @@ -192,14 +129,14 @@ describe('tool JSON parse', () => { }) describe('toError normalization', () => { - it('normalizes non-Error throws from turn-start listeners via toError', async () => { + it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false - ctx.on('agent/turn-start', () => { - if (!threwOnce) { + ctx.on('session/event', (_session, event) => { + if (event.type === 'turn/start' && !threwOnce) { threwOnce = true throw 'naked string error' // non-Error throw, normalized via toError } @@ -287,7 +224,7 @@ describe('disposed vs aborted branching', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 311bc88fa3..f3a6da38b4 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -46,21 +46,20 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // Turn boundaries are live agent/* emits; step boundaries are durable - // session events only (no agent/* mirror). Interleave both feeds in fire - // order to assert the full boundary nesting. + // All boundaries — turn and step — are durable session events on the + // session/event feed (no agent/* mirror). Record them in fire order to + // assert the full boundary nesting. const order: string[] = [] - for (const name of ['agent/turn-start', 'agent/turn-end'] as const) { - ctx.on(name, () => void order.push(name)) - } ctx.on('session/event', (_session, event) => { - if (event.type === 'step/start' || event.type === 'step/end') order.push(event.type) + if (event.type === 'turn/start' || event.type === 'step/start' || event.type === 'step/end' || event.type === 'turn/end') { + order.push(event.type) + } }) send(agent, 'hi') await waitForIdle(ctx, agent) - expect(order).toEqual(['agent/turn-start', 'step/start', 'step/end', 'agent/turn-end']) + expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) const types = agent.session.events.map(e => e.type) // turn/start opens the turn, THEN the queued user message is recorded inside @@ -436,7 +435,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') // wait until the stream is hanging, then cancel @@ -456,7 +455,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -490,7 +489,7 @@ describe('agent loop', () => { }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -512,7 +511,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -545,7 +544,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -587,7 +586,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -606,7 +605,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -683,7 +682,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const turns: number[] = [] - ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) // queue two messages while idle — first starts turn 1 immediately; // queue the second during turn 1 via a stream-chunk hook @@ -730,7 +729,7 @@ describe('agent loop', () => { 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)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'hi') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 092e63a4e5..51fdd2e146 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -132,7 +132,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -217,20 +217,21 @@ describe('HIGH: steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end') }) - it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => { + it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - let steeredOnce = false - ctx.on('agent/turn-end', () => { - if (steeredOnce) return - steeredOnce = true - agent.steer([{ type: 'text', text: 'too late for this turn' }]) - }) - const turns: number[] = [] - ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + let steeredOnce = false + ctx.on('session/event', (subject, event) => { + if (subject !== agent.session) return + if (event.type === 'turn/start') turns.push(event.data.turn) + if (event.type === 'turn/end' && !steeredOnce) { + steeredOnce = true + agent.steer([{ type: 'text', text: 'too late for this turn' }]) + } + }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -332,7 +333,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { const statuses: string[] = [] const reasons: TurnEndReason[] = [] ctx.on('agent/status', (_agent, status) => void statuses.push(status)) - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -461,7 +462,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () ctx2.effect(() => forked.start()) const turns: number[] = [] - ctx2.on('agent/turn-start', (_agent, turn) => void turns.push(turn)) + ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) forked.send([{ type: 'text', text: 'continue' }]) await new Promise((resolve) => { ctx2.on('agent/status', (subject, status) => { @@ -505,7 +506,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -530,7 +531,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -548,7 +549,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' }) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -619,28 +620,6 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar } } - it('a throwing agent/turn-start listener still closes the turn with exactly one error and one turn/end, no step', async () => { - const adapter = new MockAdapter([textResponse('never reached')]) - const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnstart'), { model: 'mock' }) - - let threw = false - ctx.on('agent/turn-start', () => { if (!threw) { threw = true; throw new Error('boom turn-start') } }) - const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const c = boundaryCounts(agent) - // turn opened and closed; no step ran; exactly one error turn-end + emitted. - expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 0, stepEnd: 0, errors: 1 }) - expect(errors.map(e => e.message)).toEqual(['boom turn-start']) - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toEqual({ kind: 'error', step: 0, message: 'boom turn-start' }) - // model was never called (we threw before the step's request). - expect(adapter.requests).toHaveLength(0) - }) - it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) @@ -720,7 +699,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -737,46 +716,46 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) }) - it('preserves reason disposed when the turn-end emit throws during disposal (outer-catch disposed branch)', async () => { - // Dispose mid-step → the step-error branch sets reason=disposed (no error - // reported). closeTurn(true) then emits agent/turn-end, whose listener - // throws → control reaches the outer catch with isDisposed() && !errorReported, - // which must PRESERVE disposed rather than overwrite it with the listener's - // throw. This is the only path that exercises that catch sub-branch. - const adapter = new MockAdapter(['hang']) + it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => { + // Reach the OUTER catch while disposed: an `agent/pre-step` listener requests + // disposal AND throws. The throw escapes the pre-step `await` (line ~419) to + // the loop's outer catch — BEFORE the post-pre-step disposal check at ~422 + // gets to run — so the catch sees `isDisposed() && !errorReported` and must + // PRESERVE reason=disposed rather than overwrite it with the listener's throw + // (disposal is not a failure). This is the surviving path to that sub-branch + // now that there is no turn-boundary emit to throw from. + const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' }) + agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) - // The FIRST agent/turn-end emit throws (the disposal-driven turn end). let threw = false - ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end during disposal') } }) - // Collect agent/error emissions to prove none is surfaced through that - // channel either (the listener throw must be fully contained). + ctx.on('agent/pre-step', () => { + if (threw) return + threw = true + // Request disposal, then throw in the same synchronous tick: status flips + // to 'disposed' (the disposer aborts the step controller) and the throw + // drives control into the outer catch with isDisposed() already true. + void fiber.dispose() + throw new Error('boom pre-step during disposal') + }) const errorEmits: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - await fiber.dispose() // dispose during the hanging step await agent.done - // The throwing turn-end listener actually fired — proving the outer-catch - // path was exercised, not skipped. - expect(threw).toBe(true) - const e = [...agent.session.events] - // Exactly one turn/start and one turn/end (balanced); the turn/end carries - // the disposed reason, NOT an error reason from the throwing listener. + // Balanced: one turn/start, one turn/end carrying disposed (NOT error). expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) - // The throwing turn-end listener is contained: the turn/end carries the - // disposed reason (not an error) and no agent/error is emitted (disposal is - // not a failure; the throw is swallowed). expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false) + // No step opened (the throw was before step/start) and disposal is not a + // failure, so no agent/error for the contained throw. + expect(e.some(x => x.type === 'step/start')).toBe(false) expect(errorEmits).toHaveLength(0) }) @@ -822,43 +801,6 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(adapter.requests).toHaveLength(1) }) - it('a throwing turn-end listener on a SUCCESSFUL turn leaves no event after turn/end (loadable log)', async () => { - // Regression: a normal turn completes, closeTurn(true) appends turn/end and - // emits agent/turn-end whose listener throws. The error must NOT be appended - // as a session event after turn/end — that would sit past the commit - // boundary and be dropped as a crash tail on resume (the turn-enclosure RFC). It is - // surfaced via agent/error instead, and the log's last event is turn/end. - const adapter = new MockAdapter([textResponse('done'), textResponse('next ok')]) - const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-tend'), { model: 'mock' }) - - let threw = false - ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) - const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const c = boundaryCounts(agent) - expect(c.turnEnd).toBe(1) - expect(c.errors).toBe(0) // NO session error event (it would be post-turn/end) - expect(agent.session.events.at(-1)?.type).toBe('turn/end') // last event is the boundary - expect(errors.map(e => e.message)).toEqual(['boom turn-end']) // surfaced via agent/error - // The late throw is also logged directly: failTurn's turn-already-ended - // branch warns so a throwing turn-end listener after turn/end never vanishes. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/turn-end listener threw after turn 1 closed')) - // The whole log is loadable (nothing dropped): a fresh replay sees the turn. - const replay = new Session(SessionId('replay'), [...agent.session.events]) - expect(replay.deriveMessages().map(m => m.role)).toEqual(['user', 'assistant']) - - // loop survives. - send(agent, 'again') - await waitForIdle(ctx, agent) - expect(boundaryCounts(agent).turnEnd).toBe(2) - }) - it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => { // closeStep() must surface a throwing step/end listener via failTurn so the // turn ends with reason error, not a silent "completed" with the throw @@ -902,39 +844,6 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar expect(c2.stepStart).toBe(c2.stepEnd) }) - it('a step error followed by a throwing turn-end listener logs the error exactly once (no double-report)', async () => { - // The step fails (finish-error) → failTurn records ONE error and sets the - // error reason. closeTurn(true) then appends turn/end and emits - // agent/turn-end, whose listener throws → the outer catch calls failTurn - // again, but its errorReported guard makes it a no-op. Trap #1: exactly one - // error, the turn stays balanced. - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }] - const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) - const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-double'), { model: 'mock' }) - - let threw = false - ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } }) - const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) - - send(agent, 'go') - await waitForIdle(ctx, agent) - - const c = boundaryCounts(agent) - // exactly one error turn-end + one agent/error emit, despite two failTurn calls. - expect(c.errors).toBe(1) - expect(errors.map(e => e.message)).toEqual(['provider down']) - expect(c.turnStart).toBe(1) - expect(c.turnEnd).toBe(1) // single turn/end, balanced - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider down' }) - - // loop survives the compound failure. - send(agent, 'again') - await waitForIdle(ctx, agent) - expect(boundaryCounts(agent).turnEnd).toBe(2) - }) - it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => { // A finish-error stream opens a step then fails it, driving finalization // through closeStep() with the step open. closeStep appends step/end; a @@ -974,11 +883,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => { // closeTurn appends turn/end; Session.append pushes it BEFORE notifying // session/event listeners, so a throwing listener leaves turn/end in the log - // (the turn is balanced) but must not escape — from the normal-path - // closeTurn(true) it would otherwise propagate; the append is contained so - // the turn/end emit + loop continue. (A throwing agent/turn-end LISTENER is - // a separate, already-tested path; here the session/event append notify is - // what throws.) + // (the turn is balanced) but must not escape — from the normal-path closeTurn + // it would otherwise propagate; the append is contained so the loop continues. + // Turn boundaries are durable session events only (no agent/* mirror), so this + // session/event append-notify throw is the sole turn-end-listener failure path. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' }) @@ -1117,7 +1025,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') // Give the loop time to enter the step and reach assemble(). @@ -1143,10 +1051,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { // No step was opened, no LLM call was made. expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // agent/turn-end may not fire when disposal happens during assembly: the - // fiber's disposer (stop→status=disposed) runs before closeTurn(true)'s - // emit, and the LIFO chain disposes effects in reverse registration order. - // The turn/end durable record is the one that matters. + // The durable turn/end record is the authoritative turn-boundary signal + // (turn boundaries have no agent/* mirror), so this asserts on the log. }) it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => { @@ -1175,7 +1081,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 50)) @@ -1230,7 +1136,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 50)) @@ -1251,9 +1157,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // agent/turn-end may not fire when disposal happens during pre-step: the - // fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end - // is the authoritative record. + // The durable turn/end record is the authoritative turn-boundary signal + // (turn boundaries have no agent/* mirror). }) it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => { @@ -1283,7 +1188,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] - ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason)) + ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -1348,7 +1253,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => { expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) expect(e.some(x => x.type === 'assistant/message')).toBe(false) expect(adapter.requests).toHaveLength(0) - // The durable turn/end reason is the authoritative record; agent/turn-end - // may not fire when disposal interleaves with closeTurn(true)'s emit. + // The durable turn/end reason is the authoritative turn-boundary record + // (turn boundaries have no agent/* mirror). }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 594ae06ef0..a27d7f8d57 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -32,11 +32,9 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag - `agent/status` — idle / running / disposed transition - `agent/queued` — message entered inbox (source-resolved, steering flag) -#### Turn boundaries (emit) +#### Boundaries are durable session events, not `agent/*` emits -- `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`) - -Step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs per-step boundaries reads the durable `step/start`/`step/end` session events (the session log is the live boundary feed). The turn boundaries stay as `agent/*` emits because the stdio UI needs the `Agent` handle (`agent.id`) at the boundary, which the session event does not carry. See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md). +Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs them reads the durable `turn/start`/`turn/end`/`step/start`/`step/end` events off the `session/event` feed (the session log is the live boundary feed, carrying the `Session` — the turn/step numbers and reasons ride on the event data). See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md) and [the remove-boundary-mirror-events RFC](../../../docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md). #### Interception seams diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 82682ef856..0dcc1d0501 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -26,13 +26,12 @@ * * **The rule:** a durable, replayable fact is a SessionEvent; a live * interception or a transient/live-object signal is an `agent`/`tools` Cordis - * event. A datum that is BOTH (a turn/step boundary) lives in the session log, - * and is mirrored as an `agent/*` emit ONLY where a live consumer provably - * needs the `Agent` handle at that instant. Turn boundaries are so mirrored - * (the stdio UI labels output by `agent.id`); step boundaries are NOT (no live - * consumer needs them — read `step/start`/`step/end` from the session log). + * event. A turn/step boundary is a durable fact: it lives in the session log + * and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` + * emit. A consumer that needs the `Agent` handle (or its short id) at a boundary + * keeps a session-id→agent map from `agent/created`/`agent/disposed`. * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` - * and the related `docs/rfc/proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. + * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. * * @module @deepseek-ai/dsh-agent/types */ @@ -47,7 +46,7 @@ export type AgentId = Branded<'AgentId'> export function AgentId(id: string): AgentId { return id as AgentId } -import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' /** * Options an agent is created with. @@ -183,26 +182,11 @@ declare module 'cordis' { */ 'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void - // ---- turn boundaries (emit) — the live boundary surface ---- - // Step boundaries are NOT mirrored here: a consumer that needs per-step - // boundaries reads the durable `step/start`/`step/end` session events (the - // session log is the live transcript feed). The TURN boundaries stay as - // agent/* emits because the only live consumer (the stdio UI) needs the - // `Agent` handle at the boundary to label output, which the session event - // does not carry. See the module doc's three-domain rule. - /** - * A turn began. `turn` is the 1-based turn number within the session. - * @mode emit - */ - 'agent/turn-start'(agent: Agent, turn: number): void - /** - * A turn ended. `reason` distinguishes a clean stop from a truncated, - * aborted, failed, disposed, or crash-interrupted one (`completed` | - * `aborted` | `error` | `disposed` | `max-tokens` | `interrupted`); the - * reason union is merge-extensible, so a plugin can add further variants. - * @mode emit - */ - 'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void + // Turn and step boundaries are NOT mirrored as agent/* emits: a consumer + // that needs them reads the durable `turn/start`/`turn/end`/`step/start`/ + // `step/end` session events off the `session/event` feed (the session log is + // the live transcript feed). See the module doc's three-domain rule and the + // "remove agent boundary mirror events" RFC. // ---- step/request extension seams (serial + waterfall) ---- /** diff --git a/packages/support/ui-stdio/README.md b/packages/support/ui-stdio/README.md index b65fd4d8e1..25f53833b7 100644 --- a/packages/support/ui-stdio/README.md +++ b/packages/support/ui-stdio/README.md @@ -1,6 +1,8 @@ # @deepseek-ai/dsh-ui-stdio -A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it only consumes the `agent/*` event taxonomy plus the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface. +A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it consumes the `session/event` transcript feed plus a few `agent/*` control events (`agent/stream-chunk`, `agent/status`, `agent/created`/`agent/disposed`) and the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface. + +This is a **convenience REPL for local testing and the demos, not a product surface** — its observable behavior is free to change. It is deliberately NOT treated as a load-bearing consumer when weighing whether a live event/API must exist: the boundary mirror events were removed precisely because "ui-stdio renders from them" is not a product constraint (it was migrated to `session/event`). The real product surfaces are the ACP bridge (`dsh-acp`) and the app packages. This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`. @@ -23,8 +25,7 @@ This package consolidates what were two near-identical copies under `examples/ec Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.) - `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on. -- `agent/turn-start` / `agent/turn-end` — a `[ turn N]` header and a trailing `> ` prompt. -- `session/event` — `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`. +- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[ turn N]` header (the short agent label comes from an `agent/created`→id map, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist. ## The I/O seam diff --git a/packages/support/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts index edfa82285e..5f3b2bdc1c 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/support/ui-stdio/src/index.ts @@ -76,6 +76,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime + // Render label lookup: the `turn/start` session event carries only the turn + // number, so to print the short agent id (`[main turn 1]`) we map the + // session's id to its agent's id. The session id is not reliably the agent id + // (a session can be created with an explicit/client-supplied id), so build the + // map from `agent/created` rather than parsing the id string. + const labelBySession = new Map() + ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) + ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) + let inReasoning = false ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => { if (chunk.type === 'reasoning-delta') { @@ -90,18 +99,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } }) - ctx.on('agent/turn-start', (agent, turn) => { - output.write(`\n[${agent.id} turn ${turn}] `) - }) - - ctx.on('agent/turn-end', () => { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - output.write('\n> ') - }) - - ctx.on('session/event', (_session, event) => { - if (event.type === 'tool/call') { + // Transcript rendering off the durable `session/event` feed — turn/step + // boundaries, tool activity, and todos all come from the one canonical stream + // (no agent/* boundary mirrors). + ctx.on('session/event', (session, event) => { + if (event.type === 'turn/start') { + const label = labelBySession.get(session.header.id) ?? session.header.id + output.write(`\n[${label} turn ${event.data.turn}] `) + } else if (event.type === 'turn/end') { + if (inReasoning) output.write('\x1B[0m') + inReasoning = false + output.write('\n> ') + } else if (event.type === 'tool/call') { const { name: toolName, arguments: args } = event.data if (inReasoning) output.write('\x1B[0m') inReasoning = false diff --git a/packages/support/ui-stdio/tests/ui-stdio.spec.ts b/packages/support/ui-stdio/tests/ui-stdio.spec.ts index 7bd1fd4868..0c58211d82 100644 --- a/packages/support/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/support/ui-stdio/tests/ui-stdio.spec.ts @@ -56,11 +56,19 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { status, sent, steered, + // A minimal session stub: the UI reads only `session.header.id` (to map the + // session back to its agent id for the turn-boundary label). + session: { header: { id: `${id}-session` } }, send: (content: ContentBlock[]) => void sent.push(content), steer: (content: ContentBlock[]) => void steered.push(content), } as never } +/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ +function makeSession(agentId: string): Session { + return { header: { id: `${agentId}-session` } } as Session +} + const CONFIG: Config = { welcome: 'hi there', agent: 'main' } async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { @@ -116,23 +124,54 @@ describe('createStdioChat rendering', () => { expect(out.text()).toBe(before) }) - it('renders turn-start and turn-end markers', async () => { + it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - ctx.emit('agent/turn-start', agent, 3) + // agent/created populates the session-id → agent-id label map. + ctx.emit('agent/created', agent) + const session = makeSession('main') + ctx.emit('session/event', session, { + type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, + } as SessionEvent) expect(out.text()).toContain('[main turn 3] ') - ctx.emit('agent/turn-end', agent, 3, { kind: 'completed' }) + ctx.emit('session/event', session, { + type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } }, + } as SessionEvent) expect(out.text()).toContain('\n> ') }) - it('resets dim styling at turn-end if a turn ends mid-reasoning', async () => { + it('falls back to the session id as the label when no agent is mapped', async () => { + const { ctx, out } = await setup() + // No agent/created emitted, so the label map is empty — the header id shows. + ctx.emit('session/event', makeSession('orphan'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[orphan-session turn 1] ') + }) + + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'mid' }) - ctx.emit('agent/turn-end', agent, 1, { kind: 'completed' }) + ctx.emit('session/event', makeSession('main'), { + type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } }, + } as SessionEvent) expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) + it('drops the label mapping on agent/disposed', async () => { + const { ctx, out } = await setup() + const agent = makeAgent('main') + ctx.emit('agent/created', agent) + ctx.emit('agent/disposed', agent) + // After disposal the map no longer resolves the agent id — fall back to the + // session header id. + ctx.emit('session/event', makeSession('main'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main-session turn 1] ') + }) + it('renders tool/call and tool/result session events', async () => { const { ctx, out } = await setup() const session = {} as Session @@ -196,7 +235,8 @@ describe('createStdioChat rendering', () => { const { ctx, out } = await setup() const before = out.text() ctx.emit('session/event', {} as Session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } }, + type: 'user/message', seq: 1, time: 0, + data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, } as SessionEvent) expect(out.text()).toBe(before) }) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index ad50383542..3f3fe967d0 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -57,7 +57,7 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal ## Settle-exactly-once -A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream), NOT the `agent/turn-start`/`agent/turn-end` events. One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the one signal that always fires (`closeTurn` appends it unconditionally, even when a boundary emit throws and the `agent/turn-end` EVENT is skipped). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. +A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang. ## Disposal & disconnect diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 754b1750be..3fab77a150 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -199,13 +199,14 @@ interface SessionRecord { } /** - * Drive the in-flight prompt's settle from the harness event stream. A turn - * can end three ways the bridge must all handle (AGENTS.md "honor cross-seam - * contracts on BOTH sides"): the normal `agent/turn-end` event; a `turn/end` - * session event WITHOUT the agent event (a boundary emit threw inside the loop, - * which still appends `turn/end`); or the agent erroring/settling to idle. The - * first of these to fire settles the prompt; `settle` is then cleared so the - * others are no-ops (settle-exactly-once). + * Drive the in-flight prompt's settle from the harness event stream. The bridge + * settles off the durable log: the `turn/end` session event on the + * `session/event` feed for the prompt's own turn, with the agent + * erroring/settling to idle as a fallback (AGENTS.md "honor cross-seam contracts + * on BOTH sides") for the case where a throwing peer `session/event` listener + * starved the bridge's listener before it saw the boundary. The first of these + * to fire settles the prompt; `settle` is then cleared so the others are no-ops + * (settle-exactly-once). */ export function apply(ctx: Context, config: AcpConfig): void { // TODO(double-default): these literals duplicate the Config schema defaults @@ -318,15 +319,14 @@ export function apply(ctx: Context, config: AcpConfig): void { // the canonical log: every assistant/chunk and tool/call/result is logged, so // translating from the log makes live streaming and `session/load` replay // share the identical path (streamSessionEventUpdate). Both the owning-turn - // capture and the settle key off the log's own `turn/start`/`turn/end` — NOT - // the `agent/turn-start`/`agent/turn-end` EVENTS, which a throwing PEER - // listener (cordis `emit` stops at the first throw) or a boundary-emit failure - // can skip. `closeTurn` appends `turn/end` to the log unconditionally, and - // `turn/start` is appended before any step runs, so within this one listener - // we always see the prompt's turn-start (tag `inflight.turn`) then its - // turn-end (settle). A `turn/end` settles the prompt ONLY when it is the - // prompt's OWN turn (`inflight.turn === event.data.turn`) — a previous, - // already-cancelled turn whose end arrives late is ignored (see + // capture and the settle key off the log's own `turn/start`/`turn/end` — the + // durable boundary events (there is no agent/* turn mirror). `closeTurn` + // appends `turn/end` to the log unconditionally, and `turn/start` is appended + // before any step runs, so within this one listener we always see the + // prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A + // `turn/end` settles the prompt ONLY when it is the prompt's OWN turn + // (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn + // whose end arrives late is ignored (see // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP // has no error stop reason); other reasons resolve via the codec. Demux // strictly by session id: a `session/event` is routed to its own record, so From 9e575a2a2cc016c30aee0a9225c858269ea9e31a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:47:37 +0800 Subject: [PATCH 6/7] docs(events): fix stale turn-mirror references caught in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of the turn-mirror removal found current-state docs/comments that still claimed the removed `agent/turn-start`/`agent/turn-end` events exist: - docs/architecture.md: the loop diagram's turn-start line still said "emit agent/turn-start" (the turn-end line was already fixed). - event-domain-semantics RFC: the `agent/*` domain description listed "the turn boundaries" among the transient emits. - docs/core-data-structures/core.md: the agent/* taxonomy blurb listed "turn/step boundaries" as agent events. - the proposed ACP RFC: the settle-signal rows named agent/turn-start / agent/turn-end; retargeted to the durable `turn/end` session event + the session/event owning-turn correlation. - loop.ts outer-catch comment: said "closeTurn/failTurn are idempotent" — after the emit-param removal closeTurn is called exactly once (mutually exclusive normal/catch paths), so corrected to state that and to scope idempotency to closeStep (which is still guarded by stepOpen). Regenerated the cordis catalog. No behavior change. --- docs/architecture.md | 2 +- docs/core-data-structures/core.md | 2 +- .../2026-06-30-event-domain-semantics.md | 2 +- .../feature/2026-06-14-acp-agent-client-protocol.md | 4 ++-- packages/core/agent-loop/src/loop.ts | 13 ++++++++----- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8f83625ab0..4e856305ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,7 +134,7 @@ forever: wait for queued messages (idle) emit agent/status(running) TURN (error-contained — a throwing plugin ends the turn, never the loop): - drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start + drain queued → 'turn/start' → session('user/message'…) ⟵ durable turn boundary (no agent/* mirror) STEP loop: drain steering (late steering from previous step's listeners) assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 34cf410944..7466b063eb 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -306,7 +306,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle, turn/step boundaries, the serial `agent/pre-step` surface-mutation seam, and the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy). +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`, `systemPrompt?`) is merge-extensible — plugins add creation options by declaration merging. The `agent/*` event taxonomy (lifecycle emits, the serial `agent/pre-step` surface-mutation seam, and the `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. ## `ToolDefinition` diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index 5ca6f874b5..7ae254f8dc 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -19,7 +19,7 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab **Three domains, one job each, with a single boundary rule.** - **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and `session/load` replay share one path. -- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`, and the turn boundaries) that notify with the `Agent` in hand. +- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`. - **`tools/*` — the tool registry + execution seam.** **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index 675e295c2c..0aa5af4dc4 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -27,7 +27,7 @@ The mapping between ACP and existing harness seams — each row names the seam a | `session/new {cwd, mcpServers, additionalDirectories}` → `{sessionId}` | the `dsh-agent` create factory (see Dependency note + Plan) | the seam must accept `{ sessionId, meta }` so the ACP-generated `sessionId` becomes the live/persisted session id and the validated `cwd` is attached as the `SessionHeader` (today `AgentLoop.create(id)` hardcodes `${id}-session` and takes no metadata); reject a 2nd session (single-session MVP, see [ACP multi-session](2026-06-14-acp-multi-session.md)); `cwd` validated (require absolute) — any absolute cwd is honored: it becomes the session's `SessionHeader.cwd` and the default bash workdir (per-session cwd, see § Deferred → RESOLVED), so the server need not launch in the workspace; non-empty `mcpServers` and `additionalDirectories` are rejected for the MVP because silently ignoring requested servers/roots would desync the client's tool and filesystem-scope UI | | `session/load {sessionId, cwd, mcpServers, additionalDirectories}` | the `dsh-agent` resume factory ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) + Dependency note) | load `{ meta, events }`, seed the session, re-derive history via `deriveMessages()`, replay prior turns to the client as `session/update` per the ACP load contract; `mcpServers` and `additionalDirectories` rejected as in `session/new` | | `session/prompt {prompt}` | `agent.send()` (idle) | text blocks → `TextBlock`; reject image/audio per advertised capabilities; one in-flight prompt per session | -| resolve `session/prompt` → `{stopReason}` | `agent/turn-end` (extended, see Plan) | map the harness kebab `TurnEndReason` to the ACP snake_case `StopReason` wire enum: `completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`(cancel)→`cancelled`, plus `refusal`/`max_turn_requests` when applicable; honor the batch-into-one-turn and send-not-synchronously-running settle semantics | +| resolve `session/prompt` → `{stopReason}` | the `turn/end` `session/event` (its `reason`) | map the harness kebab `TurnEndReason` to the ACP snake_case `StopReason` wire enum: `completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`(cancel)→`cancelled`, plus `refusal`/`max_turn_requests` when applicable; honor the batch-into-one-turn and send-not-synchronously-running settle semantics | | `session/update: agent_message_chunk` | `agent/stream-chunk` `text-delta` only | do NOT also emit on `block-end(TextBlock)` — it carries the fully-assembled block and would duplicate the streamed text | | `session/update: agent_thought_chunk` | `agent/stream-chunk` `reasoning-delta` | | | `session/update: tool_call` (pending→in_progress) | `session/event` `tool/call` | demux via a Session→sessionId map; `kind` inferred from the tool name | @@ -46,7 +46,7 @@ Lifecycle and disposal: the connection, listeners, and in-flight permission prom 1. Package scaffold `packages/ui/acp/` per [the cookbook](../../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.) 2. Connection plus `initialize`/`session/new`: wire `AgentSideConnection` to stdin/stdout; protocolVersion negotiation; the single-session guard; create the live session through the new `{ sessionId, meta }` factory seam (so the ACP `sessionId` and validated `cwd` become the session's id and header); the `sessionId↔agent` and `Session↔sessionId` maps. 3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/core/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. -4. Prompt-turn streaming plus load: translate `agent/stream-chunk` and `session/event` into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install listeners before `send()`; gate on an observed `agent/turn-start` (confirms work was accepted) then resolve on the next `agent/turn-end`; reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam. +4. Prompt-turn streaming plus load: translate `agent/stream-chunk` and `session/event` into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install the `session/event` listener before `send()`; capture the prompt's owning turn from its `turn/start` record, then resolve on that turn's `turn/end` (with `agent/status` idle/disposed as a fallback); reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam. 5. Permission gate: a single `tools/execute` listener registered with `prepend: true`, owning a `WeakMap` of bridge-created agents; no-op (`next()`) for unowned/no-agent calls; for owned calls → `session/request_permission` → allow (`next()`) / veto; settle the stored resolver exactly once on outcome, cancel, or connection close. 6. Example wiring (extract a shared base). `@cordisjs/plugin-include` is itself a plugin entry that resets `ctx.baseUrl` and loads a path, so a child `cordis.yml` can nest-include a shared base; the extraction is safe because every dependent plugin declares `inject` (loader groups initialize via `Promise.all`, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (`llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash`) into `examples/base.yml`; have both `coding-agent` and a new `examples/acp-agent/` include it and add their own UI plugin plus logger. Keep `agent-loop` per-example (NOT in the base): `AgentLoop` creates its configured agents in its constructor, and the two examples disagree — `coding-agent` needs a pre-created `main` (its `stdio-chat` calls `ctx.agents.get('main')`), while `acp-agent` must pre-create none (ACP `session/new` creates agents). So `coding-agent` declares `agent-loop` with `agents: [{ id: main, … }]` and `acp-agent` with `agents: []`. `acp-agent` loads `dsh-session-persistence-jsonl` (from [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — required for `session/load`), omits the stdout logger (see Risks), and adds `pnpm run demo:acp` plus the Zed `agent_servers` snippet. 7. Tests (the repo cares a lot here): a property-based test for the protocol shape (precedent: [property-based testing](../../implemented/testing/2026-06-11-property-based-testing.md)) — fuzz arbitrary harness event sequences and assert ACP-stream invariants (never a `tool_call_update` before its `tool_call`; exactly one `session/prompt` resolution per prompt; monotonic, well-formed ordering; `stopReason` in the legal set); codec unit tests over an in-memory `Duplex` pair (drive `AgentSideConnection` without a subprocess; assert exact frames for `initialize`, `session/new`, a full prompt turn); the mandatory HMR-safety test (dispose the fiber; assert the connection closed, all `ctx.on` listeners gone, any in-flight `request_permission` settled); failure-path tests (connection closes mid-stream; closes with a permission pending; a notification `send()` rejects but the turn survives; `finish{kind:'error'|'aborted'}`; a `tools/execute` throw with no `tool/result`; a second `session/new` rejected; a `session/prompt` while one is in flight; an empty prompt rejected without hanging; a `session/load` re-derives identical history and replays it); and an e2e (`*.e2e.ts`, self-skips without `DEEPSEEK_API_KEY`) that boots `examples/acp-agent`, connects a `ClientSideConnection`, sends a real prompt, owns and disposes the harness in `afterEach`, and verifies the world (files on disk), not the agent's self-report. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index eba75f9615..3921122559 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -529,11 +529,14 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // 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. + // and the normal-exit `closeTurn()` did NOT run (we are here because a throw + // preceded it — the two `closeTurn()` sites are on mutually exclusive paths), + // so this catch appends turn/end with the disposed/error reason chosen below. + // `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run + // already in a step branch, so running it again is a safe no-op. Absent + // turn/start means the append threw BEFORE its push (a non-serializable + // trigger — impossible for our fixed trigger); nothing was opened, so rethrow + // to the runLoop backstop. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) if (!turnStartLogged) throw error closeStep() From 1e87b6fea4a498c33259bbf7d864c01fc55a7550 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:57:59 +0800 Subject: [PATCH 7/7] fix(ui-stdio): seed turn labels from the registry; drop stale taxonomy line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on the event-taxonomy PR: - ui-stdio built its session-id→agent-id label map only from live `agent/created` events, so an agent registered before the UI fiber installed — the pre-created `main` agent, or any agent surviving an HMR reload of just this fiber — was missed and its turns rendered the raw session id instead of `[main turn N]`. Seed the map from `ctx.agents.list()` at install, then keep it live. Regression test proven red without the seed. - The agent event-domain doc still listed "the turn boundaries" among the TRANSIENT `agent/*` emits, contradicting the rule ten lines below that a turn/step boundary is a durable `session/event`, not an `agent/*` mirror. --- docs/cordis-catalog/events-and-services.md | 22 +++++++++---------- packages/core/agent/src/types.ts | 7 +++--- packages/support/ui-stdio/README.md | 2 +- packages/support/ui-stdio/src/index.ts | 8 ++++++- .../support/ui-stdio/tests/readline.spec.ts | 3 +++ .../support/ui-stdio/tests/ui-stdio.spec.ts | 20 +++++++++++++++++ 6 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index e9a4447aec..6517b35035 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:164`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:170`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:263`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -75,7 +75,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:183`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -87,7 +87,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:232`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -99,7 +99,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -111,7 +111,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:257`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -123,7 +123,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:238`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -135,7 +135,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:253`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -147,7 +147,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 0dcc1d0501..82a065b854 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -19,9 +19,10 @@ * live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, * `agent/step-result`, `agent/turn-continuation`) that mutate/veto, and * TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, - * `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`, and the - * turn boundaries) that notify with the `Agent` in hand. Answers "right now, - * with the agent object — intercept or observe." + * `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`) that + * notify with the `Agent` in hand. Turn/step boundaries are NOT here — they + * are durable `session/event` records (see the rule below). Answers "right + * now, with the agent object — intercept or observe." * - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution. * * **The rule:** a durable, replayable fact is a SessionEvent; a live diff --git a/packages/support/ui-stdio/README.md b/packages/support/ui-stdio/README.md index 25f53833b7..7e88bbc459 100644 --- a/packages/support/ui-stdio/README.md +++ b/packages/support/ui-stdio/README.md @@ -25,7 +25,7 @@ This package consolidates what were two near-identical copies under `examples/ec Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.) - `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on. -- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[ turn N]` header (the short agent label comes from an `agent/created`→id map, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist. +- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[ turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist. ## The I/O seam diff --git a/packages/support/ui-stdio/src/index.ts b/packages/support/ui-stdio/src/index.ts index 5f3b2bdc1c..9be922426f 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/support/ui-stdio/src/index.ts @@ -80,8 +80,14 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // number, so to print the short agent id (`[main turn 1]`) we map the // session's id to its agent's id. The session id is not reliably the agent id // (a session can be created with an explicit/client-supplied id), so build the - // map from `agent/created` rather than parsing the id string. + // map from `agent/created` rather than parsing the id string. Seed from the + // registry's current agents first: an agent registered before this plugin + // installed (e.g. the pre-created `main` agent, or any agent surviving an HMR + // reload of just this fiber) already fired its `agent/created`, so the live + // listener alone would miss it and its turns would fall back to the raw + // session id. const labelBySession = new Map() + for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) diff --git a/packages/support/ui-stdio/tests/readline.spec.ts b/packages/support/ui-stdio/tests/readline.spec.ts index c8b147ddab..5e092fb913 100644 --- a/packages/support/ui-stdio/tests/readline.spec.ts +++ b/packages/support/ui-stdio/tests/readline.spec.ts @@ -16,6 +16,9 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), + // The UI seeds its label map from the registry at install; this suite only + // exercises readline terminal-mode selection, so an empty roster suffices. + agents: { list: vi.fn(() => []) }, } as unknown as Context } diff --git a/packages/support/ui-stdio/tests/ui-stdio.spec.ts b/packages/support/ui-stdio/tests/ui-stdio.spec.ts index 0c58211d82..bf75c528c3 100644 --- a/packages/support/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/support/ui-stdio/tests/ui-stdio.spec.ts @@ -149,6 +149,26 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[orphan-session turn 1] ') }) + it('seeds labels for agents already registered before the UI installs', async () => { + // The pre-created `main` agent (and any agent surviving an HMR reload of just + // this fiber) fired its `agent/created` before the UI's listener existed, so + // the live listener alone would miss it. Seeding from `ctx.agents.list()` at + // install time is what keeps its turn header showing `[main turn N]` instead + // of the raw session id. + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const agent = makeAgent('main') + ctx.agents.register(agent) // registered BEFORE the UI plugin below + const { runtime, out } = makeRuntime() + await ctx.plugin(Object.assign((inner: Context) => { + createStdioChat(inner, CONFIG, runtime) + }, { inject: ['agents'] })) + ctx.emit('session/event', makeSession('main'), { + type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main turn 5] ') + }) + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const agent = makeAgent('main')