refactor(agent-loop): return request failures from steps

This commit is contained in:
_Kerman
2026-07-24 21:27:02 +08:00
parent b73eb7663c
commit d92ce62e57

View File

@@ -23,7 +23,7 @@ import type {
SendOptions,
} from '@deepseek-ai/dsh-agent'
import {
BlockAssembler, LlmError, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type {
ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message,
@@ -34,16 +34,10 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
/** A final-adapter or terminal in-band failure eligible for request recovery. */
class ModelRequestFailure extends Error {
constructor(
readonly requestError: RequestError,
readonly failure: LlmFailure,
) {
super(requestError.message, { cause: requestError })
}
}
/** One completed step or a final-adapter failure eligible for recovery. */
type StepOutcome =
| { kind: 'completed'; continueTurn: boolean; maxTokens: boolean }
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure }
/**
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
@@ -245,45 +239,59 @@ export class ReactLoopAgent extends Agent {
this.drainOutbox(turn)
while (true) {
steps: while (true) {
step += 1
const { continueTurn, maxTokens } = await this.step(turn, step, signal)
if (maxTokens) reason = { kind: 'max-tokens' }
if (continueTurn || this.outbox.some(item => 'id' in item)) continue
const outcome = await this.step(turn, step, signal)
switch (outcome.kind) {
case 'completed':
if (outcome.maxTokens) reason = { kind: 'max-tokens' }
if (outcome.continueTurn || this.outbox.some(item => 'id' in item)) continue
break
case 'request-failed': {
if (this.stepOpen) {
this.stepOpen = false
this.session.append('step/end', { turn, step })
}
if (agentInterruptReasonOf(signal) === undefined) {
const retryWindow = { requested: false }
this.retryWindow = retryWindow
let recoveryCompleted = false
try {
await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error,
outcome.failure, signal,
() => Promise.resolve(),
)
recoveryCompleted = true
} catch (recoveryError: unknown) {
this.loopCtx.logger.warn(
`agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
} finally {
if (this.retryWindow === retryWindow) this.retryWindow = undefined
}
retry = recoveryCompleted
&& agentInterruptReasonOf(signal) === undefined
&& retryWindow.requested
}
const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure)
reason = settlement.reason
idle = settlement.idle
break steps
}
default:
assertNever(outcome)
}
await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal)
signal.throwIfAborted()
if (!this.drainOutbox(turn)) break
}
} catch (caught: unknown) {
const requestFailure = caught instanceof ModelRequestFailure ? caught : undefined
const error = requestFailure?.requestError ?? caught
if (this.stepOpen) {
this.stepOpen = false
this.session.append('step/end', { turn, step })
}
if (requestFailure !== undefined && agentInterruptReasonOf(signal) === undefined) {
const retryWindow = { requested: false }
this.retryWindow = retryWindow
let recoveryCompleted = false
try {
await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request-error', this, turn, step, requestFailure.requestError,
requestFailure.failure, signal,
() => Promise.resolve(),
)
recoveryCompleted = true
} catch (recoveryError: unknown) {
this.loopCtx.logger.warn(
`agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
} finally {
if (this.retryWindow === retryWindow) this.retryWindow = undefined
}
retry = recoveryCompleted
&& agentInterruptReasonOf(signal) === undefined
&& retryWindow.requested
}
({ reason, idle } = this.settle(turn, step, error, signal, requestFailure?.failure))
({ reason, idle } = this.settle(turn, step, caught, signal))
} finally {
try {
if (this.stepOpen) {
@@ -321,7 +329,7 @@ export class ReactLoopAgent extends Agent {
turn: number,
step: number,
signal: AbortSignal,
): Promise<{ continueTurn: boolean; maxTokens: boolean }> {
): Promise<StepOutcome> {
const { session } = this
// The single between-steps seam: listeners inject, steer, or edit the log
@@ -361,7 +369,7 @@ export class ReactLoopAgent extends Agent {
} catch (error: unknown) {
const facts = llmFailureOf(stream, error)
if (facts !== undefined && error instanceof Error) {
throw new ModelRequestFailure(error, facts)
return { kind: 'request-failed', error, failure: facts }
}
throw error
}
@@ -371,7 +379,7 @@ export class ReactLoopAgent extends Agent {
const finish = assembler.finish
if (finish.kind === 'error' || finish.kind === 'aborted') {
const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure)
throw new ModelRequestFailure(error, finish.failure)
return { kind: 'request-failed', error, failure: finish.failure }
}
// Truncated (max-tokens) output cannot owe tool calls.
@@ -411,6 +419,7 @@ export class ReactLoopAgent extends Agent {
session.append('step/end', { turn, step })
this.stepOpen = false
return {
kind: 'completed',
continueTurn: (toolCalls.length > 0 && !concluded) || steered,
maxTokens: finish.kind === 'max-tokens',
}