# Conflicts: # .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.i18n.yaml # .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md # .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-07-07-session-prefix.md # .agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml # docs/architecture.md # docs/config-catalog.md # docs/core-data-structures/session.md # docs/rfc/INDEX.md # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/src/index.ts # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/support/invariants/README.md # packages/support/invariants/src/index.ts # packages/support/invariants/tests/invariants.spec.ts
2.3 KiB
Agent Note: Structured error taxonomy
Status: implemented
Problem
Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in new Error(String(x)), dropping any code. And LlmError was the only typed error in the system, with no shared base, so there was nothing for a consumer to instanceof against generically.
Decision
A single HarnessError extends Error base in dsh-llm (the leaf package every other imports — no new dependency edge): a stable code distinct from message, cause chaining via ErrorOptions, and name defaulting to the subclass. isHarnessError narrows at seams.
LlmErrorandToolArgsError(dsh-tools) extend it, keeping their existing codes.ToolExecutionResultgains optionalerror: { name, code }, populated in the registry's catch when the thrown value is aHarnessError. The agent loop forwards it onto thetool/resultsession event (which gained the same optional field), so the structured failure survives into the log for retry/sandbox plugins and replay. The model-facing text block is unchanged.- The loop's
toErrorwraps a non-Error throw in aHarnessError(code: 'UNKNOWN', original chained ascause) instead of a bareError, so even a bad throw carries a routable code into the sessionerrorevent (which already surfacedcode).
Consequences
- Errors are machine-routable end-to-end: a plugin can branch on
error.coderather than substring-matching a message. - One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge.
deriveMessagesdoes not surfaceerrorinto model history — the model still sees the text block; the structured field is for code and replay.- Argument validation retains its existing code and behavior; package-owned diagnostic invariants carry their stable code independently so the invariant registry does not import a product package. The shared base adds cross-seam routing metadata without changing model-facing text.