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

View File

@@ -717,6 +717,52 @@ describe('defineTool validation (RFC 005 part 1)', () => {
expect(err.message).toBe('invalid arguments: missing required property "a"; "b" must be a number')
})
it('a schema-invalid call surfaces the structured error on the result', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'reader',
description: 'reads a path',
parameters: { path: { type: 'string', required: true } },
async execute(args) {
return [{ type: 'text', text: args.path }]
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
})
it('a tool throwing a HarnessError surfaces its name and code', async () => {
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'coded',
async execute() {
throw new HarnessError('disk full', 'ENOSPC')
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
})
it('a non-HarnessError throw has no structured error (only the text)', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'plain',
async execute() {
throw new Error('just a message')
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toBeUndefined()
expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
})
it('raw-registered tools are NOT validated by defineTool (MCP keeps its own)', async () => {
const ctx = await setup()
// A raw ToolDefinition: no defineTool wrapping, so no validateArgs guard.