fix(llm): scope adapter failures to model calls

Replace the process-wide adapter-failure WeakSet with a per-call scope bound to the exact AsyncIterable returned by LlmService.stream(). Give every call a unique wrapper so waterfall middleware can reuse an iterable without sharing provenance.

Move agent-loop recovery classification to the model-stream boundary. Only the final adapter behind that exact call can become an agent/request-error; nested llm/stream calls remain ordinary outer middleware failures while preserving the original Error.

Cover nested calls, reused middleware iterables, and end-to-end agent-loop recovery. Update the package and RFC contracts, bilingual pairing record, and generated API and catalog references.
This commit is contained in:
Tianyi Cui
2026-07-19 16:14:58 +08:00
parent bf93605f8c
commit ee6b9d081a
14 changed files with 233 additions and 56 deletions

View File

@@ -27,7 +27,7 @@ function toError(error: unknown): RequestError {
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
}
/** Distinguishes a terminal failure finish from failures in later step processing. */
/** Distinguishes final model-request failures from failures in later step processing. */
class TerminalModelRequestFailure extends Error {
constructor(readonly requestError: RequestError) {
super(requestError.message, { cause: requestError })
@@ -347,9 +347,7 @@ async function runTurn(
stepOutcome = await runStep(
ctx, events, agent, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
} catch (error: unknown) {
if (isLlmAdapterFailure(error)) {
stepOutcome = { requestError: error }
} else if (error instanceof TerminalModelRequestFailure) {
if (error instanceof TerminalModelRequestFailure) {
stepOutcome = { requestError: error.requestError }
} else {
stepOutcome = { error: toError(error) }
@@ -624,12 +622,18 @@ async function runStep(
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
for await (const chunk of ctx.llm.stream(request)) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
assembler.push(chunk)
const stream = ctx.llm.stream(request)
try {
for await (const chunk of stream) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
assembler.push(chunk)
}
} catch (error: unknown) {
if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error)
throw error
}
// Normalize failure finish chunks into the same path as thrown stream errors.

View File

@@ -307,6 +307,42 @@ describe('agent post-step and request-error lifecycle', () => {
expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } })
})
it('does not offer a nested model-call failure as the outer request failure', async () => {
const outer = new FailureScriptAdapter([textResponse('outer adapter must not run')])
const nested = new FailureScriptAdapter([contextError('nested overflow')])
const ctx = await harness(outer)
ctx.llm.registerAdapter(['nested'], nested)
ctx.on('llm/stream', (options, next) => {
if (options.provider !== 'mock') return next()
return (async function* () {
yield* ctx.llm.stream({
provider: 'nested',
model: 'nested',
messages: [],
...options.signal === undefined ? {} : { signal: options.signal },
})
yield* next()
})()
})
const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
recoveries += 1
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(nested.requests).toHaveLength(1)
expect(outer.requests).toHaveLength(0)
expect(recoveries).toBe(0)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', message: 'nested overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } },
})
})
it.each(['prompt-submit', 'prompt-assembly', 'pre-step', 'request'] as const)(
'does not offer %s middleware failures to request recovery',
async (boundary) => {

View File

@@ -13,7 +13,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `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`.
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification does not replace the adapter's original coded `Error`.
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.

View File

@@ -5,30 +5,63 @@
*/
import { HarnessError } from './error.ts'
import type { StreamChunk } from './types.ts'
/** Errors proven to originate in final adapter dispatch or iteration. */
const adapterFailures = new WeakSet<Error>()
/** Errors proven to originate in one model call's final adapter boundary. */
export type AdapterFailureScope = WeakSet<Error>
/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */
const adapterFailureScopes = new WeakMap<AsyncIterable<StreamChunk>, AdapterFailureScope>()
/**
* Bind one call's adapter-failure scope to a unique returned stream handle.
* @param stream - the waterfall-selected stream for this call.
* @param failures - errors tagged by this call's final adapter boundary.
* @returns a unique stream handle that delegates iteration to `stream`.
* @internal
*/
export function bindAdapterFailureScope(
stream: AsyncIterable<StreamChunk>,
failures: AdapterFailureScope,
): AsyncIterable<StreamChunk> {
const call = {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
return stream[Symbol.asyncIterator]()
},
}
adapterFailureScopes.set(call, failures)
return call
}
/**
* Preserve an adapter's Error identity while tagging its provider origin.
* @param failures - the call-local final-adapter failure scope.
* @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 } {
export function markLlmAdapterFailure(
failures: AdapterFailureScope,
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)
failures.add(error)
return error
}
/**
* Whether a failure came from final adapter dispatch, iterator construction,
* or iteration rather than from an `llm/stream` waterfall listener.
* or iteration for the call represented by the exact returned stream handle.
* @param stream - the exact stream returned by the model call being classified.
* @param value - arbitrary failure caught by a model-call consumer.
* @returns true only for errors tagged at the final adapter boundary.
* @returns true only for errors tagged at that call's final adapter boundary.
*/
export function isLlmAdapterFailure(value: unknown): value is Error & { code?: string } {
return value instanceof Error && adapterFailures.has(value)
export function isLlmAdapterFailure(
stream: AsyncIterable<StreamChunk>,
value: unknown,
): value is Error & { code?: string } {
const failures = adapterFailureScopes.get(stream)
return value instanceof Error && failures !== undefined && failures.has(value)
}

View File

@@ -10,7 +10,8 @@ import { Context, Service } from 'cordis'
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts'
import { deepFreeze } from './call-config.ts'
import { HarnessError } from './error.ts'
import { markLlmAdapterFailure } from './adapter-failure.ts'
import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts'
import type { AdapterFailureScope } from './adapter-failure.ts'
export * from './attribution.ts'
export * from './brand.ts'
@@ -206,14 +207,17 @@ export class LlmService extends Service {
* so it cannot suppress the primary provider error. A downstream close awaits
* adapter cleanup, whose failures remain ordinary untagged work.
*/
private async * adapterStream(options: GenerateOptions): AsyncGenerator<StreamChunk> {
private async * adapterStream(
options: GenerateOptions,
failures: AdapterFailureScope,
): AsyncGenerator<StreamChunk> {
let iterator: AsyncIterator<StreamChunk>
try {
const adapter = this.registration(options.provider).adapter
const stream = adapter.stream(this.forAdapter(options, adapter))
iterator = stream[Symbol.asyncIterator]()
} catch (error: unknown) {
throw markLlmAdapterFailure(error)
throw markLlmAdapterFailure(failures, error)
}
let completed = false
@@ -230,7 +234,7 @@ export class LlmService extends Service {
value = item.value
} catch (error: unknown) {
iterationFailed = true
throw markLlmAdapterFailure(error)
throw markLlmAdapterFailure(failures, error)
}
// End the adapter-owned try before yielding: consumer/middleware
// failures resumed into this generator must remain untagged.
@@ -251,13 +255,16 @@ export class LlmService extends Service {
* `options.provider`. Replay state is retained only when the same adapter
* instance owns its historical provider and the target provider. Final
* adapter selection, dispatch, and iteration failures retain their original
* Error identity and are tagged for narrow agent-loop request recovery;
* middleware failures remain untagged.
* Error identity and are tagged in a call-local scope for narrow agent-loop
* request recovery; middleware and nested-call failures remain untagged for
* the outer call.
* @param options - the full request; `options.provider` 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, () => this.adapterStream(options))
const failures: AdapterFailureScope = new WeakSet<Error>()
const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures))
return bindAdapterFailureScope(stream, failures)
}
}

View File

@@ -30,6 +30,16 @@ class RecordingAdapter extends ScriptedAdapter {
}
}
class ThrowingAdapter extends LlmAdapter {
constructor(private readonly failure: Error) {
super()
}
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw this.failure
}
}
class CatalogAdapter extends ScriptedAdapter {
constructor(
private readonly provider: LlmProviderInfo,
@@ -83,16 +93,17 @@ describe('LlmService', () => {
it('throws NO_ADAPTER for unregistered providers', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const stream = ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] })
let caught: unknown
try {
for await (const _ of ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] })) { /* drain */ }
for await (const _ of stream) { /* drain */ }
} 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)
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
})
it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => {
@@ -122,15 +133,16 @@ describe('LlmService', () => {
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { /* drain */ }
for await (const _chunk of stream) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(original)
expect(isLlmAdapterFailure(caught)).toBe(true)
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
expect(cleanupLookups).toBe(0)
})
@@ -146,15 +158,91 @@ describe('LlmService', () => {
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { /* drain */ }
for await (const _chunk of stream) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(original)
expect(isLlmAdapterFailure(caught)).toBe(true)
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
})
it('keeps a nested adapter failure scoped to the nested model call', async () => {
const original = new LlmError('nested provider failed', 'NESTED_FAILED')
const outer = new RecordingAdapter(SCRIPT)
const nested = new ThrowingAdapter(original)
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['outer'], outer)
ctx.llm.registerAdapter(['nested'], nested)
let nestedStream: AsyncIterable<StreamChunk> | undefined
ctx.on('llm/stream', (options, next) => {
if (options.provider !== 'outer') return next()
return (async function* () {
nestedStream = ctx.llm.stream({ provider: 'nested', model: 'nested', messages: [] })
yield * nestedStream
})()
})
const outerStream = ctx.llm.stream({ provider: 'outer', model: 'outer', messages: [] })
let caught: unknown
try {
for await (const _chunk of outerStream) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(original)
expect(nestedStream).toBeDefined()
expect(isLlmAdapterFailure(nestedStream!, caught)).toBe(true)
expect(isLlmAdapterFailure(outerStream, caught)).toBe(false)
expect(outer.lastOptions).toBeUndefined()
})
it('keeps call scopes distinct when middleware reuses an iterable', async () => {
const firstFailure = new LlmError('first provider failed', 'FIRST_FAILED')
const secondFailure = new LlmError('second provider failed', 'SECOND_FAILED')
const delegates: AsyncIterable<StreamChunk>[] = []
const shared: AsyncIterable<StreamChunk> = {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
const delegate = delegates.shift()
if (delegate === undefined) throw new Error('shared stream has no call delegate')
return delegate[Symbol.asyncIterator]()
},
}
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['first'], new ThrowingAdapter(firstFailure))
ctx.llm.registerAdapter(['second'], new ThrowingAdapter(secondFailure))
ctx.on('llm/stream', (_options, next) => {
delegates.push(next())
return shared
})
const firstStream = ctx.llm.stream({ provider: 'first', model: 'first', messages: [] })
const secondStream = ctx.llm.stream({ provider: 'second', model: 'second', messages: [] })
const catchFailure = async (stream: AsyncIterable<StreamChunk>): Promise<unknown> => {
try {
for await (const _chunk of stream) { /* drain */ }
} catch (error: unknown) {
return error
}
return new Error('expected adapter to fail')
}
expect(firstStream).not.toBe(secondStream)
const firstCaught = await catchFailure(firstStream)
expect(firstCaught).toBe(firstFailure)
expect(isLlmAdapterFailure(firstStream, firstCaught)).toBe(true)
expect(isLlmAdapterFailure(secondStream, firstCaught)).toBe(false)
const secondCaught = await catchFailure(secondStream)
expect(secondCaught).toBe(secondFailure)
expect(isLlmAdapterFailure(secondStream, secondCaught)).toBe(true)
expect(isLlmAdapterFailure(firstStream, secondCaught)).toBe(false)
expect(delegates).toHaveLength(0)
})
it('propagates a rejected next promptly without awaiting a non-settling return', async () => {
@@ -179,9 +267,10 @@ describe('LlmService', () => {
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
const failure = (async (): Promise<unknown> => {
try {
for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { /* drain */ }
for await (const _chunk of stream) { /* drain */ }
} catch (error: unknown) {
return error
}
@@ -195,7 +284,7 @@ describe('LlmService', () => {
if (timer !== undefined) clearTimeout(timer)
expect(caught).toBe(original)
expect(isLlmAdapterFailure(caught)).toBe(true)
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
expect(cleanupCalls).toBe(0)
})
@@ -221,15 +310,16 @@ describe('LlmService', () => {
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) break
for await (const _chunk of stream) break
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(cleanup)
expect(isLlmAdapterFailure(caught)).toBe(false)
expect(isLlmAdapterFailure(stream, caught)).toBe(false)
expect(cleanupCalls).toBe(1)
})
@@ -272,16 +362,17 @@ describe('LlmService', () => {
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], adapter)
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { /* drain */ }
for await (const _chunk of stream) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(HarnessError)
expect(caught).toMatchObject({ code: 'UNKNOWN', cause: 'plain provider failure' })
expect(isLlmAdapterFailure(caught)).toBe(true)
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
})
it('does not tag a failure thrown downstream while consuming adapter output', async () => {
@@ -290,15 +381,20 @@ describe('LlmService', () => {
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })
let caught: unknown
try {
for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) throw downstream
for await (const _chunk of stream) throw downstream
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(downstream)
expect(isLlmAdapterFailure(caught)).toBe(false)
expect(isLlmAdapterFailure(stream, caught)).toBe(false)
expect(isLlmAdapterFailure(new ScriptedAdapter(SCRIPT).stream({
provider: 'unbound', model: 'unbound', messages: [],
}), caught)).toBe(false)
expect(isLlmAdapterFailure(stream, 'consumer failed')).toBe(false)
})
it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => {