Merge latest invariant registration gate

This commit is contained in:
Tianyi Cui
2026-07-20 20:29:29 +08:00
22 changed files with 329 additions and 81 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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)