fix: normalize toolCall arguments to prevent Anthropic API rejection
Some models (e.g., via google-antigravity) omit the 'arguments' field
for tools with no required parameters. When this happens, pi-ai passes
undefined to Anthropic's 'input' field, causing API rejection with:
'messages.N.content.M.tool_use.input: Field required'
This fix adds normalizeToolCallArguments() which ensures arguments is
always an object (defaulting to {}) before messages reach the API.
Fixes tool calls for:
- memory_status
- load_memory (without trigger)
- Any tool with optional-only or no parameters
This commit is contained in:
parent
6859e1e6a6
commit
099f20e0b0
@ -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";
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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<AgentMessage, { role: "assistant" }>;
|
||||
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<AgentMessage, { role: "assistant" }>;
|
||||
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<AgentMessage, { role: "assistant" }>;
|
||||
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<AgentMessage, { role: "assistant" }>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<AgentMessage, { role: "assistant" }>;
|
||||
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<string, unknown>), arguments: {} };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
if (!contentChanged) return msg;
|
||||
changed = true;
|
||||
return { ...assistant, content: nextContent as typeof assistant.content };
|
||||
});
|
||||
|
||||
return changed ? out : messages;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user