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:
Tianyi Cui
2026-06-14 01:07:28 +08:00
parent 7a39616a06
commit 825b57aff9
18 changed files with 224 additions and 32 deletions
+20
View File
@@ -9,6 +9,7 @@
import { Context, Service } from 'cordis'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -65,11 +66,23 @@ export interface ToolExecution {
signal?: AbortSignal
}
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
code: string
}
/** The outcome of one tool call. */
export interface ToolExecutionResult {
callId: CallId
content: ContentBlock[]
isError: boolean
/**
* Set when the call failed with a {@link HarnessError}: machine-routable
* `{ name, code }` for retry/sandbox plugins and replay. The model-facing
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
}
/**
@@ -87,6 +100,11 @@ function errorMessage(error: unknown): string {
return String(error)
}
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
function errorInfo(error: unknown): ToolErrorInfo | undefined {
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
}
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/execute` waterfall. The registry
@@ -160,10 +178,12 @@ export class ToolRegistry extends Service {
const content = await tool.execute(exec.arguments, exec)
return { callId: exec.callId, content, isError: false }
} catch (error: unknown) {
const info = errorInfo(error)
return {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
isError: true,
...info ? { error: info } : {},
}
}
})
+7 -10
View File
@@ -20,7 +20,7 @@
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ToolDefinition, ToolExecution } from './index.ts'
// ---------------------------------------------------------------------------
@@ -174,20 +174,17 @@ export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
/**
* Thrown by a {@link defineTool} tool when the model-generated arguments don't
* match the declared {@link SchemaSpec}. The registry's execute waterfall
* catches it and returns an `isError` result so the model can self-correct.
*
* Plain `Error` for now (carries a `code` field); a later change promotes the
* harness error taxonomy and this extends a common base.
* match the declared {@link SchemaSpec}. Extends {@link HarnessError}
* (`code: 'INVALID_ARGS'`); the registry's execute waterfall catches it and
* returns an `isError` ToolExecutionResult carrying the structured error, so
* the model can self-correct and downstream plugins can route on the code.
*/
export class ToolArgsError extends Error {
/** Machine-routable code; stable across the message wording. */
readonly code = 'INVALID_ARGS'
export class ToolArgsError extends HarnessError {
/** The individual violation messages, in declaration order. */
readonly violations: string[]
constructor(violations: string[]) {
super(`invalid arguments: ${violations.join('; ')}`)
super(`invalid arguments: ${violations.join('; ')}`, 'INVALID_ARGS')
this.name = 'ToolArgsError'
this.violations = violations
}