diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 6f6bb474f..021ae8ebb 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -53,4 +53,8 @@ export { export type { EmbeddedContextFile, FailoverReason } from "./pi-embedded-helpers/types.js"; export type { ToolCallIdMode } from "./tool-call-id.js"; -export { isValidCloudCodeAssistToolId, sanitizeToolCallId } from "./tool-call-id.js"; +export { + isValidCloudCodeAssistToolId, + sanitizeToolCallId, + normalizeToolCallArguments, +} from "./tool-call-id.js"; diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index 7b26d0d04..7f486f7ed 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -9,6 +9,7 @@ import { downgradeOpenAIReasoningBlocks, isCompactionFailureError, isGoogleModelApi, + normalizeToolCallArguments, sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, } from "../pi-embedded-helpers.js"; @@ -336,6 +337,11 @@ export async function sanitizeSessionHistory(params: { ? sanitizeToolUseResultPairing(sanitizedThinking) : sanitizedThinking; + // Normalize toolCall arguments to ensure they're always an object. + // Some models omit `arguments` for tools with no required params, + // but Anthropic API requires `input` to be present. + const normalizedArgs = normalizeToolCallArguments(repairedTools); + const isOpenAIResponsesApi = params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses"; const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId); @@ -350,8 +356,8 @@ export async function sanitizeSessionHistory(params: { : false; const sanitizedOpenAI = isOpenAIResponsesApi && modelChanged - ? downgradeOpenAIReasoningBlocks(repairedTools) - : repairedTools; + ? downgradeOpenAIReasoningBlocks(normalizedArgs) + : normalizedArgs; if (hasSnapshot && (!priorSnapshot || modelChanged)) { appendModelSnapshot(params.sessionManager, { diff --git a/src/agents/tool-call-id.test.ts b/src/agents/tool-call-id.test.ts index 5ce554e42..cad198209 100644 --- a/src/agents/tool-call-id.test.ts +++ b/src/agents/tool-call-id.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { isValidCloudCodeAssistToolId, + normalizeToolCallArguments, sanitizeToolCallIdsForCloudCodeAssist, } from "./tool-call-id.js"; @@ -266,3 +267,109 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => { }); }); }); + +describe("normalizeToolCallArguments", () => { + it("is a no-op when arguments are already present", () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call1", name: "read", arguments: { path: "/tmp" } }], + }, + ] satisfies AgentMessage[]; + + const out = normalizeToolCallArguments(input); + expect(out).toBe(input); + }); + + it("normalizes undefined arguments to empty object", () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call1", name: "memory_status" }], + }, + ] as AgentMessage[]; + + const out = normalizeToolCallArguments(input); + expect(out).not.toBe(input); + + const assistant = out[0] as Extract; + const toolCall = assistant.content?.[0] as { arguments?: unknown }; + expect(toolCall.arguments).toEqual({}); + }); + + it("normalizes null arguments to empty object", () => { + const input = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call1", name: "memory_status", arguments: null }], + }, + ] as AgentMessage[]; + + const out = normalizeToolCallArguments(input); + expect(out).not.toBe(input); + + const assistant = out[0] as Extract; + const toolCall = assistant.content?.[0] as { arguments?: unknown }; + expect(toolCall.arguments).toEqual({}); + }); + + it("handles multiple toolCalls in single message", () => { + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call1", name: "memory_status" }, + { type: "toolCall", id: "call2", name: "read", arguments: { path: "/tmp" } }, + { type: "toolCall", id: "call3", name: "load_memory" }, + ], + }, + ] as AgentMessage[]; + + const out = normalizeToolCallArguments(input); + expect(out).not.toBe(input); + + const assistant = out[0] as Extract; + const call1 = assistant.content?.[0] as { arguments?: unknown }; + const call2 = assistant.content?.[1] as { arguments?: unknown }; + const call3 = assistant.content?.[2] as { arguments?: unknown }; + expect(call1.arguments).toEqual({}); + expect(call2.arguments).toEqual({ path: "/tmp" }); + expect(call3.arguments).toEqual({}); + }); + + it("handles toolUse and functionCall types", () => { + const input = [ + { + role: "assistant", + content: [ + { type: "toolUse", id: "call1", name: "status" }, + { type: "functionCall", id: "call2", name: "ping" }, + ], + }, + ] as AgentMessage[]; + + const out = normalizeToolCallArguments(input); + expect(out).not.toBe(input); + + const assistant = out[0] as Extract; + const call1 = assistant.content?.[0] as { arguments?: unknown }; + const call2 = assistant.content?.[1] as { arguments?: unknown }; + expect(call1.arguments).toEqual({}); + expect(call2.arguments).toEqual({}); + }); + + it("does not modify user or toolResult messages", () => { + const input = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "toolResult", + toolCallId: "call1", + toolName: "read", + content: [{ type: "text", text: "ok" }], + }, + ] satisfies AgentMessage[]; + + const out = normalizeToolCallArguments(input); + expect(out).toBe(input); + }); +}); diff --git a/src/agents/tool-call-id.ts b/src/agents/tool-call-id.ts index 8b7e05417..179427426 100644 --- a/src/agents/tool-call-id.ts +++ b/src/agents/tool-call-id.ts @@ -186,3 +186,43 @@ export function sanitizeToolCallIdsForCloudCodeAssist( return changed ? out : messages; } + +/** + * Normalize toolCall arguments to ensure they are always an object. + * Some models (e.g., via google-antigravity) may omit the `arguments` field + * for tools with no required parameters. Anthropic API requires `input` to be present. + */ +export function normalizeToolCallArguments(messages: AgentMessage[]): AgentMessage[] { + let changed = false; + const out = messages.map((msg) => { + if (!msg || typeof msg !== "object") return msg; + const role = (msg as { role?: unknown }).role; + if (role !== "assistant") return msg; + + const assistant = msg as Extract; + const content = assistant.content; + if (!Array.isArray(content)) return msg; + + let contentChanged = false; + const nextContent = content.map((block) => { + if (!block || typeof block !== "object") return block; + const rec = block as { type?: unknown; arguments?: unknown }; + const type = rec.type; + if (type !== "functionCall" && type !== "toolUse" && type !== "toolCall") { + return block; + } + // Normalize missing or undefined arguments to empty object + if (rec.arguments === undefined || rec.arguments === null) { + contentChanged = true; + return { ...(block as unknown as Record), arguments: {} }; + } + return block; + }); + + if (!contentChanged) return msg; + changed = true; + return { ...assistant, content: nextContent as typeof assistant.content }; + }); + + return changed ? out : messages; +}