fix(agents): normalize missing arguments on toolCall blocks
When a model generates a toolCall without the arguments/input field
(common for tools with only optional parameters), subsequent API calls
reject with "input: Field required", permanently corrupting the session.
Add normalizeToolCallArguments() to the session sanitization pipeline
to default missing arguments to {} on toolCall blocks and missing input
to {} on toolUse blocks.
Closes #4345
This commit is contained in:
parent
6af205a13a
commit
25989c1e91
@ -206,6 +206,52 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
expect(result).toEqual(messages);
|
expect(result).toEqual(messages);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes toolCall blocks missing arguments to have empty object", async () => {
|
||||||
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||||
|
|
||||||
|
const messages: AgentMessage[] = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
// Missing arguments field entirely (the bug in #4345)
|
||||||
|
{ type: "toolCall", id: "call_1", name: "session_status" } as never,
|
||||||
|
{ type: "toolCall", id: "call_2", name: "sessions_list", arguments: { limit: 10 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Include matching tool results so repair doesn't add synthetic ones
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_1",
|
||||||
|
toolName: "session_status",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
} as AgentMessage,
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_2",
|
||||||
|
toolName: "sessions_list",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
} as AgentMessage,
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await sanitizeSessionHistory({
|
||||||
|
messages,
|
||||||
|
modelApi: "anthropic-messages",
|
||||||
|
provider: "anthropic",
|
||||||
|
sessionManager: mockSessionManager,
|
||||||
|
sessionId: "test-session",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
const assistant = result[0] as {
|
||||||
|
role: string;
|
||||||
|
content: Array<{ type: string; arguments?: unknown }>;
|
||||||
|
};
|
||||||
|
// The first tool call should now have arguments: {}
|
||||||
|
expect(assistant.content[0]).toHaveProperty("arguments", {});
|
||||||
|
// The second tool call should keep its original arguments
|
||||||
|
expect(assistant.content[1]).toHaveProperty("arguments", { limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
it("downgrades openai reasoning only when the model changes", async () => {
|
it("downgrades openai reasoning only when the model changes", async () => {
|
||||||
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -336,6 +336,10 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
? sanitizeToolUseResultPairing(sanitizedThinking)
|
? sanitizeToolUseResultPairing(sanitizedThinking)
|
||||||
: sanitizedThinking;
|
: sanitizedThinking;
|
||||||
|
|
||||||
|
// Ensure every toolCall/toolUse block has an arguments/input field (default {}).
|
||||||
|
// Some models omit the field for optional-args tools, which causes API rejections.
|
||||||
|
const normalizedToolArgs = normalizeToolCallArguments(repairedTools);
|
||||||
|
|
||||||
const isOpenAIResponsesApi =
|
const isOpenAIResponsesApi =
|
||||||
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
|
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
|
||||||
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
|
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
|
||||||
@ -350,8 +354,8 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
: false;
|
: false;
|
||||||
const sanitizedOpenAI =
|
const sanitizedOpenAI =
|
||||||
isOpenAIResponsesApi && modelChanged
|
isOpenAIResponsesApi && modelChanged
|
||||||
? downgradeOpenAIReasoningBlocks(repairedTools)
|
? downgradeOpenAIReasoningBlocks(normalizedToolArgs)
|
||||||
: repairedTools;
|
: normalizedToolArgs;
|
||||||
|
|
||||||
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
|
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
|
||||||
appendModelSnapshot(params.sessionManager, {
|
appendModelSnapshot(params.sessionManager, {
|
||||||
@ -373,3 +377,46 @@ export async function sanitizeSessionHistory(params: {
|
|||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
}).messages;
|
}).messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure every toolCall/toolUse block has its arguments/input field.
|
||||||
|
* Some models omit the field entirely for tools with only optional parameters,
|
||||||
|
* which causes API rejections ("input: Field required").
|
||||||
|
*/
|
||||||
|
function normalizeToolCallArguments(messages: AgentMessage[]): AgentMessage[] {
|
||||||
|
let changed = false;
|
||||||
|
const out: AgentMessage[] = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg || typeof msg !== "object" || (msg as { role?: unknown }).role !== "assistant") {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||||
|
const content = assistant.content;
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
out.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let blockChanged = false;
|
||||||
|
const nextContent = content.map((block) => {
|
||||||
|
if (!block || typeof block !== "object") return block;
|
||||||
|
const rec = block as unknown as Record<string, unknown>;
|
||||||
|
if (rec.type === "toolCall" && rec.arguments === undefined) {
|
||||||
|
blockChanged = true;
|
||||||
|
return { ...rec, arguments: {} };
|
||||||
|
}
|
||||||
|
if (rec.type === "toolUse" && rec.input === undefined) {
|
||||||
|
blockChanged = true;
|
||||||
|
return { ...rec, input: {} };
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
});
|
||||||
|
if (blockChanged) {
|
||||||
|
changed = true;
|
||||||
|
out.push({ ...assistant, content: nextContent } as AgentMessage);
|
||||||
|
} else {
|
||||||
|
out.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? out : messages;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user