aeb48cc5f3
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.
118 lines
5.3 KiB
TypeScript
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
|
|
}
|