feat(llm): structured error taxonomy with a shared HarnessError base (RFC 005 pt 2)
Introduce HarnessError in dsh-llm (the leaf package): a stable machine-routable
code distinct from the message, cause chaining, name from the subclass, plus
isHarnessError. LlmError, ToolArgsError, and InvariantError now extend it.
Tool failures carry the structure end-to-end: ToolExecutionResult gains
error: { name, code } (populated from a thrown HarnessError), and the loop
forwards it onto the tool/result session event (which gained the same optional
field) for retry/sandbox plugins and replay. The loop's toError wraps non-Error
throws in a HarnessError(code: UNKNOWN, cause) instead of a bare Error.
Landed last and in isolation so it's a pure upgrade over the plain Error+code
the earlier PRs used — independently revertible. Graduates RFC 005 pt 2 ->
ADR 0015; RFC 005 now fully implemented.
This commit is contained in:
@@ -38,7 +38,8 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
|
||||
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. Used by the agent loop (raw chunks for replay
|
||||
+ assembled for history) and by `streamBlocks()`/`generate()`.
|
||||
- `LlmError` — typed error with a `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) and an optional numeric `status` when the failure came from a non-2xx provider response.
|
||||
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
|
||||
- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response.
|
||||
|
||||
### Real adapters
|
||||
|
||||
|
||||
33
packages/llm/src/error.ts
Normal file
33
packages/llm/src/error.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The harness error taxonomy: one base class so failures carry a stable,
|
||||
* machine-routable `code` and chain their `cause`, instead of flattening to a
|
||||
* bare message string. Per-package errors extend {@link HarnessError}; the
|
||||
* tool layer surfaces `{ name, code }` on results and the session `tool/result`
|
||||
* event so retry/sandbox plugins and replay can distinguish failure classes.
|
||||
*
|
||||
* Lives in dsh-llm (the leaf package every other imports) so a single base is
|
||||
* shared without a new dependency edge. See ADR 0015.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/error
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base class for all harness errors. Carries a `code` (stable, programmatic —
|
||||
* e.g. `NO_ADAPTER`, `INVALID_ARGS`, `INVARIANT`) distinct from the
|
||||
* human-readable `message`, and supports `cause` chaining via the standard
|
||||
* `ErrorOptions`. `name` defaults to the subclass constructor name.
|
||||
*/
|
||||
export class HarnessError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.code = code
|
||||
this.name = new.target.name
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). */
|
||||
export function isHarnessError(value: unknown): value is HarnessError {
|
||||
return value instanceof HarnessError
|
||||
}
|
||||
@@ -9,9 +9,11 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types.ts'
|
||||
import { BlockAssembler } from './assembler.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
|
||||
export * from './brand.ts'
|
||||
export * from './never.ts'
|
||||
export * from './error.ts'
|
||||
export * from './types.ts'
|
||||
export { BlockAssembler } from './assembler.ts'
|
||||
|
||||
@@ -31,14 +33,14 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed error for LLM-related failures. The `code` string enables programmatic
|
||||
* handling (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`); `status` carries the HTTP
|
||||
* status when the error originated from a non-2xx provider response (absent for
|
||||
* protocol/usage errors that have no HTTP status).
|
||||
* Typed error for LLM-related failures. Extends {@link HarnessError}, so the
|
||||
* `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy;
|
||||
* `status` carries the HTTP status when the error originated from a non-2xx
|
||||
* provider response (absent for protocol/usage errors that have no HTTP status).
|
||||
*/
|
||||
export class LlmError extends Error {
|
||||
constructor(message: string, public code: string, public status?: number) {
|
||||
super(message)
|
||||
export class LlmError extends HarnessError {
|
||||
constructor(message: string, code: string, public status?: number, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.name = 'LlmError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,28 @@ describe('LlmService', () => {
|
||||
expect(err.code).toBe('CUSTOM_CODE')
|
||||
})
|
||||
|
||||
it('LlmError extends the shared HarnessError base', async () => {
|
||||
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const err = new LlmError('boom', 'AUTH', 401)
|
||||
expect(err).toBeInstanceOf(HarnessError)
|
||||
expect(isHarnessError(err)).toBe(true)
|
||||
expect(err.code).toBe('AUTH')
|
||||
expect(err.status).toBe(401)
|
||||
})
|
||||
|
||||
it('HarnessError carries a code, names itself by subclass, and chains cause', async () => {
|
||||
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const root = new Error('root cause')
|
||||
const err = new HarnessError('wrapper', 'UNKNOWN', { cause: root })
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.name).toBe('HarnessError')
|
||||
expect(err.code).toBe('UNKNOWN')
|
||||
expect(err.cause).toBe(root)
|
||||
expect(isHarnessError(err)).toBe(true)
|
||||
expect(isHarnessError(root)).toBe(false)
|
||||
expect(isHarnessError('nope')).toBe(false)
|
||||
})
|
||||
|
||||
it('disposes adapter registration on adapter-change event emission', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
Reference in New Issue
Block a user