feat(core): add post-step request recovery (PR3 phase 1)

This commit is contained in:
Hypatia May
2026-07-15 16:03:52 +08:00
parent e55e96eec6
commit e8d066f750
29 changed files with 1207 additions and 120 deletions

View File

@@ -12,6 +12,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.models(): string[]` — model names with a registered adapter.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
`LlmService` preserves and privately tags errors from final adapter selection, synchronous dispatch, iterator construction, and iteration. `isLlmAdapterFailure(value)` exposes that provenance without classifying `llm/stream` middleware or downstream consumer failures as provider failures, and without replacing the adapter's original coded `Error`.
### Events
| Event | Mode | Purpose |
@@ -43,6 +45,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`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response.
- `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
@@ -54,7 +57,7 @@ None, as this adapter registry forwards an already assembled request without add
## Known Limitations and Deferred Work
- **No retry/caching/rate-limit layer ships** — `llm/stream` is the intended wrap seam and has no production listener, so provider 429/5xx failures surface immediately.
- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains the call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure.
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)).
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([RFC](../../../docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw.

View File

@@ -0,0 +1,34 @@
/**
* Private provider-failure tagging shared by `LlmService` and its consumers.
*
* @module @deepseek-ai/dsh-llm/adapter-failure
*/
import { HarnessError } from './error.ts'
/** Errors proven to originate in final adapter dispatch or iteration. */
const adapterFailures = new WeakSet<Error>()
/**
* Preserve an adapter's Error identity while tagging its provider origin.
* @param value - arbitrary value thrown by adapter dispatch or iteration.
* @returns the original Error, or a coded Error wrapping a non-Error throw.
* @internal
*/
export function markLlmAdapterFailure(value: unknown): Error & { code?: string } {
const error = value instanceof Error
? value as Error & { code?: string }
: new HarnessError(String(value), 'UNKNOWN', { cause: value })
adapterFailures.add(error)
return error
}
/**
* Whether a failure came from final adapter dispatch, iterator construction,
* or iteration rather than from an `llm/stream` waterfall listener.
* @param value - arbitrary failure caught by a model-call consumer.
* @returns true only for errors tagged at the final adapter boundary.
*/
export function isLlmAdapterFailure(value: unknown): value is Error & { code?: string } {
return value instanceof Error && adapterFailures.has(value)
}

View File

@@ -21,6 +21,47 @@ export class HarnessError extends Error {
}
}
/** 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)
}
/**
* Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams).
* @param value - the caught value (`unknown` in catch clauses).

View File

@@ -9,6 +9,7 @@
import { Context, Service } from 'cordis'
import type { GenerateOptions, StreamChunk } from './types.ts'
import { HarnessError } from './error.ts'
import { markLlmAdapterFailure } from './adapter-failure.ts'
export * from './attribution.ts'
export * from './brand.ts'
@@ -18,6 +19,7 @@ export * from './types.ts'
export { BlockAssembler } from './assembler.ts'
export { callConfigEquals, deepFreeze } from './call-config.ts'
export type { LlmCallConfig } from './call-config.ts'
export { isLlmAdapterFailure } from './adapter-failure.ts'
declare module 'cordis' {
interface Context {
@@ -118,17 +120,65 @@ export class LlmService extends Service {
return adapter
}
/**
* Final adapter boundary. It tags only failures from adapter selection,
* synchronous dispatch, iterator construction, or iteration while preserving
* the original Error object. Middleware outside this generator remains
* distinguishable as plugin work. Adapter cleanup is best-effort after an
* earlier failure or downstream close and never masks the winning error.
*/
private async * adapterStream(options: GenerateOptions): AsyncGenerator<StreamChunk> {
let iterator: AsyncIterator<StreamChunk>
try {
const stream = this.adapter(options.model).stream(options)
iterator = stream[Symbol.asyncIterator]()
} catch (error: unknown) {
throw markLlmAdapterFailure(error)
}
let completed = false
try {
while (true) {
let value: StreamChunk
try {
const item = await iterator.next()
if (item.done) {
completed = true
return
}
value = item.value
} catch (error: unknown) {
throw markLlmAdapterFailure(error)
}
// End the adapter-owned try before yielding: consumer/middleware
// failures resumed into this generator must remain untagged.
yield value
}
} finally {
if (!completed) {
try {
const close = iterator.return?.bind(iterator)
if (close) await close()
} catch {
// Lookup and invocation are both adapter-owned cleanup following an
// existing failure/downstream close; neither can replace it.
}
}
}
}
/**
* Stream one model call as raw chunks (token-level deltas). Throws
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
* `options.model`. Dispatches through the `llm/stream` waterfall.
* `options.model`. Dispatches through the `llm/stream` waterfall. Final
* adapter dispatch/iteration failures retain their original Error identity
* and are tagged so the agent loop can distinguish them from middleware
* failures without widening request recovery to plugin code.
* @param options - the full request; `options.model` selects the adapter.
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.ctx.waterfall(this, 'llm/stream', options, () => {
return this.adapter(options.model).stream(options)
})
return this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options))
}
}

View File

@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { GenerateOptions, LlmAdapter, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, {
GenerateOptions,
HarnessError,
isContextWindowExceededError,
isLlmAdapterFailure,
LlmAdapter,
LlmError,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
class ScriptedAdapter extends LlmAdapter {
constructor(private script: StreamChunk[]) {
@@ -19,6 +27,22 @@ const SCRIPT: StreamChunk[] = [
]
describe('LlmService', () => {
it('recognizes structured and model-capacity context-window overflow details', () => {
expect(isContextWindowExceededError('context_length_exceeded maximum context length')).toBe(true)
expect(isContextWindowExceededError('context-window-overflowed')).toBe(true)
expect(isContextWindowExceededError('This model maximum context length is 128000 tokens')).toBe(true)
expect(isContextWindowExceededError('input is too long for this model')).toBe(true)
expect(isContextWindowExceededError('request too large for model context')).toBe(true)
expect(isContextWindowExceededError('input exceeds the model context window limit')).toBe(true)
})
it('does not mistake unrelated input validation for context-window overflow', () => {
expect(isContextWindowExceededError('invalid request: malformed tool arguments')).toBe(false)
expect(isContextWindowExceededError('invalid input: temperature exceeds maximum allowed value')).toBe(false)
expect(isContextWindowExceededError('input exceeds maximum allowed value')).toBe(false)
expect(isContextWindowExceededError('context window size must be positive')).toBe(false)
})
it('routes stream() to the registered adapter', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -32,9 +56,177 @@ describe('LlmService', () => {
it('throws NO_ADAPTER for unregistered models', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect((async () => {
let caught: unknown
try {
for await (const _ of ctx.llm.stream({ model: 'nope', messages: [] })) { /* drain */ }
})()).rejects.toThrow('no adapter registered')
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(LlmError)
expect((caught as LlmError).code).toBe('NO_ADAPTER')
expect((caught as LlmError).message).toContain('no adapter registered')
expect(isLlmAdapterFailure(caught)).toBe(true)
})
it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => {
const original = new LlmError(`${field} getter failed`, 'RESULT_GETTER_FAILED')
const result = field === 'done' ? {} : { done: false }
Object.defineProperty(result, field, { get: () => { throw original } })
const adapter = new class extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
return { next: () => Promise.resolve(result as unknown as IteratorResult<StreamChunk>) }
},
}
}
}()
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(original)
expect(isLlmAdapterFailure(caught)).toBe(true)
})
it.each(['dispatch', 'iterator'] as const)('tags synchronous adapter %s failures without replacing their Error', async (boundary) => {
const original = new LlmError(`${boundary} failed`, 'BOUNDARY_FAILED')
const adapter = new class extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
if (boundary === 'dispatch') throw original
return { [Symbol.asyncIterator]: () => { throw original } }
}
}()
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(original)
expect(isLlmAdapterFailure(caught)).toBe(true)
})
it('tags adapter iteration failures without replacing the original Error or cleanup outcome', async () => {
const original = new LlmError('provider failed', 'PROVIDER_FAILED')
let cleanupCalls = 0
const adapter = new class extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
return {
next: () => Promise.reject(original),
return: () => {
cleanupCalls += 1
return Promise.reject(new Error('cleanup failed'))
},
}
},
}
}
}()
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(original)
expect(isLlmAdapterFailure(caught)).toBe(true)
expect(cleanupCalls).toBe(1)
})
it('contains a throwing iterator.return getter after next fails without replacing the original Error', async () => {
const original = new LlmError('provider failed', 'PROVIDER_FAILED')
let cleanupLookups = 0
const iterator: AsyncIterator<StreamChunk> = { next: () => Promise.reject(original) }
Object.defineProperty(iterator, 'return', {
get: () => {
cleanupLookups += 1
throw new Error('return getter failed')
},
})
const adapter = new class extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
return { [Symbol.asyncIterator]: () => iterator }
}
}()
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(original)
expect(isLlmAdapterFailure(caught)).toBe(true)
expect(cleanupLookups).toBe(1)
})
it('normalizes and tags non-Error adapter failures once', async () => {
const adapter = new class extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
// Third-party adapters can reject with arbitrary values; normalization is under test.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return { next: () => Promise.reject('plain provider failure') }
},
}
}
}()
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(HarnessError)
expect(caught).toMatchObject({ code: 'UNKNOWN', cause: 'plain provider failure' })
expect(isLlmAdapterFailure(caught)).toBe(true)
})
it('does not tag a failure thrown downstream while consuming adapter output', async () => {
const downstream = new Error('consumer failed')
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) throw downstream
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(downstream)
expect(isLlmAdapterFailure(caught)).toBe(false)
})
it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => {