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:
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user