From 76d0e450cefea0ffd7cfb46cbbda8cf2744294ea Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 24 Jul 2026 17:11:58 +0800 Subject: [PATCH] fix(agent): preserve thrown error values --- ...20-error-cause-chain-diagnostics.i18n.yaml | 4 +- ...026-07-20-error-cause-chain-diagnostics.md | 2 +- ...-07-20-error-cause-chain-diagnostics.zh.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 3 +- docs/core-data-structures/session.zh.md | 3 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/agent.ts | 37 +++++++----------- .../agent-loop/tests/coverage-edges.spec.ts | 39 +++++++++---------- packages/core/agent/src/types.ts | 6 +-- packages/core/session/src/types.ts | 3 +- packages/host/runtime/src/api-proxy.ts | 5 ++- .../host/runtime/tests/host-runtime.spec.ts | 4 +- 14 files changed, 55 insertions(+), 61 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index 4798f54960..5f3920e58c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -2026-07-20-error-cause-chain-diagnostics.md: 391e35997bb1bb050dd2ca620920961d77bb1c46 -2026-07-20-error-cause-chain-diagnostics.zh.md: 90d6559a9410e8a4e5475db9560a2a177ba7a1a7 +2026-07-20-error-cause-chain-diagnostics.md: 700c36c45ae8c939b6d9161ab90ab5dfe2c92686 +2026-07-20-error-cause-chain-diagnostics.zh.md: b358ec606b7c92641f3e6138fe23d832431bd5ce diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md index 391e35997b..700c36c45a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -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`. - 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 ] `, `[turn aborted] `, `[turn rejected] `, `[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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 90d6559a94..b358ec606b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -15,7 +15,7 @@ TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` - `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是诊断表面的渲染器;路由仍然基于 `HarnessError.code`。 - 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 ] `、`[turn aborted] `、`[turn rejected] `、`[turn interrupted by a previous process exit]` 以及输出 token 上限通知。未知的 merge 扩展 kind 按普通 turn 结束处理。 `errorChain` 与 `HarnessError` 一样放在 `dsh-llm` 里,理由相同:它是每个消费者都已导入的叶子包,共享不增加新的依赖边。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 05aea0ea7e..3c7a22351d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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. * @mode emit */ -'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void +'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 55517ff4e5..c4bd4e9866 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # 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: # pnpm run verify-translation-pairing --write -session.md: f439b3cb681a4073bba76cfeb23e8711000da797 -session.zh.md: 8da3384900a1963de61552e3e72af8bbb1f509d6 +session.md: 6401a1dd4111ab157138e8fed53754c41db7022c +session.zh.md: e3573c2836b598bcad11835de80b10c59386917c diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index f439b3cb68..6401a1dd41 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -516,7 +516,8 @@ 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 } diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 8da3384900..e3573c2836 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -516,7 +516,8 @@ 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 } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 223e291b4b..6d5ada67be 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -877,7 +877,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/error', mode: 'emit', - signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void', + signature: '\'agent/error\'(this: Scoped, 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 */', summary: 'A step or turn errored.', }, diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 03b07e8ead..ae9026d94e 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -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 }, } } diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 340c9e0d96..a3bbef4092 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -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') diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3617f65c17..8507031b8f 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -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, turn: number, step: number, error: Error): void + 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void } } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 28cef3044f..a6383699e0 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -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 } diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 52f537849a..f94f68bafc 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto' import { stat } from 'node:fs/promises' import type { Context } from 'cordis' 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 { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' 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) => { 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) => { - queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) })) + ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => { + queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) })) }), ] return queue.iterate(signal, () => { for (const dispose of disposers) dispose() }) diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index d86cb2ee5a..0121c3de42 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -583,9 +583,9 @@ describe('events streams', () => { // Raw ctx.emit lacks the scope carrier the mounted invariants plugin now // 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() - expect((errorFrame.value as RpcRequest).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' }) + expect((errorFrame.value as RpcRequest).payload).toMatchObject({ type: 'host/agent-error', message: 'boom' }) ac.abort() // Push-after-done: an event landing between abort and generator wind-down