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
+11 -4
View File
@@ -9,7 +9,7 @@
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
@@ -18,9 +18,15 @@ import type { LoopAgent } from './agent.ts'
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
type CodedError = Error & { code?: string }
/** Normalize an arbitrary thrown value into a (possibly coded) Error. */
/**
* Normalize an arbitrary thrown value into a coded Error. A real Error passes
* through (its `code`, if any, is preserved by {@link errorData}); a non-Error
* throw is wrapped in a {@link HarnessError} with code `UNKNOWN` and the
* original value chained as `cause`, so a bad throw still carries a routable
* code instead of degrading to a bare message.
*/
function toError(error: unknown): CodedError {
return error instanceof Error ? error : new Error(String(error))
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
}
/**
@@ -178,7 +184,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
try {
stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
} catch (error: unknown) {
stepOutcome = { error: error instanceof Error ? error : new Error(String(error)) }
stepOutcome = { error: toError(error) }
} finally {
handle.setAbort(undefined)
}
@@ -345,6 +351,7 @@ async function runStep(
callId: result.callId,
content: result.content,
isError: result.isError,
...result.error ? { error: result.error } : {},
})
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.
@@ -6,7 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
const ctx = new Context()
@@ -177,6 +177,10 @@ describe('toError normalization', () => {
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toBe('naked string error')
// A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the
// session error event carries a routable code instead of degrading.
const errorEvent = agent.session.events.find(e => e.type === 'error')
expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN')
})
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
@@ -201,6 +205,8 @@ describe('toError normalization', () => {
expect(errors).toHaveLength(1)
// String() of { code: 500 } is '[object Object]'
expect(errors[0]!.message).toBe('[object Object]')
const errorEvent = agent.session.events.find(e => e.type === 'error')
expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN')
})
})
@@ -259,3 +265,33 @@ describe('disposed vs aborted branching', () => {
expect(reasons).toContainEqual({ kind: 'disposed' })
})
})
describe('structured tool error propagation (RFC 005 pt 2)', () => {
it('forwards a tool HarnessError onto the tool/result session event', async () => {
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
// First model turn calls the tool; second turn (after the tool result is
// fed back) ends with plain text so the loop settles.
const adapter = new MockAdapter([
toolCallResponse('c1', 'boom', {}),
textResponse('done'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
ctx.tools.register(defineTool({
name: 'boom',
description: 'always fails',
parameters: {},
async execute() {
throw new HarnessError('exploded', 'BOOM')
},
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
expect(toolResult?.type === 'tool/result' && toolResult.data.isError).toBe(true)
expect(toolResult?.type === 'tool/result' && toolResult.data.error)
.toEqual({ name: 'HarnessError', code: 'BOOM' })
})
})