Merge latest master into codex/llm-error-recovery-rfc
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/src/loop.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/llm/llm-deepseek/README.md # packages/llm/llm-deepseek/src/adapter.ts # packages/llm/llm-deepseek/tests/adapter.spec.ts # packages/llm/llm/README.md # packages/llm/llm/src/error.ts # packages/llm/llm/tests/service.spec.ts # packages/sandbox/sandbox-policy/tsconfig.json # packages/ui/stdio/README.md # packages/ui/stdio/src/index.ts # packages/ui/stdio/tests/stdio.spec.ts # packages/ui/tui/src/index.ts # pnpm-lock.yaml # website/zh-CN/api/harness/events.md # website/zh-CN/api/harness/llm.md
This commit is contained in:
@@ -48,6 +48,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
|
||||
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history.
|
||||
- `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`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) matches its frozen serializable `failure.code`. The payload may also retain validated status, `Retry-After`, and branded provider request id facts; policy remains outside the error.
|
||||
- `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result.
|
||||
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
|
||||
- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits.
|
||||
|
||||
|
||||
@@ -78,6 +78,51 @@ export function isQuotaExceededError(detail: string): boolean {
|
||||
|| /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i.test(detail)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a thrown value with its full `cause` chain and AggregateError
|
||||
* members, so transport wrappers like undici's `TypeError: fetch failed`
|
||||
* surface the underlying failure instead of masking it. Diagnostic-surface
|
||||
* rendering only (messages, notices, logs) — never parse the result; route on
|
||||
* {@link HarnessError.code}.
|
||||
* @param value - the caught value (`unknown` in catch clauses).
|
||||
* @returns the outermost message first, each cause appended with `: ` (skipped
|
||||
* when it repeats the wrapper message verbatim), and AggregateError members
|
||||
* bracketed and `; `-joined.
|
||||
*/
|
||||
export function errorChain(value: unknown): string {
|
||||
// Tracks the active recursion path (entries removed on exit), so only true
|
||||
// cycles are flagged and a diamond-shared cause still renders in full.
|
||||
const path = new Set<unknown>()
|
||||
const render = (current: unknown): string => {
|
||||
if (path.has(current)) return '<circular cause>'
|
||||
path.add(current)
|
||||
try {
|
||||
if (!(current instanceof Error)) return String(current)
|
||||
const message = current.message === '' ? current.name : current.message
|
||||
const members = current instanceof AggregateError && current.errors.length > 0
|
||||
? ` [${current.errors.map(render).join('; ')}]`
|
||||
: ''
|
||||
const causeText = current.cause === undefined || current.cause === null
|
||||
? ''
|
||||
: render(current.cause)
|
||||
// Wrappers like `new HarnessError(String(value), code, { cause: value })`
|
||||
// repeat their cause verbatim; rendering it again would only add noise.
|
||||
const cause = causeText === '' || causeText === message ? '' : `: ${causeText}`
|
||||
return `${message}${members}${cause}`
|
||||
} catch {
|
||||
// Only hostile coercion or hostile accessors (a throwing toString /
|
||||
// Symbol.toPrimitive on a non-Error, or a throwing message/name/cause/
|
||||
// errors getter on an Error subclass): this renderer feeds UI notices
|
||||
// and logs, so nothing may escape. Inner frames catch their own throws,
|
||||
// so only the hostile node collapses, not the whole chain.
|
||||
return '<unrenderable value>'
|
||||
} finally {
|
||||
path.delete(current)
|
||||
}
|
||||
}
|
||||
return render(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams).
|
||||
* @param value - the caught value (`unknown` in catch clauses).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, {
|
||||
errorChain,
|
||||
GenerateOptions,
|
||||
HarnessError,
|
||||
isContextWindowExceededError,
|
||||
@@ -94,6 +95,50 @@ describe('LlmService', () => {
|
||||
expect(isQuotaExceededError('quota resets in one minute')).toBe(false)
|
||||
})
|
||||
|
||||
it('errorChain renders the full cause chain of a wrapped transport failure', () => {
|
||||
const chain = new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:443') })
|
||||
expect(errorChain(chain)).toBe('fetch failed: connect ECONNREFUSED 127.0.0.1:443')
|
||||
})
|
||||
|
||||
it('errorChain renders AggregateError members (Happy Eyeballs multi-address failures)', () => {
|
||||
const aggregate = new AggregateError(
|
||||
[new Error('connect ECONNREFUSED ::1:443'), new Error('connect ECONNREFUSED 127.0.0.1:443')],
|
||||
'',
|
||||
)
|
||||
const wrapped = new TypeError('fetch failed', { cause: aggregate })
|
||||
expect(errorChain(wrapped)).toBe(
|
||||
'fetch failed: AggregateError [connect ECONNREFUSED ::1:443; connect ECONNREFUSED 127.0.0.1:443]',
|
||||
)
|
||||
})
|
||||
|
||||
it('errorChain survives non-Error values, hostile coercion, and circular causes', () => {
|
||||
expect(errorChain('plain string')).toBe('plain string')
|
||||
expect(errorChain({ toString: () => { throw new Error('hostile') } })).toBe('<unrenderable value>')
|
||||
const circular = new Error('outer')
|
||||
circular.cause = circular
|
||||
expect(errorChain(circular)).toBe('outer: <circular cause>')
|
||||
// A hostile accessor collapses only its own node, not the whole chain.
|
||||
const hostileNode = new Error('node')
|
||||
Object.defineProperty(hostileNode, 'message', { get() { throw new Error('hostile getter') } })
|
||||
expect(errorChain(new Error('outer', { cause: hostileNode }))).toBe('outer: <unrenderable value>')
|
||||
// A diamond-shared (non-cyclic) cause renders in full on both paths.
|
||||
const shared = new Error('shared')
|
||||
const diamond = new AggregateError([new Error('a', { cause: shared }), new Error('b', { cause: shared })], 'agg')
|
||||
expect(errorChain(diamond)).toBe('agg [a: shared; b: shared]')
|
||||
})
|
||||
|
||||
it('errorChain falls back to the error name, skips empty aggregates, and stops at null causes', () => {
|
||||
expect(errorChain(new TypeError('', { cause: null }))).toBe('TypeError')
|
||||
expect(errorChain(new AggregateError([], 'all failed'))).toBe('all failed')
|
||||
})
|
||||
|
||||
it('errorChain collapses a cause that repeats the wrapper message verbatim', () => {
|
||||
// The `new HarnessError(String(value), code, { cause: value })` normalization
|
||||
// pattern repeats its cause; rendering it twice would only add noise.
|
||||
const wrapped = new HarnessError('boom', 'UNKNOWN', { cause: 'boom' })
|
||||
expect(errorChain(wrapped)).toBe('boom')
|
||||
})
|
||||
|
||||
it('routes stream() to the registered adapter', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
Reference in New Issue
Block a user