Merge latest invariant registration gate
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 product error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf product package every other product package imports, so a single base is shared without a new dependency edge. Per-package errors such as `LlmError` and `ToolArgsError` 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`) is the programmatic failure contract.
|
||||
- `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.
|
||||
|
||||
### Real adapters
|
||||
|
||||
@@ -62,6 +62,51 @@ export function isContextWindowExceededError(detail: string): boolean {
|
||||
|| EXCEEDS_MODEL_CONTEXT.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,
|
||||
@@ -81,6 +82,50 @@ describe('LlmService', () => {
|
||||
expect(isContextWindowExceededError('context window size must be positive')).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