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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-20-error-cause-chain-diagnostics.md: 391e35997bb1bb050dd2ca620920961d77bb1c46 2026-07-20-error-cause-chain-diagnostics.md: 700c36c45ae8c939b6d9161ab90ab5dfe2c92686
2026-07-20-error-cause-chain-diagnostics.zh.md: 90d6559a9410e8a4e5475db9560a2a177ba7a1a7 2026-07-20-error-cause-chain-diagnostics.zh.md: b358ec606b7c92641f3e6138fe23d832431bd5ce

View File

@@ -15,7 +15,7 @@ A TUI run against an unreachable DeepSeek endpoint failed with the single notice
- `dsh-llm` exports `errorChain(value)`: renders a thrown value with its full `cause` chain (`outer: inner: …`) and AggregateError members (`msg [m1; m2]`), with circular-cause and hostile-coercion containment. It is a diagnostic-surface renderer only; routing stays on `HarnessError.code`. - `dsh-llm` exports `errorChain(value)`: renders a thrown value with its full `cause` chain (`outer: inner: …`) and AggregateError members (`msg [m1; m2]`), with circular-cause and hostile-coercion containment. It is a diagnostic-surface renderer only; routing stays on `HarnessError.code`.
- The DeepSeek adapter wraps a pre-response transport failure in `LlmError('TRANSPORT')` naming the configured `baseURL` and chaining the original rejection as `cause`. An aborted request becomes `LlmError('ABORTED')`; because the turn signal is already aborted, the loop still classifies the turn as cancellation rather than recovery. - The DeepSeek adapter wraps a pre-response transport failure in `LlmError('TRANSPORT')` naming the configured `baseURL` and chaining the original rejection as `cause`. An aborted request becomes `LlmError('ABORTED')`; because the turn signal is already aborted, the loop still classifies the turn as cancellation rather than recovery.
- Every diagnostic seam renders through `errorChain` instead of `error.message`/`String(error)`: the agent-loop's durable `turn/end` error message (`errorData`), its logger warnings, the TUI's `agent/error` notice and startup-failure line, and `dsh-stdio`'s startup-failure log lines. The per-package `renderThrown` copies in `dsh-agent-loop`, `dsh-stdio`, and `dsh-tui` are deleted in favor of the one shared renderer. - Every diagnostic seam renders through `errorChain` instead of `error.message`/`String(error)`: the agent-loop's durable `turn/end` error message (`errorData`), its logger warnings, the TUI's `agent/error` notice and startup-failure line, and `dsh-stdio`'s startup-failure log lines. The live `agent/error` event and `IdleReason` preserve the thrown value as `unknown`; each diagnostic consumer renders it instead of the loop wrapping it into another error. The per-package `renderThrown` copies in `dsh-agent-loop`, `dsh-stdio`, and `dsh-tui` are deleted in favor of the one shared renderer.
- `dsh-stdio` renders failure `turn/end` reasons: `[turn failed <code>] <message>`, `[turn aborted] <reason>`, `[turn rejected] <reason>`, `[turn interrupted by a previous process exit]`, and the output-token-limit notice. Unknown merge-extended kinds fall through as ordinary turn ends. - `dsh-stdio` renders failure `turn/end` reasons: `[turn failed <code>] <message>`, `[turn aborted] <reason>`, `[turn rejected] <reason>`, `[turn interrupted by a previous process exit]`, and the output-token-limit notice. Unknown merge-extended kinds fall through as ordinary turn ends.
`errorChain` lives in `dsh-llm` beside `HarnessError` for the same reason the base class does: it is the leaf package every consumer already imports, so sharing costs no new dependency edge. `errorChain` lives in `dsh-llm` beside `HarnessError` for the same reason the base class does: it is the leaf package every consumer already imports, so sharing costs no new dependency edge.

View File

@@ -15,7 +15,7 @@ TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed`
- `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是诊断表面的渲染器;路由仍然基于 `HarnessError.code` - `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是诊断表面的渲染器;路由仍然基于 `HarnessError.code`
- DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('TRANSPORT')`,写明配置的 `baseURL` 并把原始拒绝值链为 `cause`。被中止的请求变为 `LlmError('ABORTED')`;由于轮次信号已处于中止状态,循环仍将该轮次归类为取消而非恢复。 - DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('TRANSPORT')`,写明配置的 `baseURL` 并把原始拒绝值链为 `cause`。被中止的请求变为 `LlmError('ABORTED')`;由于轮次信号已处于中止状态,循环仍将该轮次归类为取消而非恢复。
- 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`agent-loop 的持久化 `turn/end` 错误消息(`errorData`、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。`dsh-agent-loop``dsh-stdio``dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。 - 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`agent-loop 的持久化 `turn/end` 错误消息(`errorData`、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。实时 `agent/error` 事件与 `IdleReason``unknown` 原样保留抛出值;各诊断消费者自行渲染,而不是由循环把它包装成另一个错误。`dsh-agent-loop``dsh-stdio``dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。
- `dsh-stdio` 渲染失败的 `turn/end` reason`[turn failed <code>] <message>``[turn aborted] <reason>``[turn rejected] <reason>``[turn interrupted by a previous process exit]` 以及输出 token 上限通知。未知的 merge 扩展 kind 按普通 turn 结束处理。 - `dsh-stdio` 渲染失败的 `turn/end` reason`[turn failed <code>] <message>``[turn aborted] <reason>``[turn rejected] <reason>``[turn interrupted by a previous process exit]` 以及输出 token 上限通知。未知的 merge 扩展 kind 按普通 turn 结束处理。
`errorChain``HarnessError` 一样放在 `dsh-llm` 里,理由相同:它是每个消费者都已导入的叶子包,共享不增加新的依赖边。 `errorChain``HarnessError` 一样放在 `dsh-llm` 里,理由相同:它是每个消费者都已导入的叶子包,共享不增加新的依赖边。

View File

@@ -91,7 +91,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @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
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
session.md: f439b3cb681a4073bba76cfeb23e8711000da797 session.md: 6401a1dd4111ab157138e8fed53754c41db7022c
session.zh.md: 8da3384900a1963de61552e3e72af8bbb1f509d6 session.zh.md: e3573c2836b598bcad11835de80b10c59386917c

View File

@@ -516,7 +516,8 @@ interface TurnEndReasonMap {
* step number the failure occurred on (the operational error's location — the * 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 * single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). Final model-request failures retain their normalized facts * `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 } & ( error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never } | { failure: LlmFailure; message?: never; code?: never }

View File

@@ -516,7 +516,8 @@ interface TurnEndReasonMap {
* step number the failure occurred on (the operational error's location — the * 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 * single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). Final model-request failures retain their normalized facts * `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 } & ( error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never } | { failure: LlmFailure; message?: never; code?: never }

View File

@@ -877,7 +877,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
{ {
name: 'agent/error', name: 'agent/error',
mode: 'emit', mode: 'emit',
signature: '\'agent/error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void', signature: '\'agent/error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void',
jsDoc: '/**\n * A step or turn errored. The machine reports a failure here (plus the\n * logger) even when the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * A step or turn errored. The machine reports a failure here (plus the\n * logger) even when the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A step or turn errored.', summary: 'A step or turn errored.',
}, },

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { Context } from 'cordis' 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 SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -131,8 +131,8 @@ describe('tool JSON parse', () => {
}) })
}) })
describe('toError normalization', () => { describe('thrown-value propagation', () => {
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => { it('preserves non-Error throws from pre-commit dispatch validation', async () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -143,57 +143,56 @@ describe('toError normalization', () => {
const event = args[1] as SessionEvent const event = args[1] as SessionEvent
if (event.type === 'turn/start' && !threwOnce) { if (event.type === 'turn/start' && !threwOnce) {
threwOnce = true 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)) ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'fails before turn start') send(agent, 'fails before turn start')
send(agent, 'survives as the next item') send(agent, 'survives as the next item')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1) 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) expect(adapter.requests).toHaveLength(1)
const starts = agent.session.events.filter(event => event.type === 'turn/start') const starts = agent.session.events.filter(event => event.type === 'turn/start')
const ends = agent.session.events.filter(event => event.type === 'turn/end') const ends = agent.session.events.filter(event => event.type === 'turn/end')
const messages = agent.session.events.filter(event => event.type === 'user/message') const messages = agent.session.events.filter(event => event.type === 'user/message')
expect(starts).toHaveLength(1) 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(ends).toHaveLength(1)
expect(messages).toHaveLength(1) expect(messages).toHaveLength(2)
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([ expect(messages[1]?.type === 'user/message' && messages[1].data.content).toEqual([
{ type: 'text', text: 'survives as the next item' }, { 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 adapter = new MockAdapter([textResponse('irrelevant')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false 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) { if (!threwOnce) {
threwOnce = true 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)) ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go') send(agent, 'go')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1) expect(errors).toHaveLength(1)
// String() of { code: 500 } is '[object Object]' expect(errors[0]).toEqual({ code: 500 })
expect(errors[0]!.message).toBe('[object Object]')
const turnEnd = agent.session.events.find(e => e.type === 'turn/end') const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
&& ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)) && ('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' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false 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) { if (!threwOnce) {
threwOnce = true threwOnce = true
throw new LlmError('server overloaded', 'RATE_LIMIT') throw new LlmError('server overloaded', 'RATE_LIMIT')
@@ -212,13 +211,13 @@ describe('coded error data emission', () => {
return next() return next()
}) })
const errors: Error[] = [] const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go') send(agent, 'go')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1) 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 // turn-end error reason includes the code
const turnEnd = agent.session.events.find(e => e.type === 'turn/end') 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 * 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 * model-request failures, the adapter-normalized facts) so a recovery
* consumer can decide to repair and {@link Agent.retry}. * consumer can decide to repair and {@link Agent.retry}.
*/ */
export type IdleReason = export type IdleReason =
| { kind: 'completed' } | { kind: 'completed' }
| { kind: 'aborted' } | { 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`. */ /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' 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. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @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 * 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 * single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). Final model-request failures retain their normalized facts * `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 } & ( error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never } | { failure: LlmFailure; message?: never; code?: never }

View File

@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises' import { stat } from 'node:fs/promises'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
@@ -544,8 +545,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
}), }),
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => { ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => {
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) })) queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) }))
}), }),
] ]
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() }) return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })

View File

@@ -583,9 +583,9 @@ describe('events streams', () => {
// Raw ctx.emit lacks the scope carrier the mounted invariants plugin now // Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
// enforces; dispatch the way the loop does. // enforces; dispatch the way the loop does.
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom')) agentEvents(ctx, agent).emit('agent/error', 1, 1, 'boom')
const errorFrame = await stream.next() const errorFrame = await stream.next()
expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' }) expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'boom' })
ac.abort() ac.abort()
// Push-after-done: an event landing between abort and generator wind-down // Push-after-done: an event landing between abort and generator wind-down