fix(agent-loop): report turn failures at source

This commit is contained in:
_Kerman
2026-07-31 13:54:36 +08:00
parent 7a463dbe44
commit 741fc7dc79
12 changed files with 125 additions and 44 deletions

View File

@@ -41,6 +41,8 @@ type Admission =
| { kind: 'admitted'; messages: UserMessage[] }
| { kind: 'blocked' }
type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
/** Remove adapter-derived values before plugins propose the next request config. */
function requestProposal(header: EpochHeader): LlmCallConfig {
if (header.adapterDefaults === undefined) return header.config
@@ -136,15 +138,19 @@ export class ReactLoopAgent implements Agent {
} while (driver !== this.driverDone)
}
/** Report one failure at its live boundary, then preserve it for driver containment. */
private throwError(error: unknown): never {
const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn
const step = this.phase.kind === 'running' ? this.phase.step : 0
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
throw error
}
private async kick(): Promise<void> {
try {
while (await this.turn()) {}
} catch (error: unknown) {
if (this.phase.kind !== 'idle') {
const turn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
this.setPhase({ kind: 'idle', lastTurn: turn })
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, 0, error)
}
} catch (_error) {
// Admission and turn boundaries report before rethrowing; the driver only contains the rejection.
} finally {
if (this.phase.kind === 'running') {
this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
@@ -176,7 +182,9 @@ export class ReactLoopAgent implements Agent {
/** Admitted input stays unowned until `turn/start` commits. */
private async turn(): Promise<boolean> {
if (this.phase.kind === 'idle') throw new Error(`agent "${this.id}": turn without driver reservation`)
if (this.phase.kind === 'idle') {
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
}
const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController()
const { signal } = abort
const lastTurn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
@@ -191,10 +199,14 @@ export class ReactLoopAgent implements Agent {
} catch (error: unknown) {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- cancel may abort while admission awaits
if (signal.aborted) return this.inbox.hasPending
throw error
this.throwError(error)
}
const turn = ++phase.turn
this.session.append('turn/start', { turn })
try {
this.session.append('turn/start', { turn })
} catch (error: unknown) {
this.throwError(error)
}
let turnEnds: TurnEndReason | null = null
try {
while (true) {
@@ -218,7 +230,7 @@ export class ReactLoopAgent implements Agent {
}
admission = await this.admit(false)
if (admission.kind === 'blocked') {
turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
turnEnds = { kind: 'blocked' }
return false
}
signal.throwIfAborted()
@@ -226,16 +238,27 @@ export class ReactLoopAgent implements Agent {
}
} catch (error: unknown) {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- cancel may abort during any awaited turn operation
if (signal.aborted) turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
else turnEnds = { kind: 'error', error: errorChain(error) }
if (signal.aborted) {
turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
} else {
turnEnds = {
kind: 'error',
error: error instanceof LlmError ? error.failure : errorChain(error),
}
this.throwError(error)
}
} finally {
// oxlint-disable-next-line typescript/no-non-null-assertion -- the turn is always ended in this block
this.session.append('turn/end', { turn, reason: turnEnds! })
try {
// oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
this.session.append('turn/end', { turn, reason: turnEnds! })
} catch (error: unknown) {
this.throwError(error)
}
}
return this.inbox.hasPending
}
private async step(): Promise<TurnEndReason | null> {
private async step(): Promise<StepEndReason | null> {
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
const { turn, step, abort: { signal } } = this.phase
signal.throwIfAborted()
@@ -272,7 +295,9 @@ export class ReactLoopAgent implements Agent {
() => Promise.resolve<RequestErrorAction>(undefined),
)
signal.throwIfAborted()
if (action?.kind !== 'retry') return { kind: 'error', error: finish.failure }
if (action?.kind !== 'retry') {
throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
}
continue
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, freezeMessage, CallId, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, freezeMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
@@ -594,12 +594,20 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
const errors: unknown[] = []
ctx.on('agent/error', (_agent, turn, step, error) => {
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
errors.push(error)
})
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', error: failure }])
expect(errors).toHaveLength(1)
expect(errors[0]).toBeInstanceOf(LlmError)
expect((errors[0] as LlmError).failure).toEqual(failure)
const events = [...agent.session.events]
const turnEnd = events.find(event => event.type === 'turn/end')
@@ -809,6 +817,7 @@ describe('turn and step boundary recovery', () => {
expect(adapter.requests).toHaveLength(1)
expect(errors.map(error => error.message)).toEqual([
'reject first step-end',
'invariant violated by "@deepseek-ai/dsh-session": turn/end 1 while step 1 is still open',
])
expect(boundaryCounts(agent)).toMatchObject({
@@ -842,6 +851,7 @@ describe('turn and step boundary recovery', () => {
kind: 'error',
error: { message: 'provider 500', code: 'SERVER' },
})
expect(threw).toBe(true)
// loop survives: a second turn runs to completion (invariants oracle would
// throw on its turn/start if turn 1 had been left open).
@@ -1012,7 +1022,9 @@ describe('turn and step boundary recovery', () => {
expect(e.some(x => x.type === 'step/end')).toBe(true)
expect(e.some(x => x.type === 'turn/end')).toBe(true)
expect(e.at(-1)?.type).toBe('turn/end')
expect(errors).toEqual([])
expect(errors).toHaveLength(1)
expect(errors[0]).toBeInstanceOf(LlmError)
expect((errors[0] as LlmError).failure).toEqual({ message: 'provider 500', code: 'SERVER' })
// loop survives.
send(agent, 'again')

View File

@@ -181,7 +181,10 @@ describe('durable error rendering', () => {
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd).toBeDefined()
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
expect(turnEnd.data.reason.error).toBe('server overloaded')
expect(turnEnd.data.reason.error).toEqual({
message: 'server overloaded',
code: 'RATE_LIMIT',
})
}
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -200,7 +200,9 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0) // the request was never sent
expect(errors).toEqual([])
expect(errors.map(error => error.message)).toEqual([
'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona")',
])
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
@@ -346,7 +348,7 @@ describe('agent loop', () => {
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('second idle steer')
})
it('contains a throwing step observer and carries steering into a replacement turn', async () => {
it('stops after a throwing step observer and retains steering until a later wakeup', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
@@ -361,6 +363,13 @@ describe('agent loop', () => {
send(agent, 'prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.inbox.nextStep).toHaveLength(1)
send(agent, 'resume')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
@@ -658,7 +667,7 @@ describe('agent loop', () => {
send(agent, 'first')
await waitForIdle(ctx, agent)
// The first turn failed at step 1 before a model call.
expect(errors).toEqual([])
expect(errors.map(error => error.message)).toEqual(['boom in pre-step'])
expect(adapter.requests.length).toBe(0)
const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error' })
@@ -1085,20 +1094,24 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
const errors: unknown[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
errors.push(error)
})
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(errors).toEqual([])
expect(errors).toHaveLength(1)
expect(errors[0]).toBeInstanceOf(LlmError)
expect((errors[0] as LlmError).failure).toEqual({
message: 'MockAdapter: script exhausted',
code: 'UNKNOWN',
})
expect(reasons[0]).toMatchObject({ kind: 'error' })
// The durable failure lives entirely on turn/end.reason (with the failing
// step), not a standalone error event.
// The durable failure and live relay describe the same failed turn.
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' })
})

View File

@@ -359,7 +359,12 @@ describe('request stability across the loop', () => {
await waitForIdle(ctx, agent)
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: failure.message } },
data: {
reason: {
kind: 'error',
error: failure instanceof LlmError ? failure.failure : failure.message,
},
},
})
expect(adapter.requests).toHaveLength(0)
},

View File

@@ -100,6 +100,8 @@ export interface TurnEndReasonMap {
completed: { kind: 'completed' }
/** A cancellation request interrupted the live turn. */
aborted: { kind: 'aborted'; reason: AgentCancelCause }
blocked: { kind: 'blocked' }
/**
* The turn failed.
*/