refactor(events): document event-domain semantics, drop step-boundary mirror emits
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.
This commit is contained in:
@@ -133,7 +133,7 @@ forever:
|
|||||||
drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
|
drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
|
||||||
STEP loop:
|
STEP loop:
|
||||||
drain steering (late steering from previous step's listeners)
|
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
|
assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
||||||
req = {model, system, tools, messages: session.deriveMessages(), signal}
|
req = {model, system, tools, messages: session.deriveMessages(), signal}
|
||||||
req = waterfall agent/request ⟵ hooks, compaction, model switch
|
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`
|
tool execution may append tool-owned session events, e.g. `todo/write`
|
||||||
session('tool/result')
|
session('tool/result')
|
||||||
drain steering → session('steering/message'); emit agent/steering
|
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)
|
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
|
if !cont: break
|
||||||
session('turn/end'); emit agent/turn-end
|
session('turn/end'); emit agent/turn-end
|
||||||
await ctx.parallel('session/flush', session) ⟵ durability checkpoint (failure
|
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 |
|
| 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 |
|
| `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders |
|
||||||
| `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue |
|
| `/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()` |
|
| 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) |
|
| 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 |
|
| System prompt configurability | `ctx.systemPrompt.section()` with ordering |
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages.
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
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
|
#### `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)
|
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
|
#### `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)
|
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
|
#### `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)
|
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
|
#### `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)
|
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
|
#### `agent/status` — emit
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
|
|||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
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
|
#### `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)
|
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)
|
Source: [`packages/core/agent/src/types.ts:238`](../../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)
|
|
||||||
|
|
||||||
#### `agent/step-result` — waterfall
|
#### `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)
|
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)
|
Source: [`packages/core/agent/src/types.ts:219`](../../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)
|
|
||||||
|
|
||||||
#### `agent/stream-chunk` — emit
|
#### `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)
|
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
|
#### `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)
|
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
|
#### `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
|
```ts cordis-catalog
|
||||||
'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void
|
'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)
|
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
|
#### `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)
|
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/*`
|
### `llm/*`
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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
|
### Process
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -147,7 +147,7 @@ export interface LoopHandle {
|
|||||||
* drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
|
* drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
|
||||||
* STEP loop:
|
* STEP loop:
|
||||||
* drain steering → session('steering/message') ⟵ catches late steering
|
* 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
|
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
||||||
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
||||||
* req = waterfall agent/request ⟵ hooks/compaction/model-switch
|
* 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/call'); ctx.tools.execute() ⟵ waterfall tools/execute
|
||||||
* session('tool/result')
|
* session('tool/result')
|
||||||
* drain steering → session('steering/message'); emit agent/steering
|
* 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)
|
* 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/continuation listeners: cont = true
|
||||||
* if !cont: break
|
* if !cont: break
|
||||||
@@ -279,33 +279,29 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
|||||||
let stepOpen = false
|
let stepOpen = false
|
||||||
let errorReported = false
|
let errorReported = false
|
||||||
|
|
||||||
// Close the open step exactly once (idempotent via stepOpen). The
|
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
||||||
// agent/step-end emit is contained: a throwing step-end listener must not
|
// are durable session events only — there is no agent/* step emit to mirror
|
||||||
// abort finalization and strand the turn open (turn/end balance > notifying
|
// them (see the agent event-domain rule). A throwing step/end session-event
|
||||||
// one bad listener). Appended before the emit (the event-sourcing RFC append-before-emit).
|
// 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 => {
|
const closeStep = (): boolean => {
|
||||||
if (!stepOpen) return false
|
if (!stepOpen) return false
|
||||||
stepOpen = false
|
stepOpen = false
|
||||||
// Session.append pushes step/end BEFORE notifying session/event listeners,
|
// Session.append pushes step/end BEFORE notifying session/event listeners,
|
||||||
// so a throwing listener leaves step/end in the log (balance holds) but
|
// 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
|
// 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
|
let failure: unknown
|
||||||
try {
|
try {
|
||||||
session.append('step/end', { turn, step })
|
session.append('step/end', { turn, step })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
failure = error
|
failure = error
|
||||||
}
|
}
|
||||||
try {
|
// A throwing step/end session-event listener surfaces as a turn error via
|
||||||
ctx.emit('agent/step-end', agent, turn, step)
|
// failTurn (idempotent). This prevents a throwing listener from producing a
|
||||||
} catch (error: unknown) {
|
// silent "completed" turn when the step itself succeeded, AND keeps
|
||||||
failure ??= error
|
// finalization going when closeStep runs from the outer catch.
|
||||||
}
|
|
||||||
// A throwing step/end session-event listener OR a throwing agent/step-end
|
|
||||||
// listener surfaces as a turn error via failTurn (idempotent). This prevents
|
|
||||||
// a throwing listener from producing a silent "completed" turn when the step
|
|
||||||
// itself succeeded, AND keeps finalization going when closeStep runs from
|
|
||||||
// the outer catch.
|
|
||||||
if (failure !== undefined) {
|
if (failure !== undefined) {
|
||||||
failTurn(toError(failure))
|
failTurn(toError(failure))
|
||||||
return true
|
return true
|
||||||
@@ -382,24 +378,23 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
|||||||
while (true) {
|
while (true) {
|
||||||
step += 1
|
step += 1
|
||||||
|
|
||||||
// Steering from the previous round's step-end/continuation listeners
|
// Steering from the previous round's continuation listeners (or
|
||||||
// (or turn-start listeners on the first step) joins before the request.
|
// turn-start listeners on the first step) joins before the request.
|
||||||
drainSteering(ctx, agent, turn)
|
drainSteering(ctx, agent, turn)
|
||||||
|
|
||||||
session.append('step/start', { turn, step })
|
session.append('step/start', { turn, step })
|
||||||
stepOpen = true
|
stepOpen = true
|
||||||
ctx.emit('agent/step-start', agent, turn, step)
|
|
||||||
|
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
handle.setAbort(abort)
|
handle.setAbort(abort)
|
||||||
|
|
||||||
// Cancel landing in the step-start window: a synchronous `agent/turn-start`
|
// Cancel landing in the step-start window: a synchronous `agent/turn-start`
|
||||||
// or `agent/step-start` listener (both fire before this point) can have
|
// listener (fires before this point) can have called `cancel()`, and
|
||||||
// called `cancel()`, and `runStep` would otherwise run a full extra step
|
// `runStep` would otherwise run a full extra step with no AbortController
|
||||||
// with no AbortController having observed it. Check the marker AFTER
|
// having observed it. Check the marker AFTER setAbort (so the
|
||||||
// setAbort (so the next-iteration drain sees a clean controller) and before
|
// next-iteration drain sees a clean controller) and before `runStep`: drop
|
||||||
// `runStep`: drop the step, end the turn `aborted`. closeStep balances the
|
// the step, end the turn `aborted`. closeStep balances the already-appended
|
||||||
// already-appended step/start.
|
// step/start.
|
||||||
if (handle.isCancelled()) {
|
if (handle.isCancelled()) {
|
||||||
handle.setAbort(undefined)
|
handle.setAbort(undefined)
|
||||||
reason = { kind: 'aborted', reason: handle.cancelReason() }
|
reason = { kind: 'aborted', reason: handle.cancelReason() }
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ describe('Agent.cancel()', () => {
|
|||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
let steps = 0
|
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[] = []
|
const reasons: TurnEndReason[] = []
|
||||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||||
|
|
||||||
|
|||||||
@@ -46,15 +46,21 @@ describe('agent loop', () => {
|
|||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
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[] = []
|
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(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')
|
send(agent, 'hi')
|
||||||
await waitForIdle(ctx, agent)
|
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)
|
const types = agent.session.events.map(e => e.type)
|
||||||
// turn/start opens the turn, THEN the queued user message is recorded inside
|
// 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' })
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
let steps = 0
|
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) => {
|
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
||||||
if (steps < 3) return true
|
if (steps < 3) return true
|
||||||
return next()
|
return next()
|
||||||
@@ -371,7 +377,7 @@ describe('agent loop', () => {
|
|||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
|
|
||||||
let steps = 0
|
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
|
// Force exactly one continuation (step 1 → step 2), then defer to default
|
||||||
// (step 2 is a plain stop with no tool calls → stops).
|
// (step 2 is a plain stop with no tool calls → stops).
|
||||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
|
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([
|
const adapter = new MockAdapter([
|
||||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||||
textResponse('should not run'),
|
textResponse('should not run'),
|
||||||
@@ -552,8 +558,11 @@ describe('agent loop', () => {
|
|||||||
}))
|
}))
|
||||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||||
let threw = false
|
let threw = false
|
||||||
ctx.on('agent/step-end', () => {
|
// A throwing step/end session-event listener is the surviving boundary-listener
|
||||||
if (!threw) { threw = true; throw new Error('bad step-end 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')
|
send(agent, 'go')
|
||||||
|
|||||||
@@ -144,36 +144,6 @@ describe('HIGH: abort during tool execution ends the turn', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('HIGH: steering from late extension points is never stranded', () => {
|
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 () => {
|
it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
|
||||||
const adapter = new MockAdapter([
|
const adapter = new MockAdapter([
|
||||||
textResponse('no tools, would stop here'),
|
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', () => {
|
describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
|
||||||
it('a step-start listener sees the step/start event already in session.events', async () => {
|
it('the step/start event is in session.events when its session/event listener fires', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('done')])
|
const adapter = new MockAdapter([textResponse('done')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
|
||||||
|
|
||||||
// Capture, at the moment agent/step-start fires, whether the matching
|
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||||
// step/start event is already in the log (append-before-emit, the event-sourcing RFC).
|
// 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 }[] = []
|
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
|
||||||
ctx.on('agent/step-start', (subject, turn, step) => {
|
ctx.on('session/event', (subject, event) => {
|
||||||
if (subject !== agent) return
|
if (subject !== agent.session || event.type !== 'step/start') return
|
||||||
const events = [...subject.session.events]
|
const events = [...subject.events]
|
||||||
const last = events.at(-1)
|
const last = events.at(-1)
|
||||||
observed.push({
|
observed.push({
|
||||||
turn,
|
turn: event.data.turn,
|
||||||
step,
|
step: event.data.step,
|
||||||
lastEventType: last?.type,
|
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)
|
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 adapter = new MockAdapter([textResponse('never reached')])
|
||||||
const ctx = await balancedHarness(adapter)
|
const ctx = await balancedHarness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
|
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
|
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[] = []
|
const errors: Error[] = []
|
||||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||||
|
|
||||||
send(agent, 'go')
|
send(agent, 'go')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
const e = [...agent.session.events]
|
|
||||||
const c = boundaryCounts(agent)
|
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'])
|
expect(errors.map(x => x.message)).toEqual(['boom step-start'])
|
||||||
// step/end must precede turn/end (the invariants oracle would reject
|
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason.kind).toBe('error')
|
||||||
// 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 () => {
|
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)
|
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 () => {
|
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
|
// closeStep() must surface a throwing step/end listener via failTurn so the
|
||||||
// turn ends with reason error, not a silent "completed" with the throw
|
// turn ends with reason error, not a silent "completed" with the throw
|
||||||
// swallowed. Regression test for the closeStep() catch that previously
|
// 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 adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
||||||
const ctx = await balancedHarness(adapter)
|
const ctx = await balancedHarness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
|
||||||
|
|
||||||
let threw = false
|
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[] = []
|
const errors: Error[] = []
|
||||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(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 () => {
|
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
|
// A finish-error stream opens a step then fails it, driving finalization
|
||||||
// closeStep() during finalization. closeStep appends step/end; a
|
// through closeStep() with the step open. closeStep appends step/end; a
|
||||||
// session/event listener throwing on THAT must not abort the catch before
|
// session/event listener throwing on THAT must not abort the catch before
|
||||||
// closeTurn(false) — step/end is already logged (balance holds) and the
|
// closeTurn — step/end is already logged (balance holds) and the throw is
|
||||||
// throw is contained + surfaced via failTurn, so turn/end is still appended.
|
// contained + surfaced via failTurn, so turn/end is still appended. (The
|
||||||
const adapter = new MockAdapter([textResponse('never reached')])
|
// 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 ctx = await harness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' })
|
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
|
let threw = false
|
||||||
ctx.on('session/event', (_s, event) => {
|
ctx.on('session/event', (_s, event) => {
|
||||||
if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
|
if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
|
||||||
|
|||||||
@@ -6,6 +6,34 @@
|
|||||||
* Merge-extensible: `AgentOptions` supports declaration merging for
|
* Merge-extensible: `AgentOptions` supports declaration merging for
|
||||||
* plugin-specific creation options.
|
* 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
|
* @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
|
'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.
|
* A turn began. `turn` is the 1-based turn number within the session.
|
||||||
* @mode emit
|
* @mode emit
|
||||||
*/
|
*/
|
||||||
'agent/turn-start'(agent: Agent, turn: number): void
|
'agent/turn-start'(agent: Agent, turn: number): void
|
||||||
/**
|
/**
|
||||||
* A turn ended. `reason` distinguishes a clean stop from a truncated or
|
* A turn ended. `reason` distinguishes a clean stop from a truncated,
|
||||||
* aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`).
|
* aborted, or hook-rejected one (`completed` | `aborted` | `error` |
|
||||||
|
* `disposed` | `max-tokens` | `rejected` | `interrupted`).
|
||||||
* @mode emit
|
* @mode emit
|
||||||
*/
|
*/
|
||||||
'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void
|
'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) ----
|
// ---- interception seams (waterfall) ----
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user