Compare commits

...
Author SHA1 Message Date
John Choi e154c7f863 fix: retry transient empty model responses 2026-06-29 18:27:11 -07:00
6 changed files with 118 additions and 11 deletions
+55 -1
View File
@@ -94,7 +94,7 @@ describe("AgentRuntime", () => {
const model = new ScriptedModel([
() => [{ type: "finish", reason: "stop" }],
]);
const runtime = new AgentRuntime({ model });
const runtime = new AgentRuntime({ model, modelEmptyResponseRetries: 0 });
const result = await runtime.run("Hi");
@@ -104,6 +104,60 @@ describe("AgentRuntime", () => {
expect(result.messages[0]?.role).toBe("user");
});
it("retries an empty model response once by default without persisting it", async () => {
const model = new ScriptedModel([
() => [{ type: "finish", reason: "stop" }],
() => [
{ type: "text-delta", text: "recovered" },
{ type: "finish", reason: "stop" },
],
]);
const notices: string[] = [];
const addedMessages: AgentMessage[] = [];
const runtime = new AgentRuntime({ model });
runtime.subscribe((event) => {
if (event.type === "status-notice") notices.push(event.message);
if (event.type === "message-added") addedMessages.push(event.message);
});
const result = await runtime.run("Hi");
expect(result.status).toBe("completed");
expect(result.outputText).toBe("recovered");
expect(model.requests).toHaveLength(2);
expect(result.messages.map((message) => message.role)).toEqual([
"user",
"assistant",
]);
expect(addedMessages.map((message) => message.role)).toEqual([
"user",
"assistant",
]);
expect(JSON.stringify(result.messages)).not.toContain(
"ERROR: EMPTY CONTENT",
);
expect(notices).toEqual(["Model returned empty response; retrying (1/1)"]);
});
it("fails after the empty-response retry budget without persisting an assistant", async () => {
const model = new ScriptedModel([
() => [{ type: "finish", reason: "stop" }],
() => [{ type: "finish", reason: "stop" }],
]);
const runtime = new AgentRuntime({ model });
const result = await runtime.run("Hi");
expect(result.status).toBe("failed");
expect(result.error?.message).toBe("Model returned empty response");
expect(model.requests).toHaveLength(2);
expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.role).toBe("user");
expect(JSON.stringify(result.messages)).not.toContain(
"ERROR: EMPTY CONTENT",
);
});
it("executes a tool call and continues the loop", async () => {
const model = new ScriptedModel([
() => [
+50 -10
View File
@@ -43,6 +43,8 @@ function createUID(prefix: string, length = 8): string {
return `${prefix}_${nanoid(length)}`;
}
const DEFAULT_MODEL_EMPTY_RESPONSE_RETRIES = 1;
export type AgentRunInput = string | AgentMessage | readonly AgentMessage[];
export type AgentEventListener = (event: AgentRuntimeEvent) => void;
@@ -372,6 +374,16 @@ function textFromToolMessage(message: AgentMessage | undefined): string {
}
}
function resolveModelEmptyResponseRetries(value: unknown): number {
if (value === undefined) {
return DEFAULT_MODEL_EMPTY_RESPONSE_RETRIES;
}
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return 0;
}
return Math.floor(value);
}
function normalizeInput(input: AgentRunInput): AgentMessage[] {
if (typeof input === "string") {
return [createMessage("user", [{ type: "text", text: input }])];
@@ -614,16 +626,44 @@ export class AgentRuntime {
iteration: this.state.iteration,
});
const { message, finishReason } = await this.generateAssistantMessage();
if (finishReason === "aborted") {
throw this.normalizeAbortError();
}
if (message.content.length === 0) {
throw new Error(
finishReason === "error"
? (this.state.lastError ?? "Model stream failed")
: "Model returned empty response",
);
const maxEmptyResponseRetries = resolveModelEmptyResponseRetries(
this.config.modelEmptyResponseRetries,
);
let emptyResponseRetries = 0;
let message: AgentMessage;
let finishReason: AgentModelFinishReason;
while (true) {
({ message, finishReason } = await this.generateAssistantMessage());
if (finishReason === "aborted") {
throw this.normalizeAbortError();
}
if (message.content.length > 0) {
break;
}
if (finishReason === "error") {
throw new Error(this.state.lastError ?? "Model stream failed");
}
if (emptyResponseRetries >= maxEmptyResponseRetries) {
throw new Error("Model returned empty response");
}
emptyResponseRetries += 1;
this.config.logger?.log("Model returned empty response; retrying", {
severity: "warn",
iteration: this.state.iteration,
retry: emptyResponseRetries,
maxRetries: maxEmptyResponseRetries,
});
await this.emit({
type: "status-notice",
snapshot: this.snapshot(),
message: `Model returned empty response; retrying (${emptyResponseRetries}/${maxEmptyResponseRetries})`,
metadata: {
reason: "empty_model_response_retry",
iteration: this.state.iteration,
retry: emptyResponseRetries,
maxRetries: maxEmptyResponseRetries,
},
});
}
const toolCalls = message.content.filter(
(part: AgentMessagePart): part is AgentToolCallPart =>
@@ -135,6 +135,7 @@ describe("createAgentRuntimeConfig", () => {
thinking: true,
reasoningEffort: "high",
maxIterations: 7,
modelEmptyResponseRetries: 2,
maxParallelToolCalls: 4,
completionPolicy: { requireCompletionTool: true },
toolPolicies: { "*": { autoApprove: false } },
@@ -175,6 +176,7 @@ describe("createAgentRuntimeConfig", () => {
});
expect(runtimeConfig.tools).toBe(tools);
expect(runtimeConfig.maxIterations).toBe(7);
expect(runtimeConfig.modelEmptyResponseRetries).toBe(2);
expect(runtimeConfig.toolExecution).toBe("parallel");
expect(runtimeConfig.completionPolicy).toEqual({
requireCompletionTool: true,
@@ -110,6 +110,7 @@ export function createAgentRuntimeConfig(
initialMessages: input.initialMessages,
completionPolicy: agentConfig.completionPolicy,
maxIterations: agentConfig.maxIterations,
modelEmptyResponseRetries: agentConfig.modelEmptyResponseRetries,
toolExecution,
toolPolicies: agentConfig.toolPolicies,
toolContextMetadata: input.toolContextMetadata,
+5
View File
@@ -427,6 +427,11 @@ export interface AgentRuntimeConfig {
telemetry?: ITelemetryService;
initialMessages?: readonly AgentMessage[];
maxIterations?: number;
/**
* Number of times to retry a model call that finishes successfully but
* produces no assistant content. Defaults to 1 in AgentRuntime.
*/
modelEmptyResponseRetries?: number;
completionPolicy?: {
requireCompletionTool?: boolean;
completionGuard?: () => string | undefined;
+5
View File
@@ -702,6 +702,11 @@ export interface AgentConfig {
* If undefined, no iteration cap is enforced.
*/
maxIterations?: number;
/**
* Number of times to retry a model call that finishes successfully but
* produces no assistant content. Defaults to 1 in AgentRuntime.
*/
modelEmptyResponseRetries?: number;
/**
* Maximum number of tool calls to execute concurrently in a single iteration.
* @default 8