Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-tools-sip.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 26 additions & 1 deletion packages/core/src/agent/message-normalizer.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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([
{
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/agent/message-normalizer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -329,7 +330,9 @@ const normalizeToolPart = (part: ToolLikePart): UIMessagePart<any, any> | 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));
}
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/utils/message-converter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -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[] = [
{
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/utils/message-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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 }
: {}),
Expand Down Expand Up @@ -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 }
: {}),
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/utils/tool-input.spec.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
34 changes: 34 additions & 0 deletions packages/core/src/utils/tool-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const isPlainToolInput = (value: unknown): value is Record<string, unknown> => {
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<string, unknown> | 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<string, unknown> => {
if (isPlainToolInput(value)) {
return value;
}

if (typeof value === "string") {
return parseStringifiedToolInput(value) ?? {};
}

return {};
};
Loading