From bb72ccebf79ba6af42287cc0be53072163f2d4bb Mon Sep 17 00:00:00 2001 From: Omer Date: Wed, 3 Jun 2026 11:10:24 -0700 Subject: [PATCH 1/2] fix(core): sanitize tool inputs before replay --- .changeset/clean-tools-sip.md | 5 + examples/base/package.json | 1 + examples/base/src/repro-1336.ts | 223 ++++++++++++++++++ .../core/src/agent/message-normalizer.spec.ts | 27 ++- packages/core/src/agent/message-normalizer.ts | 5 +- .../core/src/utils/message-converter.spec.ts | 68 ++++++ packages/core/src/utils/message-converter.ts | 5 +- packages/core/src/utils/tool-input.spec.ts | 25 ++ packages/core/src/utils/tool-input.ts | 34 +++ 9 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 .changeset/clean-tools-sip.md create mode 100644 examples/base/src/repro-1336.ts create mode 100644 packages/core/src/utils/tool-input.spec.ts create mode 100644 packages/core/src/utils/tool-input.ts diff --git a/.changeset/clean-tools-sip.md b/.changeset/clean-tools-sip.md new file mode 100644 index 000000000..d2edef180 --- /dev/null +++ b/.changeset/clean-tools-sip.md @@ -0,0 +1,5 @@ +--- +"@voltagent/core": patch +--- + +Sanitize tool call inputs before model replay so malformed or non-object values cannot break provider history conversion. diff --git a/examples/base/package.json b/examples/base/package.json index ee8c94718..efcc68ba1 100644 --- a/examples/base/package.json +++ b/examples/base/package.json @@ -31,6 +31,7 @@ "scripts": { "build": "tsc", "dev": "tsx watch --env-file=.env ./src", + "repro:1336": "tsx --env-file=.env ./src/repro-1336.ts", "start": "node dist/index.js", "volt": "volt" }, diff --git a/examples/base/src/repro-1336.ts b/examples/base/src/repro-1336.ts new file mode 100644 index 000000000..4345b7833 --- /dev/null +++ b/examples/base/src/repro-1336.ts @@ -0,0 +1,223 @@ +import { Agent, Memory, createTool } from "@voltagent/core"; +import { LibSQLMemoryAdapter } from "@voltagent/libsql"; +import { type LanguageModel, type ModelMessage, type UIMessage, convertToModelMessages } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { z } from "zod"; + +const userId = "issue-1336-user"; +const conversationId = "issue-1336-conversation"; +const toolCallId = "toolu_issue_1336"; +const toolName = "createImageFromText"; + +const malformedToolInput = + '{"prompts":["Full body portrait of a 5\'7" woman"],"imageSize":"1024x1536"}'; + +const storage = new LibSQLMemoryAdapter({ + url: ":memory:", +}); + +const memory = new Memory({ + storage, +}); + +type ReplayedToolCall = { + type: "tool-call"; + input?: unknown; +}; + +function describeInputType(input: unknown) { + return Array.isArray(input) ? "array" : typeof input; +} + +function findReplayedToolCall(modelMessages: ModelMessage[]) { + return modelMessages + .flatMap((message) => { + const content = (message as { content?: unknown }).content; + return Array.isArray(content) ? content : []; + }) + .find( + (part): part is ReplayedToolCall => + typeof part === "object" && + part !== null && + (part as { type?: unknown }).type === "tool-call", + ); +} + +function createImageFromTextTool() { + return createTool({ + name: toolName, + description: "Create images from text prompts.", + parameters: z.object({ + prompts: z.array(z.string()), + imageSize: z.string(), + }), + execute: async ({ prompts, imageSize }) => ({ prompts, imageSize }), + }); +} + +function createMockModel(): LanguageModel { + return new MockLanguageModelV3({ + modelId: "issue-1336-mock-model", + doGenerate: { + content: [{ type: "text", text: "Mock response" }], + finishReason: "stop" as const, + usage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: { + noCacheTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + outputTokenDetails: { + textTokens: 0, + reasoningTokens: 0, + }, + }, + warnings: [], + } as any, + }) as unknown as LanguageModel; +} + +const messages: UIMessage[] = [ + { + id: "issue-1336-user-message", + role: "user", + parts: [{ type: "text", text: "Create the image." }], + }, + { + id: "issue-1336-assistant-message", + role: "assistant", + parts: [ + { + type: `tool-${toolName}`, + toolCallId, + state: "output-available", + input: malformedToolInput, + output: { + type: "error-text", + value: + "Invalid input for tool createImageFromText: JSON parsing failed: Expected ',' or ']' after array element", + }, + providerExecuted: false, + } as UIMessage["parts"][number], + ], + }, +]; + +function findToolPart(uiMessages: UIMessage[]) { + return uiMessages + .flatMap((message) => message.parts) + .find((part) => part.type === `tool-${toolName}`) as + | (UIMessage["parts"][number] & { input?: unknown }) + | undefined; +} + +async function seedMemory() { + await memory.createConversation({ + id: conversationId, + resourceId: "issue-1336-repro", + userId, + title: "Issue 1336 repro", + metadata: { issue: 1336 }, + }); + + await memory.addMessages(messages, userId, conversationId); +} + +async function inspectReplay() { + const storedMessages = await memory.getMessages(userId, conversationId); + const storedToolPart = findToolPart(storedMessages); + + console.log("Stored UI tool input type:", describeInputType(storedToolPart?.input)); + console.dir(storedToolPart, { depth: null }); + + const modelMessages = await convertToModelMessages(storedMessages); + const replayedToolCall = findReplayedToolCall(modelMessages); + + console.log( + "Direct AI SDK replayed model tool input type:", + describeInputType(replayedToolCall?.input), + ); + console.dir(replayedToolCall, { depth: null }); +} + +async function inspectVoltAgentReplay() { + let preparedModelMessages: ModelMessage[] = []; + const agent = new Agent({ + name: "Issue 1336 Mock Replay Agent", + instructions: "Continue the conversation briefly.", + model: createMockModel(), + tools: [createImageFromTextTool()], + memory, + hooks: { + onPrepareModelMessages: async ({ modelMessages }) => { + preparedModelMessages = modelMessages; + return {}; + }, + }, + }); + + await agent.generateText("continue", { + memory: { + userId, + conversationId, + options: { + readOnly: true, + }, + }, + }); + + const replayedToolCall = findReplayedToolCall(preparedModelMessages); + console.log( + "VoltAgent sanitized model tool input type:", + describeInputType(replayedToolCall?.input), + ); + console.dir(replayedToolCall, { depth: null }); +} + +async function replayAgainstAnthropic() { + if (!process.env.ANTHROPIC_API_KEY) { + console.log("Set ANTHROPIC_API_KEY to replay this seeded history against Anthropic."); + return; + } + + const agent = new Agent({ + name: "Issue 1336 Repro Agent", + instructions: "Continue the conversation briefly.", + model: process.env.REPRO_1336_MODEL ?? "anthropic/claude-opus-4-1", + tools: [createImageFromTextTool()], + memory, + }); + + try { + await agent.generateText("continue", { + memory: { + userId, + conversationId, + options: { + readOnly: true, + }, + }, + }); + console.log( + "Provider accepted the replayed history; the bug may be fixed or model behavior changed.", + ); + } catch (error) { + console.error("Provider replay failed:"); + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} + +try { + await seedMemory(); + await inspectReplay(); + await inspectVoltAgentReplay(); + await replayAgainstAnthropic(); +} finally { + await storage.close(); +} + +process.exit(process.exitCode ?? 0); diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 67c1a2c7c..25c46587e 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -1,4 +1,4 @@ -import type { UIMessage } from "ai"; +import { type UIMessage, convertToModelMessages } from "ai"; import { describe, expect, it } from "vitest"; import { sanitizeMessageForModel, sanitizeMessagesForModel } from "./message-normalizer"; @@ -93,6 +93,31 @@ describe("message-normalizer", () => { }); }); + it("sanitizes legacy non-object tool inputs before model replay", async () => { + const malformedInput = `{"prompts":["Full body portrait of a 5'7" woman"]}`; + const message = baseMessage([ + { + type: "tool-createImageFromText", + toolCallId: "call-malformed", + state: "output-available", + input: malformedInput, + output: { error: "invalid input" }, + } as any, + ]); + + const sanitized = sanitizeMessagesForModel([message], { filterIncompleteToolCalls: false }); + + expect(sanitized).toHaveLength(1); + const toolPart = sanitized[0].parts[0] as any; + expect(toolPart.input).toEqual({}); + expect((message.parts[0] as any).input).toBe(malformedInput); + + const modelMessages = await convertToModelMessages(sanitized); + const assistantMessage = modelMessages.find((item) => item.role === "assistant"); + const toolCall = (assistantMessage?.content as any[]).find((part) => part.type === "tool-call"); + expect(toolCall.input).toEqual({}); + }); + it("preserves provider metadata on text parts", () => { const message = baseMessage([ { diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index ea163abf8..d491823f7 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -1,6 +1,7 @@ import { safeStringify } from "@voltagent/internal"; import type { UIMessage, UIMessagePart } from "ai"; +import { normalizeToolInputForModel } from "../utils/tool-input"; import { hasOpenAIItemIdForPart as hasOpenAIItemIdForPartBase, isObject, @@ -329,7 +330,9 @@ const normalizeToolPart = (part: ToolLikePart): UIMessagePart | null = if (part.toolCallId) normalized.toolCallId = part.toolCallId; if (part.state) normalized.state = part.state; - if (part.input !== undefined) normalized.input = safeClone(part.input); + if (part.input !== undefined || isToolInputState(part.state) || isToolOutputState(part.state)) { + normalized.input = safeClone(normalizeToolInputForModel(part.input)); + } if (part.output !== undefined) { normalized.output = safeClone(normalizeToolOutputPayload(part.output)); } diff --git a/packages/core/src/utils/message-converter.spec.ts b/packages/core/src/utils/message-converter.spec.ts index bec3d196a..c878f06f0 100644 --- a/packages/core/src/utils/message-converter.spec.ts +++ b/packages/core/src/utils/message-converter.spec.ts @@ -142,6 +142,49 @@ describe("convertResponseMessagesToUIMessages", () => { }); }); + it("sanitizes non-object tool call inputs when converting response messages", async () => { + const malformedInput = `{"prompts":["Full body portrait of a 5'7" woman"]}`; + const messages: (AssistantModelMessage | ToolModelMessage)[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-malformed", + toolName: "createImageFromText", + input: malformedInput, + } as any, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-malformed", + toolName: "createImageFromText", + output: { error: "invalid input" }, + }, + ], + }, + ]; + + const result = await convertResponseMessagesToUIMessages(messages); + const toolPart = result[0].parts[0] as any; + + expect(toolPart).toMatchObject({ + type: "tool-createImageFromText", + toolCallId: "call-malformed", + state: "output-available", + input: {}, + }); + + const modelMessages = await convertToModelMessages(result); + const assistantMessage = modelMessages.find((message) => message.role === "assistant"); + const toolCall = (assistantMessage?.content as any[]).find((part) => part.type === "tool-call"); + expect(toolCall.input).toEqual({}); + }); + it("should map tool approval requests to tool parts", async () => { const messages: AssistantModelMessage[] = [ { @@ -708,6 +751,31 @@ describe("convertModelMessagesToUIMessages (AI SDK v5)", () => { }); }); + it("sanitizes non-object tool call inputs when converting model messages", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-string-input", + toolName: "createImageFromText", + input: `{"prompts":["Full body portrait of a 5'7" woman"]}`, + } as any, + ], + }, + ]; + + const ui = convertModelMessagesToUIMessages(messages); + expect(ui).toHaveLength(1); + expect(ui[0].parts[0]).toEqual({ + type: "tool-createImageFromText", + toolCallId: "call-string-input", + state: "input-available", + input: {}, + }); + }); + it("applies tool approval responses to existing tool parts", () => { const messages: ModelMessage[] = [ { diff --git a/packages/core/src/utils/message-converter.ts b/packages/core/src/utils/message-converter.ts index bef9f3f68..67790ee2c 100644 --- a/packages/core/src/utils/message-converter.ts +++ b/packages/core/src/utils/message-converter.ts @@ -6,6 +6,7 @@ import type { AssistantModelMessage, ModelMessage, ToolModelMessage } from "@ai- import type { FileUIPart, ReasoningUIPart, TextUIPart, ToolUIPart, UIMessage } from "ai"; import { bytesToBase64 } from "./base64"; import { randomUUID } from "./id"; +import { normalizeToolInputForModel } from "./tool-input"; const hasOpenAIReasoningProviderOptions = (providerOptions: unknown): boolean => { if (!providerOptions || typeof providerOptions !== "object") { @@ -112,7 +113,7 @@ export async function convertResponseMessagesToUIMessages( type: `tool-${contentPart.toolName}` as const, toolCallId: contentPart.toolCallId, state: "input-available" as const, - input: contentPart.input || {}, + input: normalizeToolInputForModel(contentPart.input), ...(contentPart.providerOptions ? { callProviderMetadata: contentPart.providerOptions } : {}), @@ -480,7 +481,7 @@ export function convertModelMessagesToUIMessages(messages: ModelMessage[]): UIMe type: `tool-${contentPart.toolName}` as const, toolCallId: contentPart.toolCallId, state: "input-available" as const, - input: contentPart.input || {}, + input: normalizeToolInputForModel(contentPart.input), ...(contentPart.providerOptions ? { callProviderMetadata: contentPart.providerOptions as any } : {}), diff --git a/packages/core/src/utils/tool-input.spec.ts b/packages/core/src/utils/tool-input.spec.ts new file mode 100644 index 000000000..431eb3c21 --- /dev/null +++ b/packages/core/src/utils/tool-input.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeToolInputForModel } from "./tool-input"; + +describe("normalizeToolInputForModel", () => { + it("keeps plain object tool inputs", () => { + const input = { query: "weather" }; + + expect(normalizeToolInputForModel(input)).toBe(input); + }); + + it("parses stringified JSON object tool inputs", () => { + expect(normalizeToolInputForModel('{"query":"weather"}')).toEqual({ query: "weather" }); + }); + + it("falls back to an empty object for malformed JSON strings", () => { + expect(normalizeToolInputForModel(`{"query":"5'7" woman"}`)).toEqual({}); + }); + + it("falls back to an empty object for non-dictionary values", () => { + expect(normalizeToolInputForModel(["weather"])).toEqual({}); + expect(normalizeToolInputForModel("[1,2,3]")).toEqual({}); + expect(normalizeToolInputForModel(null)).toEqual({}); + }); +}); diff --git a/packages/core/src/utils/tool-input.ts b/packages/core/src/utils/tool-input.ts new file mode 100644 index 000000000..fc9ccd434 --- /dev/null +++ b/packages/core/src/utils/tool-input.ts @@ -0,0 +1,34 @@ +export const isPlainToolInput = (value: unknown): value is Record => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const parseStringifiedToolInput = (value: string): Record | undefined => { + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(trimmed); + return isPlainToolInput(parsed) ? parsed : undefined; + } catch { + return undefined; + } +}; + +export const normalizeToolInputForModel = (value: unknown): Record => { + if (isPlainToolInput(value)) { + return value; + } + + if (typeof value === "string") { + return parseStringifiedToolInput(value) ?? {}; + } + + return {}; +}; From ac7d9c0850c8f714e13acdc66c76799ccecc3c33 Mon Sep 17 00:00:00 2001 From: Omer Date: Wed, 3 Jun 2026 20:46:45 -0700 Subject: [PATCH 2/2] chore(examples): remove issue 1336 repro --- examples/base/package.json | 1 - examples/base/src/repro-1336.ts | 223 -------------------------------- 2 files changed, 224 deletions(-) delete mode 100644 examples/base/src/repro-1336.ts diff --git a/examples/base/package.json b/examples/base/package.json index efcc68ba1..ee8c94718 100644 --- a/examples/base/package.json +++ b/examples/base/package.json @@ -31,7 +31,6 @@ "scripts": { "build": "tsc", "dev": "tsx watch --env-file=.env ./src", - "repro:1336": "tsx --env-file=.env ./src/repro-1336.ts", "start": "node dist/index.js", "volt": "volt" }, diff --git a/examples/base/src/repro-1336.ts b/examples/base/src/repro-1336.ts deleted file mode 100644 index 4345b7833..000000000 --- a/examples/base/src/repro-1336.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Agent, Memory, createTool } from "@voltagent/core"; -import { LibSQLMemoryAdapter } from "@voltagent/libsql"; -import { type LanguageModel, type ModelMessage, type UIMessage, convertToModelMessages } from "ai"; -import { MockLanguageModelV3 } from "ai/test"; -import { z } from "zod"; - -const userId = "issue-1336-user"; -const conversationId = "issue-1336-conversation"; -const toolCallId = "toolu_issue_1336"; -const toolName = "createImageFromText"; - -const malformedToolInput = - '{"prompts":["Full body portrait of a 5\'7" woman"],"imageSize":"1024x1536"}'; - -const storage = new LibSQLMemoryAdapter({ - url: ":memory:", -}); - -const memory = new Memory({ - storage, -}); - -type ReplayedToolCall = { - type: "tool-call"; - input?: unknown; -}; - -function describeInputType(input: unknown) { - return Array.isArray(input) ? "array" : typeof input; -} - -function findReplayedToolCall(modelMessages: ModelMessage[]) { - return modelMessages - .flatMap((message) => { - const content = (message as { content?: unknown }).content; - return Array.isArray(content) ? content : []; - }) - .find( - (part): part is ReplayedToolCall => - typeof part === "object" && - part !== null && - (part as { type?: unknown }).type === "tool-call", - ); -} - -function createImageFromTextTool() { - return createTool({ - name: toolName, - description: "Create images from text prompts.", - parameters: z.object({ - prompts: z.array(z.string()), - imageSize: z.string(), - }), - execute: async ({ prompts, imageSize }) => ({ prompts, imageSize }), - }); -} - -function createMockModel(): LanguageModel { - return new MockLanguageModelV3({ - modelId: "issue-1336-mock-model", - doGenerate: { - content: [{ type: "text", text: "Mock response" }], - finishReason: "stop" as const, - usage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - inputTokenDetails: { - noCacheTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }, - outputTokenDetails: { - textTokens: 0, - reasoningTokens: 0, - }, - }, - warnings: [], - } as any, - }) as unknown as LanguageModel; -} - -const messages: UIMessage[] = [ - { - id: "issue-1336-user-message", - role: "user", - parts: [{ type: "text", text: "Create the image." }], - }, - { - id: "issue-1336-assistant-message", - role: "assistant", - parts: [ - { - type: `tool-${toolName}`, - toolCallId, - state: "output-available", - input: malformedToolInput, - output: { - type: "error-text", - value: - "Invalid input for tool createImageFromText: JSON parsing failed: Expected ',' or ']' after array element", - }, - providerExecuted: false, - } as UIMessage["parts"][number], - ], - }, -]; - -function findToolPart(uiMessages: UIMessage[]) { - return uiMessages - .flatMap((message) => message.parts) - .find((part) => part.type === `tool-${toolName}`) as - | (UIMessage["parts"][number] & { input?: unknown }) - | undefined; -} - -async function seedMemory() { - await memory.createConversation({ - id: conversationId, - resourceId: "issue-1336-repro", - userId, - title: "Issue 1336 repro", - metadata: { issue: 1336 }, - }); - - await memory.addMessages(messages, userId, conversationId); -} - -async function inspectReplay() { - const storedMessages = await memory.getMessages(userId, conversationId); - const storedToolPart = findToolPart(storedMessages); - - console.log("Stored UI tool input type:", describeInputType(storedToolPart?.input)); - console.dir(storedToolPart, { depth: null }); - - const modelMessages = await convertToModelMessages(storedMessages); - const replayedToolCall = findReplayedToolCall(modelMessages); - - console.log( - "Direct AI SDK replayed model tool input type:", - describeInputType(replayedToolCall?.input), - ); - console.dir(replayedToolCall, { depth: null }); -} - -async function inspectVoltAgentReplay() { - let preparedModelMessages: ModelMessage[] = []; - const agent = new Agent({ - name: "Issue 1336 Mock Replay Agent", - instructions: "Continue the conversation briefly.", - model: createMockModel(), - tools: [createImageFromTextTool()], - memory, - hooks: { - onPrepareModelMessages: async ({ modelMessages }) => { - preparedModelMessages = modelMessages; - return {}; - }, - }, - }); - - await agent.generateText("continue", { - memory: { - userId, - conversationId, - options: { - readOnly: true, - }, - }, - }); - - const replayedToolCall = findReplayedToolCall(preparedModelMessages); - console.log( - "VoltAgent sanitized model tool input type:", - describeInputType(replayedToolCall?.input), - ); - console.dir(replayedToolCall, { depth: null }); -} - -async function replayAgainstAnthropic() { - if (!process.env.ANTHROPIC_API_KEY) { - console.log("Set ANTHROPIC_API_KEY to replay this seeded history against Anthropic."); - return; - } - - const agent = new Agent({ - name: "Issue 1336 Repro Agent", - instructions: "Continue the conversation briefly.", - model: process.env.REPRO_1336_MODEL ?? "anthropic/claude-opus-4-1", - tools: [createImageFromTextTool()], - memory, - }); - - try { - await agent.generateText("continue", { - memory: { - userId, - conversationId, - options: { - readOnly: true, - }, - }, - }); - console.log( - "Provider accepted the replayed history; the bug may be fixed or model behavior changed.", - ); - } catch (error) { - console.error("Provider replay failed:"); - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - } -} - -try { - await seedMemory(); - await inspectReplay(); - await inspectVoltAgentReplay(); - await replayAgainstAnthropic(); -} finally { - await storage.close(); -} - -process.exit(process.exitCode ?? 0);