fix(agent-loop): harden lifecycle edge cases

This commit is contained in:
Tianyi Cui
2026-06-17 21:25:47 +08:00
parent f860474f8b
commit 6fdd048123
5 files changed
+167 -21

No files matched your search

+12 -8
View File
@@ -63,7 +63,11 @@ export class LoopAgent implements Agent {
// waiter (AGENTS.md "contain callback exceptions" — a lifecycle await must
// not hang on one bad listener).
if (status !== 'running') this.settleIdleWaiters()
this.ctx.emit('agent/status', this, status)
try {
this.ctx.emit('agent/status', this, status)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
}
}
/**
@@ -177,16 +181,16 @@ export class LoopAgent implements Agent {
* `running`. If it is already disposed, awaits {@link done} (the loop-exit
* promise) — `agent/status('disposed')` fires in the disposer BEFORE the
* driver loop has unwound, so it is NOT itself a quiescence signal. If it is
* idle, resolves immediately. Otherwise queues an internal waiter (see
* {@link idleWaiters}) released on the next running→idle/disposed transition,
* resolving on `idle` directly (the turn fully ended) or chaining {@link done}
* on `disposed` (wait for the loop to actually exit). Implements the
* {@link Agent.whenIdle} contract used by teardown (`abort()` then
* `await whenIdle()`).
* idle AND has no queued work, resolves immediately. Otherwise queues an
* internal waiter (see {@link idleWaiters}) released on the next
* running→idle/disposed transition, resolving on `idle` directly (the turn
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
* actually exit). Implements the {@link Agent.whenIdle} contract used by
* teardown (`abort()` then `await whenIdle()`).
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running') return Promise.resolve()
if (this._status !== 'running' && !this.inbox.hasQueued) return Promise.resolve()
// Register an internal waiter (resolved by settleIdleWaiters on the next
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
// a concurrent fiber disposal runs this agent's listener disposers, which
+19 -7
View File
@@ -203,8 +203,8 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// 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 (ADR 0003 append-before-emit).
const closeStep = (): void => {
if (!stepOpen) return
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
@@ -227,6 +227,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// itself succeeded, AND keeps finalization going when closeStep runs from
// the outer catch.
if (failure !== undefined) failTurn(toError(failure))
return failure !== undefined
}
// Record a step/turn failure exactly once: append the single `error` event
@@ -358,7 +359,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// Steering that arrived during streaming/tool execution.
const steered = drainSteering(ctx, agent, turn)
closeStep()
if (closeStep()) break
const defaultDecision = stepOutcome.hadToolCalls || steered
let shouldContinue: boolean
@@ -502,16 +503,27 @@ async function runStep(
// tool dispatch actually uses.
let message: Message = assembler.message()
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
const finish = assembler.finish
const messageForLog: Message = finish.kind === 'max-tokens'
? { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
: message
session.append('assistant/message', { turn, step, content: message.content })
if (finish.kind !== 'max-tokens' || messageForLog.content.length > 0) {
session.append('assistant/message', { turn, step, content: messageForLog.content })
}
if (assembler.usage) {
session.append('usage', { turn, step, usage: assembler.usage })
}
// --- Tool execution (sequential; parallel execution is a TODO) ---
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// isError results, so abort is re-checked around every call here. A
// max-tokens step is cut off: any tool-call block in it may be partial, so it
// is neither dispatched nor recorded in the derived-history assistant message
// above. Raw assistant/chunk events still preserve the exact stream.
const toolCalls = finish.kind === 'max-tokens'
? []
: message.content.filter(block => block.type === 'tool-call')
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
@@ -552,7 +564,7 @@ async function runStep(
/* v8 ignore stop */
}
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
return { hadToolCalls: toolCalls.length > 0, finish }
}
/** The last turn number in a (possibly seeded) session log, or 0. */