4.8 KiB
Agent Note: Render error cause chains at every diagnostic boundary
Status: implemented
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-workerthreadstill render without the chain; they wrap package-local errors that carry their own messages, and can adopterrorChainwhen their diagnostics prove insufficient.