From cd2af648607866dca488483574f0f79b62489ee0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 05:16:26 +0000 Subject: [PATCH] fix: cap tool call IDs for OpenAI/OpenRouter (#875) (thanks @j1philli) --- CHANGELOG.md | 1 + src/agents/pi-embedded-helpers/google.ts | 6 ++- ...ed-runner.google-sanitize-thinking.test.ts | 32 ++++++++++++++ src/agents/pi-embedded-runner/google.ts | 13 +++++- src/agents/tool-call-id.test.ts | 44 +++++++++++++++++++ 5 files changed, 93 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b83f58b31..856c0d772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Fixes - Browser: add tests for snapshot labels/efficient query params and labeled image responses. - Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. +- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli. - Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. diff --git a/src/agents/pi-embedded-helpers/google.ts b/src/agents/pi-embedded-helpers/google.ts index c0ec87e46..acfc3ebe3 100644 --- a/src/agents/pi-embedded-helpers/google.ts +++ b/src/agents/pi-embedded-helpers/google.ts @@ -52,8 +52,9 @@ export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMe // Gemini rejects thinking blocks that lack a signature; downgrade to text for safety. let hasDowngraded = false; - const nextContent = assistantMsg.content.flatMap((block) => { - if (!block || typeof block !== "object") return [block]; + type AssistantContentBlock = (typeof assistantMsg.content)[number]; + const nextContent = assistantMsg.content.flatMap((block): AssistantContentBlock[] => { + if (!block || typeof block !== "object") return [block as AssistantContentBlock]; const record = block as GeminiThinkingBlock; if (record.type !== "thinking") return [block]; const signature = @@ -63,6 +64,7 @@ export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMe const trimmed = thinking.trim(); hasDowngraded = true; if (!trimmed) return []; +<<<<<<< HEAD return [{ type: "text" as const, text: thinking }]; }); diff --git a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts index c63a934d6..0f4aa90ff 100644 --- a/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts +++ b/src/agents/pi-embedded-runner.google-sanitize-thinking.test.ts @@ -170,4 +170,36 @@ describe("sanitizeSessionHistory (google thinking)", () => { }; expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]); }); + + it("sanitizes tool call ids for OpenAI-compatible APIs", async () => { + const sessionManager = SessionManager.inMemory(); + const longId = `call_${"a".repeat(60)}`; + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: longId, name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: longId, + toolName: "read", + content: [{ type: "text", text: "ok" }], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionHistory({ + messages: input, + modelApi: "openai-responses", + sessionManager, + sessionId: "session:openai", + }); + + const assistant = out[0] as Extract; + const toolCall = assistant.content?.[0] as { id?: string }; + expect(toolCall.id).toBeDefined(); + expect(toolCall.id?.length).toBeLessThanOrEqual(40); + + const toolResult = out[1] as Extract; + expect(toolResult.toolCallId).toBe(toolCall.id); + }); }); diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index 978edc78b..4a3a7c3f6 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -37,6 +37,17 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([ "minProperties", "maxProperties", ]); +const OPENAI_TOOL_CALL_ID_APIS = new Set([ + "openai", + "openai-completions", + "openai-responses", + "openai-codex-responses", +]); + +function shouldSanitizeToolCallIds(modelApi?: string | null): boolean { + if (!modelApi) return false; + return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi); +} function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] { if (!schema || typeof schema !== "object") return []; @@ -145,7 +156,7 @@ export async function sanitizeSessionHistory(params: { sessionId: string; }): Promise { const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", { - sanitizeToolCallIds: isGoogleModelApi(params.modelApi), + sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi), enforceToolCallLast: params.modelApi === "anthropic-messages", }); const repairedTools = sanitizeToolUseResultPairing(sanitizedImages); diff --git a/src/agents/tool-call-id.test.ts b/src/agents/tool-call-id.test.ts index 6ffe2b85e..0d9fb1341 100644 --- a/src/agents/tool-call-id.test.ts +++ b/src/agents/tool-call-id.test.ts @@ -65,4 +65,48 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => { expect(r1.toolCallId).toBe(a.id); expect(r2.toolCallId).toBe(b.id); }); + + it("caps tool call IDs at 40 chars while preserving uniqueness", () => { + const longA = `call_${"a".repeat(60)}`; + const longB = `call_${"a".repeat(59)}b`; + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: longA, name: "read", arguments: {} }, + { type: "toolCall", id: longB, name: "read", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: longA, + toolName: "read", + content: [{ type: "text", text: "one" }], + }, + { + role: "toolResult", + toolCallId: longB, + toolName: "read", + content: [{ type: "text", text: "two" }], + }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolCallIdsForCloudCodeAssist(input); + const assistant = out[0] as Extract; + const a = assistant.content?.[0] as { id?: string }; + const b = assistant.content?.[1] as { id?: string }; + + expect(typeof a.id).toBe("string"); + expect(typeof b.id).toBe("string"); + expect(a.id).not.toBe(b.id); + expect(a.id?.length).toBeLessThanOrEqual(40); + expect(b.id?.length).toBeLessThanOrEqual(40); + expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true); + expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true); + + const r1 = out[1] as Extract; + const r2 = out[2] as Extract; + expect(r1.toolCallId).toBe(a.id); + expect(r2.toolCallId).toBe(b.id); + }); });