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

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