Render error cause chains at every diagnostic boundary
在每个诊断边界渲染错误 cause 链
A TUI run against an unreachable DeepSeek endpoint failed with the single notice `fetch failed` and no further detail. Two independent gaps produced that dead end: 1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic b
English
Problem
A TUI run against an unreachable DeepSeek endpoint failed with the single notice fetch failed and no further detail. Two independent gaps produced that dead end:
- undici's
fetchwraps every transport failure (DNS, refused connection, TLS, proxy) in a bareTypeError: fetch failedwhose actionable detail —ECONNREFUSED,bad port, the Happy Eyeballs AggregateError — lives onerror.cause. Every diagnostic boundary in the harness rendered onlyerror.message(orString(error), which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durableturn/endreason, and every logger line. - The readline entry point (
dsh-stdio) rendered no failure reason at all: aturn/endwithreason.kind === 'error'printed nothing but the next>prompt, so the same failure indemo:replwas pure silence.
Decision
dsh-llmexportserrorChain(value): renders a thrown value with its fullcausechain (outer: inner: …) and AggregateError members (msg [m1; m2]), with circular-cause and hostile-coercion containment. It is a diagnostic-output renderer only; routing stays onHarnessError.code.- The DeepSeek adapter wraps a pre-response transport failure in
LlmError('TRANSPORT')naming the configuredbaseURLand chaining the original rejection ascause. An aborted request becomesLlmError('ABORTED'); because the turn signal is already aborted, the loop still classifies the turn as cancellation rather than recovery. - Every diagnostic boundary renders through
errorChaininstead oferror.message/String(error): the agent-loop's durableturn/enderror message (errorData), its logger warnings, the TUI'sagent/errornotice and startup-failure line, anddsh-stdio's startup-failure log lines. The liveagent/errorevent andSettleReasonpreserve the thrown value asunknown; each diagnostic Consumer renders it instead of the loop wrapping it into another error. The per-packagerenderThrowncopies indsh-agent-loop,dsh-stdio, anddsh-tuiare deleted in favor of the one shared renderer. dsh-stdiorenders failureturn/endreasons:[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.
Alternatives considered
Chain rendering inside each error's constructor (bake the cause into message). Rejected: it double-renders once consumers also walk cause (the first draft of the adapter fix produced … fetch failed: bad port: fetch failed: bad port), and it destroys the structured chain for consumers that want to route on the inner error.
A cause-aware logger exporter only. Rejected: the durable turn/end reason and the TUI notice are not logger lines; the masked message would persist in the session log — the single durable record of an in-turn failure — and in the primary UI.
Per-package renderThrown upgrades. Rejected: three packages already carried near-identical private copies; upgrading each separately entrenches the duplication the shared renderer removes.
Consequences
- A transport failure now reads
DeepSeek API request to <baseURL> failed: fetch failed: connect ECONNREFUSED …in the TUI notice, the readline transcript, and the persisted session log, at the cost of longer diagnostic strings. - Durable
turn/enderror messages include cause detail. Existing snapshot fixtures replay byte-identically because their scripted errors carry nocause(for such errorserrorChain(err)equalserr.message); only unit-test expectation strings changed. A fixture recorded from a real transport failure would carry the chain. errorChainrendersmessagewithout the class name (String(error)renderedError: <message>), so a bareTypeErrorin a log line loses its type label unless its message is empty (then the name is the fallback). The chain detail was judged worth more than the class name at these diagnostic boundaries.dsh-stdiooutput for failed turns is no longer silent; piped consumers that parsed the transcript see new[turn …]lines.- Remaining
renderThrowncopies indsh-subagent,dsh-workflow,dsh-skill, anddsh-workflow-worker-threadstill render without the chain; they wrap package-local errors that carry their own messages, and can adopterrorChainwhen their diagnostics prove insufficient.
中文
问题
TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 fetch failed 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同:
- undici 的
fetch把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的TypeError: fetch failed,可操作的细节——ECONNREFUSED、bad port、Happy Eyeballs 的 AggregateError——都在error.cause上。harness 里的每个诊断边界都只渲染error.message(或对 Error 等价的String(error)),于是包装层在 TUI 通知、持久化的turn/endreason 和所有日志行里都掩盖了诊断信息。 - readline 入口(
dsh-stdio)完全不渲染失败原因:reason.kind === 'error'的turn/end只打印下一个>提示符,同样的失败在demo:repl里就是纯粹的沉默。
决策
dsh-llm导出errorChain(value):渲染抛出值及其完整cause链(outer: inner: …)与 AggregateError 成员(msg [m1; m2]),并容错循环 cause 和恶意强制转换。它只是用于诊断输出的渲染器;路由仍然基于HarnessError.code。- DeepSeek 适配器把拿到响应之前的传输失败包装成
LlmError('TRANSPORT'),写明配置的baseURL并将原始拒绝值作为cause串入错误链。被中止的请求变为LlmError('ABORTED');由于轮次信号已处于中止状态,agent loop(智能体循环)仍将该轮次归类为取消而非恢复。 - 每个诊断边界改用
errorChain而非error.message/String(error):agent-loop 的持久化turn/end错误消息(errorData)、其日志警告、TUI 的agent/error通知与启动失败行、以及dsh-stdio的启动失败日志行。实时agent/error事件与SettleReason以unknown原样保留抛出值;各诊断消费方自行渲染,而不是由循环把它包装成另一个错误。dsh-agent-loop、dsh-stdio、dsh-tui里各自的renderThrown副本被删除,统一使用这一个共享渲染器。 dsh-stdio渲染失败的turn/endreason:[turn failed <code>] <message>、[turn aborted] <reason>、[turn rejected] <reason>、[turn interrupted by a previous process exit]以及输出 token 上限通知。通过声明合并扩展出的未知 kind 按普通轮次结束处理。
errorChain 与 HarnessError 一样放在 dsh-llm 里,理由相同:它是每个消费方都已导入的叶子包,共享不增加新的依赖边。
考虑过的替代方案
在每个错误的构造函数里渲染链(把 cause 写入 message)。 否决:当消费方同时遍历 cause 时会双重渲染(适配器修复的第一版产出了 … fetch failed: bad port: fetch failed: bad port),并且破坏了想按内层错误路由的消费方所需的结构化链。
只做一个感知 cause 的日志导出器。 否决:持久化的 turn/end reason 和 TUI 通知不是日志行;被掩盖的消息会留在会话日志——轮次内失败的唯一持久记录——以及主要 UI 中。
逐包升级 renderThrown。 否决:三个包已经各自持有几乎相同的私有副本;分别升级只会固化共享渲染器所要消除的重复。
后果
- 传输失败现在在 TUI 通知、readline transcript(文本记录)和持久化会话日志里显示为
DeepSeek API request to <baseURL> failed: fetch failed: connect ECONNREFUSED …,代价是更长的诊断字符串。 - 持久化的
turn/end错误消息包含 cause 细节。现有快照 fixture(测试前置数据)字节级一致地回放,因为其脚本化错误不带cause(对这类错误errorChain(err)等于err.message);只有单元测试的期望字符串有变化。从真实传输失败录制的 fixture 会携带完整链。 errorChain渲染message而不带类名(String(error)会渲染Error: <message>),因此日志行里的裸TypeError会丢失类型标签,除非消息为空(此时回退到类名)。在这些诊断边界上,链细节被判断为比类名更有价值。dsh-stdio对失败轮次的输出不再沉默;解析 transcript 的管道消费方会看到新的[turn …]行。dsh-subagent、dsh-workflow、dsh-skill、dsh-workflow-worker-thread里剩余的renderThrown副本仍不渲染链;它们包装的是自带消息的包内错误,等诊断信息证明不足时再采用errorChain。