diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 1de3328af..89442e470 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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 diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 9f502bac5..6459dc829 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -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 { - 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); diff --git a/packages/agent/test/handoff.test.ts b/packages/agent/test/handoff.test.ts index a3bdc293c..cf89f4344 100644 --- a/packages/agent/test/handoff.test.ts +++ b/packages/agent/test/handoff.test.ts @@ -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 { + 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, + }); + }); });