merge PR #4720: fix(agent): retry handoff auto-only tool choice errors
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user