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