merge PR #4720: fix(agent): retry handoff auto-only tool choice errors

This commit is contained in:
can1357
2026-07-08 15:19:35 +02:00
3 changed files with 149 additions and 13 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed handoff generation retrying with `toolChoice: "auto"` when custom OpenAI-compatible providers reject `toolChoice: "none"` with an auto-only 400. ([#4715](https://github.com/can1357/oh-my-pi/issues/4715))
## [16.3.7] - 2026-07-05
### Fixed
+27 -12
View File
@@ -698,6 +698,12 @@ function createSummarizationError(prefix: string, response: AssistantMessage): E
return response.errorStatus === undefined ? new Error(text) : new ProviderHttpError(text, response.errorStatus);
}
function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boolean {
if (response.errorStatus !== 400) return false;
const message = response.errorMessage ?? "";
return /\btool_choice\b/i.test(message) && /\bauto\b/i.test(message) && /\bsupported\b/i.test(message);
}
/**
* Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge.
@@ -917,24 +923,33 @@ export interface HandoffFromContextOptions {
* `streamOptions` that mirror the live turn's cache routing. That keeps the
* cache-preserving context construction in the host (which owns the transform
* pipeline) while this function centralizes the handoff request contract:
* `toolChoice: "none"`, clamped reasoning effort, oneshot telemetry, text-only
* extraction, and provider-error mapping.
* cache-first `toolChoice: "none"`, clamped reasoning effort, one retry for
* auto-only `tool_choice` providers, oneshot telemetry, text-only extraction,
* and provider-error mapping.
*/
export async function generateHandoffFromContext(
context: Context,
model: Model,
options: HandoffFromContextOptions,
): Promise<string> {
const response = await instrumentedCompleteSimple(
model,
context,
{
...options.streamOptions,
reasoning: resolveCompactionEffort(model, options.thinkingLevel),
toolChoice: "none",
},
{ telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl },
);
const requestOptions = {
...options.streamOptions,
reasoning: resolveCompactionEffort(model, options.thinkingLevel),
toolChoice: "none" as const,
};
let response = await instrumentedCompleteSimple(model, context, requestOptions, {
telemetry: options.telemetry,
oneshotKind: "handoff",
completeImpl: options.completeImpl,
});
if (response.stopReason === "error" && shouldRetryHandoffWithAutoToolChoice(response)) {
response = await instrumentedCompleteSimple(
model,
context,
{ ...requestOptions, toolChoice: "auto" },
{ telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl },
);
}
if (response.stopReason === "error") {
throw createSummarizationError("Handoff generation failed", response);
+118 -1
View File
@@ -9,7 +9,7 @@ import {
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core/thinking";
import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
import * as ai from "@oh-my-pi/pi-ai";
import { Effort } from "@oh-my-pi/pi-ai";
import { Effort, z } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
function createAssistantMessage(content: AssistantMessage["content"]): AssistantMessage {
@@ -32,6 +32,28 @@ function createAssistantMessage(content: AssistantMessage["content"]): Assistant
};
}
function createAssistantError(errorStatus: number, errorMessage: string): AssistantMessage {
return {
...createAssistantMessage([]),
stopReason: "error",
errorStatus,
errorMessage,
};
}
const handoffToolSchema = z.object({ note: z.string().optional() });
function createHandoffTool(): AgentTool<typeof handoffToolSchema> {
return {
name: "handoff_probe",
label: "Handoff Probe",
description: "Confirms handoff requests keep live tools available.",
parameters: handoffToolSchema,
intent: "omit",
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
};
}
function getTestModel(): Model {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) {
@@ -155,4 +177,99 @@ describe("handoff helpers", () => {
reasoning: Effort.Medium,
});
});
test("generateHandoffFromContext retries auto-only tool_choice rejection with live tools", async () => {
const completeSimpleSpy = vi
.spyOn(ai, "completeSimple")
.mockResolvedValueOnce(
createAssistantError(
400,
"400 Bad Request: Only a tool_choice of 'auto' is supported for this model; param=tool_choice",
),
)
.mockResolvedValueOnce(createAssistantMessage([{ type: "text", text: "## Goal\nRecovered on retry" }]));
const model = getTestModel();
const tools = [createHandoffTool()];
const context = {
systemPrompt: ["Live system prompt"],
tools,
messages: [{ role: "user" as const, content: "prepare handoff", timestamp: 1 }],
};
const document = await generateHandoffFromContext(context, model, {
streamOptions: {
apiKey: "test-key",
sessionId: "sess-auto-only:side:42",
promptCacheKey: "sess-auto-only",
},
thinkingLevel: ThinkingLevel.Medium,
});
expect(document).toBe("## Goal\nRecovered on retry");
expect(completeSimpleSpy).toHaveBeenCalledTimes(2);
const firstCall = completeSimpleSpy.mock.calls[0];
const secondCall = completeSimpleSpy.mock.calls[1];
if (!firstCall) throw new Error("Expected initial completeSimple call");
if (!secondCall) throw new Error("Expected retry completeSimple call");
const [firstModel, firstContext, firstOptions] = firstCall;
const [secondModel, secondContext, secondOptions] = secondCall;
expect(firstModel).toBe(model);
expect(secondModel).toBe(model);
expect(firstContext).toBe(context);
expect(secondContext).toBe(context);
expect(firstContext.tools).toBe(tools);
expect(secondContext.tools).toBe(tools);
expect(firstOptions).toMatchObject({
apiKey: "test-key",
sessionId: "sess-auto-only:side:42",
promptCacheKey: "sess-auto-only",
toolChoice: "none",
reasoning: Effort.Medium,
});
expect(secondOptions).toMatchObject({
apiKey: "test-key",
sessionId: "sess-auto-only:side:42",
promptCacheKey: "sess-auto-only",
toolChoice: "auto",
reasoning: Effort.Medium,
});
});
test("generateHandoffFromContext surfaces unrelated provider 400 without retrying", async () => {
const completeSimpleSpy = vi
.spyOn(ai, "completeSimple")
.mockResolvedValueOnce(createAssistantError(400, "400 Bad Request: unsupported max_tokens; param=max_tokens"));
const model = getTestModel();
const tools = [createHandoffTool()];
const context = {
systemPrompt: ["Live system prompt"],
tools,
messages: [{ role: "user" as const, content: "prepare handoff", timestamp: 1 }],
};
const error = await generateHandoffFromContext(context, model, {
streamOptions: {
apiKey: "test-key",
sessionId: "sess-unrelated-400:side:42",
promptCacheKey: "sess-unrelated-400",
},
thinkingLevel: ThinkingLevel.Medium,
}).catch((caught: unknown) => caught);
if (!(error instanceof Error)) throw new Error("Expected handoff generation to reject");
expect(error.message).toContain("unsupported max_tokens");
expect(completeSimpleSpy).toHaveBeenCalledTimes(1);
const call = completeSimpleSpy.mock.calls[0];
if (!call) throw new Error("Expected completeSimple call");
const [, calledContext, options] = call;
expect(calledContext).toBe(context);
expect(calledContext.tools).toBe(tools);
expect(options).toMatchObject({
apiKey: "test-key",
sessionId: "sess-unrelated-400:side:42",
promptCacheKey: "sess-unrelated-400",
toolChoice: "none",
reasoning: Effort.Medium,
});
});
});