Files
deepseek-harness/packages/llm/llm/src/error.ts
T
Turtle aeb48cc5f3 Render error cause chains at every diagnostic seam
A TUI run against an unreachable endpoint failed with only 'fetch failed':
undici wraps transport failures in a bare TypeError whose diagnosis lives
on .cause, and every diagnostic seam rendered only error.message. The
readline front door additionally rendered failed turns as pure silence.

- dsh-llm: new errorChain(value) renders the full cause chain and
  AggregateError members with circular/hostile-coercion containment.
- llm-deepseek: pre-response transport failures throw LlmError('NETWORK')
  naming the endpoint and chaining the fetch TypeError; aborts keep their
  DOMException so the loop still classifies them as cancellation.
- agent-loop: durable turn/end error messages and logger warnings render
  through errorChain; local renderThrown copies removed.
- ui-stdio: failure turn/end reasons now render ([turn failed <code>],
  [turn aborted], [turn rejected], output-token-limit); startup-failure
  logs use errorChain.
- ui-tui: agent/error notices and the startup-failure line use errorChain.
2026-07-20 12:35:10 +08:00

118 lines
5.3 KiB
TypeScript

/**
* Harness error base with a stable machine-routable code and chained cause.
* Package errors extend it so tool results and replay can retain failure class.
* @module @deepseek-ai/dsh-llm/error
*/
/**
* Base class for all harness errors. Carries a `code` (stable, programmatic —
* e.g. `NO_ADAPTER`, `INVALID_ARGS`, `INVARIANT`) distinct from the
* human-readable `message`, and supports `cause` chaining via the standard
* `ErrorOptions`. `name` defaults to the subclass constructor name.
*/
export class HarnessError extends Error {
/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */
readonly code: string
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, options)
this.code = code
this.name = new.target.name
}
}
/** Canonical provider-neutral code for a model request rejected because its context window was exceeded. */
export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED'
/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(
String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]`
+ String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`,
'i',
)
/** Request-size wording that ties "too large" directly to model context capacity. */
const TOO_LARGE_FOR_CONTEXT = new RegExp(
String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?`
+ String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?`
+ String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`,
'i',
)
/** "Exceeds" wording is safe only when its object is explicitly the model context. */
const EXCEEDS_MODEL_CONTEXT = new RegExp(
String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}`
+ String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}`
+ String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`,
'i',
)
/**
* Recognize the context-overflow wording used by OpenAI-compatible providers
* and library adapters. Adapters pass all available provider code, type, and
* message text so both thrown and in-band delivery styles share one classifier.
* @param detail - provider error code/type/message text joined into one string.
* @returns true when the detail identifies a request exceeding the model context window.
*/
export function isContextWindowExceededError(detail: string): boolean {
return STRUCTURED_CONTEXT_OVERFLOW.test(detail)
|| /\b(?:maximum|max)(?:\s+(?:allowed|supported))?\s+context\s+(?:length|window)\b/i.test(detail)
|| TOO_LARGE_FOR_CONTEXT.test(detail)
|| /\b(?:input|prompt|request)\s+(?:is\s+)?too\s+(?:long|large)\s+for\s+(?:this|the)\s+model\b/i.test(detail)
|| 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).
* @returns true only for real instances; duck-typed or cross-realm errors do not narrow.
*/
export function isHarnessError(value: unknown): value is HarnessError {
return value instanceof HarnessError
}