fix(agent): preserve thrown error values

This commit is contained in:
_Kerman
2026-07-24 17:11:58 +08:00
parent 90e69a3123
commit 76d0e450ce
14 changed files with 55 additions and 61 deletions

View File

@@ -22,10 +22,10 @@ import type {
SendOptions,
} from '@deepseek-ai/dsh-agent'
import {
BlockAssembler, HarnessError, LlmError, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest,
BlockAssembler, LlmError, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type {
ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message, MessageSource,
ContentBlock, GenerateOptions, LlmCallConfig, Message, MessageSource,
} from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, SessionId, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
@@ -47,16 +47,6 @@ interface OutboxItem extends PromptMessageData {
steering?: PendingMessage
}
/** Normalize thrown values while preserving an existing error code. */
function toError(error: unknown): Error & { code?: string } {
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
}
/** Rebuild the live {@link LlmError} for serializable provider facts; `cause` keeps the foreign original. */
function llmError(facts: LlmFailure, cause?: Error): LlmError {
return new LlmError(facts.message, facts.code, { ...facts, cause })
}
function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
}
@@ -216,8 +206,7 @@ export class ReactLoopAgent extends Agent {
}
} catch (error: unknown) {
if (agentInterruptReasonOf(signal) === undefined) {
const failure = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(failure)}`)
this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`)
}
}
@@ -276,9 +265,8 @@ export class ReactLoopAgent extends Agent {
this.session.append('turn/end', { turn, reason })
}
} catch (error: unknown) {
const err = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
}
if (this.abort === controller) this.abort = undefined
emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle)
@@ -335,14 +323,18 @@ export class ReactLoopAgent extends Agent {
// Normalize a final-adapter failure into the one model-error type; the
// foreign original stays on `cause` for the rendered chain.
const facts = llmFailureOf(stream, error)
if (facts !== undefined && error instanceof Error) throw llmError(facts, error)
if (facts !== undefined && error instanceof Error) {
throw new LlmError(facts.message, facts.code, { ...facts, cause: error })
}
throw error
}
signal.throwIfAborted()
// Failure finish chunks take the same path as thrown stream errors.
const finish = assembler.finish
if (finish.kind === 'error' || finish.kind === 'aborted') throw llmError(finish.failure)
if (finish.kind === 'error' || finish.kind === 'aborted') {
throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
}
// Truncated (max-tokens) output cannot owe tool calls.
const assembled = assembler.finish.kind === 'max-tokens'
@@ -478,11 +470,10 @@ export class ReactLoopAgent extends Agent {
idle: { kind: 'error', error, failure: error.failure },
}
}
const err = toError(error)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
return {
reason: { kind: 'error', step, message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} },
idle: { kind: 'error', error: err },
reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} },
idle: { kind: 'error', error },
}
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, LlmError, StreamChunk, errorChain } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -131,8 +131,8 @@ describe('tool JSON parse', () => {
})
})
describe('toError normalization', () => {
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
describe('thrown-value propagation', () => {
it('preserves non-Error throws from pre-commit dispatch validation', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -143,57 +143,56 @@ describe('toError normalization', () => {
const event = args[1] as SessionEvent
if (event.type === 'turn/start' && !threwOnce) {
threwOnce = true
throw 'naked string error' // non-Error throw, normalized via toError
throw 'naked string error'
}
})
const errors: Error[] = []
const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'fails before turn start')
send(agent, 'survives as the next item')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
expect(errors[0]).toBe('naked string error')
expect(adapter.requests).toHaveLength(1)
const starts = agent.session.events.filter(event => event.type === 'turn/start')
const ends = agent.session.events.filter(event => event.type === 'turn/end')
const messages = agent.session.events.filter(event => event.type === 'user/message')
expect(starts).toHaveLength(1)
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(2)
expect(ends).toHaveLength(1)
expect(messages).toHaveLength(1)
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
expect(messages).toHaveLength(2)
expect(messages[1]?.type === 'user/message' && messages[1].data.content).toEqual([
{ type: 'text', text: 'survives as the next item' },
])
})
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
it('preserves non-Error throws from the agent/request waterfall', async () => {
const adapter = new MockAdapter([textResponse('irrelevant')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
if (!threwOnce) {
threwOnce = true
throw { code: 500 } // non-Error throw, goes through runStep catch
throw { code: 500 }
}
return _next()
return next()
})
const errors: Error[] = []
const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
// String() of { code: 500 } is '[object Object]'
expect(errors[0]!.message).toBe('[object Object]')
expect(errors[0]).toEqual({ code: 500 })
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
&& ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code))
.toBe('UNKNOWN')
.toBeUndefined()
})
})
@@ -204,7 +203,7 @@ describe('coded error data emission', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
if (!threwOnce) {
threwOnce = true
throw new LlmError('server overloaded', 'RATE_LIMIT')
@@ -212,13 +211,13 @@ describe('coded error data emission', () => {
return next()
})
const errors: Error[] = []
const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toBe('server overloaded')
expect(errorChain(errors[0])).toBe('server overloaded')
// turn-end error reason includes the code
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')

View File

@@ -128,14 +128,14 @@ export type PromptDecision =
/**
* Why a turn ended, reported live on `agent/idle` right after the turn's
* durable `turn/end` and flush. `error` carries the live Error (and, for
* durable `turn/end` and flush. `error` carries the thrown value verbatim (and, for
* model-request failures, the adapter-normalized facts) so a recovery
* consumer can decide to repair and {@link Agent.retry}.
*/
export type IdleReason =
| { kind: 'completed' }
| { kind: 'aborted' }
| { kind: 'error'; error: Error; failure?: LlmFailure }
| { kind: 'error'; error: unknown; failure?: LlmFailure }
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
@@ -426,6 +426,6 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void
}
}

View File

@@ -111,7 +111,8 @@ export interface TurnEndReasonMap {
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). Final model-request failures retain their normalized facts
* as one `failure`; other turn failures retain their live Error message/code.
* as one `failure`; other thrown values retain their rendered message and a
* real `HarnessError` code when present.
*/
error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never }