fix(agent): preserve thrown error values
This commit is contained in:
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user