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:
Tianyi Cui
2026-06-30 10:32:55 +08:00
parent 3f85f522ea
commit 05b75abbca
9 changed files with 176 additions and 151 deletions

View File

@@ -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 |

View File

@@ -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/*`

View File

@@ -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

View File

@@ -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.

View File

@@ -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() }

View File

@@ -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))

View File

@@ -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')

View File

@@ -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') }

View File

@@ -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) ----
/**