From d0ac36c1b6ab2a71a87d6f7691175228cc25a89f Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 4 Feb 2026 12:34:02 -0600 Subject: [PATCH 01/14] fix(core): drop orphan reasoning parts --- .../core/src/agent/message-normalizer.spec.ts | 92 +++++++++++++++++++ packages/core/src/agent/message-normalizer.ts | 37 +++++++- .../src/memory/manager/memory-manager.spec.ts | 42 +++++++++ 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index b79d8666f..03dfab673 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -428,6 +428,98 @@ describe("message-normalizer", () => { expect(sanitized[0].role).toBe("user"); }); + it("drops orphaned empty reasoning when incomplete tool calls are pruned", () => { + const messages: UIMessage[] = [ + baseMessage([ + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_123" } }, + } as any, + { + type: "tool-search", + toolCallId: "call-123", + state: "input-available", + input: { query: "hello" }, + } as any, + ]), + baseMessage([{ type: "text", text: "next turn" } as any], "user"), + ]; + + const sanitized = sanitizeMessagesForModel(messages); + + expect(sanitized).toHaveLength(1); + expect(sanitized[0].role).toBe("user"); + }); + + it("drops empty reasoning when assistant message only contains tool output", () => { + const messages: UIMessage[] = [ + baseMessage([ + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_123" } }, + } as any, + { + type: "tool-search", + toolCallId: "call-456", + state: "output-available", + output: { results: [] }, + } as any, + ]), + baseMessage([{ type: "text", text: "next turn" } as any], "user"), + ]; + + const sanitized = sanitizeMessagesForModel(messages); + + expect(sanitized).toHaveLength(2); + expect(sanitized[0].role).toBe("assistant"); + expect(sanitized[0].parts.some((part) => part.type === "reasoning")).toBe(false); + expect(sanitized[1].role).toBe("user"); + }); + + it("drops empty reasoning that precedes working-memory tool calls", () => { + const message = baseMessage([ + { + type: "reasoning", + text: "Planning next steps.", + providerMetadata: { openai: { itemId: "rs_text" } }, + } as any, + { + type: "tool-search", + toolCallId: "call-1", + state: "output-available", + output: { result: true }, + } as any, + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_empty" } }, + } as any, + { + type: "tool-update_working_memory", + toolCallId: "call-2", + state: "output-available", + output: { type: "text", value: "ok" }, + } as any, + { type: "step-start" } as any, + { type: "text", text: "final answer" } as any, + ]); + + const sanitized = sanitizeMessageForModel(message); + expect(sanitized).not.toBeNull(); + const parts = (sanitized as UIMessage).parts; + expect(parts.some((part: any) => part.type === "tool-update_working_memory")).toBe(false); + expect( + parts.some( + (part: any) => + part.type === "reasoning" && part.providerMetadata?.openai?.itemId === "rs_empty", + ), + ).toBe(false); + expect(parts.some((part: any) => part.type === "reasoning")).toBe(true); + expect(parts.some((part: any) => part.type === "text")).toBe(true); + }); + it("preserves approval responses on the last assistant message", () => { const messages: UIMessage[] = [ baseMessage([ diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index b27fd13a6..c4b2b5979 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -354,7 +354,20 @@ export const sanitizeMessagesForModel = ( export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => { const sanitizedParts: UIMessagePart[] = []; - for (const part of message.parts) { + for (let index = 0; index < message.parts.length; index += 1) { + const part = message.parts[index]; + const nextPart = message.parts[index + 1]; + if (part?.type === "reasoning") { + const text = typeof (part as any).text === "string" ? (part as any).text.trim() : ""; + if ( + text.length === 0 && + nextPart && + isToolLikePart(nextPart as UIMessagePart) && + isWorkingMemoryTool(nextPart as ToolLikePart) + ) { + continue; + } + } const normalized = normalizeGenericPart(part); if (!normalized) { continue; @@ -462,7 +475,27 @@ const filterIncompleteToolCallsForModel = (messages: UIMessage[]): UIMessage[] = return true; }); - const pruned = collapseRedundantStepStarts(parts); + let pruned = collapseRedundantStepStarts(parts); + const hasTextContent = pruned.some( + (part) => + part.type === "text" && + typeof (part as any).text === "string" && + (part as any).text.trim().length > 0, + ); + const hasToolPart = pruned.some((part) => isToolLikePart(part)); + const hasNonReasoningPart = pruned.some((part) => part.type !== "reasoning"); + const shouldDropEmptyReasoning = + (!hasTextContent && hasToolPart) || (mutated && !hasNonReasoningPart); + + if (shouldDropEmptyReasoning) { + pruned = pruned.filter((part) => { + if (part.type !== "reasoning") { + return true; + } + const text = typeof (part as any).text === "string" ? (part as any).text.trim() : ""; + return text.length > 0; + }); + } if (pruned.length === 0) { continue; } diff --git a/packages/core/src/memory/manager/memory-manager.spec.ts b/packages/core/src/memory/manager/memory-manager.spec.ts index 3750e83d5..bfc633b08 100644 --- a/packages/core/src/memory/manager/memory-manager.spec.ts +++ b/packages/core/src/memory/manager/memory-manager.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sanitizeMessagesForModel } from "../../agent/message-normalizer"; import type { OperationContext } from "../../agent/types"; import { getGlobalLogger } from "../../logger"; import { Memory } from "../../memory"; @@ -161,6 +162,47 @@ describe("MemoryManager", () => { expect(messages[1].id).toBe("msg-2"); }); + it("drops empty reasoning that precedes working-memory tool calls on reload", async () => { + const context = createMockOperationContext(); + + const assistantMessage = createTestUIMessage({ + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_123" } }, + } as any, + { + type: "tool-search", + toolCallId: "call-123", + state: "input-available", + input: { query: "hello" }, + } as any, + { + type: "tool-update_working_memory", + toolCallId: "call-999", + state: "output-available", + output: { type: "text", value: "ok" }, + } as any, + ], + }); + + await manager.saveMessage(context, assistantMessage, "user-1", "conv-1"); + + const reloaded = await manager.getMessages(context, "user-1", "conv-1"); + const nextTurn = { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "next turn" }], + }; + + const sanitized = sanitizeMessagesForModel([...reloaded, nextTurn]); + expect(sanitized).toHaveLength(1); + expect(sanitized[0].role).toBe("user"); + }); + it("should return empty array when memory is disabled", async () => { const disabledManager = new MemoryManager("agent-3", false); From c685e24b816586bfcc24d1e1fe32ddc2c759d47b Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 4 Feb 2026 12:35:34 -0600 Subject: [PATCH 02/14] chore: add changeset for reasoning fix --- .changeset/drop-orphan-reasoning.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/drop-orphan-reasoning.md diff --git a/.changeset/drop-orphan-reasoning.md b/.changeset/drop-orphan-reasoning.md new file mode 100644 index 000000000..8c6b7bdf4 --- /dev/null +++ b/.changeset/drop-orphan-reasoning.md @@ -0,0 +1,5 @@ +--- +"@voltagent/core": patch +--- + +Drop orphan reasoning parts when tool-only messages are sanitized. From 0ef15294b9bff485a07b0c16d58f28d27099c565 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 4 Feb 2026 12:37:50 -0600 Subject: [PATCH 03/14] chore(changeset): reference issue --- .changeset/drop-orphan-reasoning.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/drop-orphan-reasoning.md b/.changeset/drop-orphan-reasoning.md index 8c6b7bdf4..7e0567fb5 100644 --- a/.changeset/drop-orphan-reasoning.md +++ b/.changeset/drop-orphan-reasoning.md @@ -3,3 +3,4 @@ --- Drop orphan reasoning parts when tool-only messages are sanitized. +Fixes #1019. From a4eaa7666aa4912631e240be7ce083c4befdaa17 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 4 Feb 2026 13:04:04 -0600 Subject: [PATCH 04/14] fix(core): skip pruned parts when dropping reasoning --- .../core/src/agent/message-normalizer.spec.ts | 33 ++++++++++ packages/core/src/agent/message-normalizer.ts | 62 ++++++++++++++----- 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 03dfab673..b43dfe2b1 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -520,6 +520,39 @@ describe("message-normalizer", () => { expect(parts.some((part: any) => part.type === "text")).toBe(true); }); + it("drops empty reasoning when prunable parts sit before working-memory tools", () => { + const message = baseMessage([ + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_empty" } }, + } as any, + { + type: "tool-search", + toolCallId: "call-ghost", + } as any, + { + type: "tool-update_working_memory", + toolCallId: "call-mem", + state: "output-available", + output: { type: "text", value: "ok" }, + } as any, + { type: "text", text: "final answer" } as any, + ]); + + const sanitized = sanitizeMessageForModel(message); + expect(sanitized).not.toBeNull(); + const parts = (sanitized as UIMessage).parts; + expect( + parts.some( + (part: any) => + part.type === "reasoning" && part.providerMetadata?.openai?.itemId === "rs_empty", + ), + ).toBe(false); + expect(parts.some((part: any) => part.type === "tool-update_working_memory")).toBe(false); + expect(parts.some((part: any) => part.type === "text")).toBe(true); + }); + it("preserves approval responses on the last assistant message", () => { const messages: UIMessage[] = [ baseMessage([ diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index c4b2b5979..7bd16f557 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -351,21 +351,45 @@ export const sanitizeMessagesForModel = ( return addStepStartsBetweenToolRuns(filtered); }; +const findNextContentPart = ( + parts: UIMessagePart[], + startIndex: number, +): { kind: "working-memory" } | { kind: "part"; part: UIMessagePart } | undefined => { + for (let index = startIndex; index < parts.length; index += 1) { + const candidate = parts[index]; + if (!candidate) { + continue; + } + if (candidate.type === "step-start") { + continue; + } + if (isToolLikePart(candidate) && isWorkingMemoryTool(candidate as ToolLikePart)) { + return { kind: "working-memory" }; + } + const normalized = normalizeGenericPart(candidate); + if (!normalized) { + continue; + } + if (isPrunableEmptyToolRun(normalized)) { + continue; + } + return { kind: "part", part: normalized }; + } + return undefined; +}; + export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => { const sanitizedParts: UIMessagePart[] = []; for (let index = 0; index < message.parts.length; index += 1) { const part = message.parts[index]; - const nextPart = message.parts[index + 1]; if (part?.type === "reasoning") { const text = typeof (part as any).text === "string" ? (part as any).text.trim() : ""; - if ( - text.length === 0 && - nextPart && - isToolLikePart(nextPart as UIMessagePart) && - isWorkingMemoryTool(nextPart as ToolLikePart) - ) { - continue; + if (text.length === 0) { + const nextContent = findNextContentPart(message.parts, index + 1); + if (nextContent?.kind === "working-memory") { + continue; + } } } const normalized = normalizeGenericPart(part); @@ -558,13 +582,8 @@ const addStepStartsBetweenToolRuns = (messages: UIMessage[]): UIMessage[] => { const pruneEmptyToolRuns = (parts: UIMessagePart[]): UIMessagePart[] => { const cleaned: UIMessagePart[] = []; for (const part of parts) { - if (typeof part.type === "string" && part.type.startsWith("tool-")) { - const hasPendingState = (part as any).state === "input-available"; - const hasResult = - (part as any).state === "output-available" || (part as any).output !== undefined; - if (!hasPendingState && !hasResult && (part as any).input == null) { - continue; - } + if (isPrunableEmptyToolRun(part)) { + continue; } cleaned.push(part); @@ -572,6 +591,19 @@ const pruneEmptyToolRuns = (parts: UIMessagePart[]): UIMessagePart): boolean => { + if (typeof part.type !== "string" || !part.type.startsWith("tool-")) { + return false; + } + const hasPendingState = (part as any).state === "input-available"; + const hasResult = + (part as any).state === "output-available" || (part as any).output !== undefined; + if (!hasPendingState && !hasResult && (part as any).input == null) { + return true; + } + return false; +}; + const removeProviderExecutedToolsWithoutReasoning = ( parts: UIMessagePart[], ): UIMessagePart[] => { From 537749bcdbf6c337617a76c5544ad1d38d2ade12 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 4 Feb 2026 13:22:48 -0600 Subject: [PATCH 05/14] fix(core): preserve tool run error states --- .../core/src/agent/message-normalizer.spec.ts | 31 +++++++++++++++++++ packages/core/src/agent/message-normalizer.ts | 6 ++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index b43dfe2b1..35854e60e 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -147,6 +147,37 @@ describe("message-normalizer", () => { expect(((sanitized as UIMessage).parts[0] as any).state).toBe("input-available"); }); + it("keeps tool runs with pending, error, or denied states", () => { + const message = baseMessage([ + { + type: "tool-search", + toolCallId: "call-streaming", + state: "input-streaming", + input: { query: "streaming" }, + } as any, + { + type: "tool-search", + toolCallId: "call-error", + state: "output-error", + errorText: "Tool failed", + } as any, + { + type: "tool-search", + toolCallId: "call-denied", + state: "output-denied", + } as any, + ]); + + const sanitized = sanitizeMessageForModel(message); + expect(sanitized).not.toBeNull(); + const parts = (sanitized as UIMessage).parts as any[]; + expect(parts.map((part) => part.state)).toEqual([ + "input-streaming", + "output-error", + "output-denied", + ]); + }); + it("preserves tool approval metadata for approval flows", () => { const message = baseMessage([ { diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index 7bd16f557..3cd1648af 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -595,9 +595,9 @@ const isPrunableEmptyToolRun = (part: UIMessagePart): boolean => { if (typeof part.type !== "string" || !part.type.startsWith("tool-")) { return false; } - const hasPendingState = (part as any).state === "input-available"; - const hasResult = - (part as any).state === "output-available" || (part as any).output !== undefined; + const state = (part as any).state; + const hasPendingState = state === "input-available" || state === "input-streaming"; + const hasResult = hasToolOutput(part as ToolLikePart); if (!hasPendingState && !hasResult && (part as any).input == null) { return true; } From 06dd974a77276d24165f40e2eefedf38f6ea0419 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 4 Feb 2026 16:42:19 -0600 Subject: [PATCH 06/14] fix(core): treat output-streaming as tool result --- packages/core/src/agent/message-normalizer.spec.ts | 14 ++++++++++++++ packages/core/src/agent/message-normalizer.ts | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 35854e60e..741a063f5 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -178,6 +178,20 @@ describe("message-normalizer", () => { ]); }); + it("keeps tool runs with output-streaming state", () => { + const message = baseMessage([ + { + type: "tool-search", + toolCallId: "call-streaming-output", + state: "output-streaming", + } as any, + ]); + + const sanitized = sanitizeMessageForModel(message); + expect(sanitized).not.toBeNull(); + expect(((sanitized as UIMessage).parts[0] as any).state).toBe("output-streaming"); + }); + it("preserves tool approval metadata for approval flows", () => { const message = baseMessage([ { diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index 3cd1648af..409e34139 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -261,7 +261,12 @@ const isToolLikePart = (part: UIMessagePart): part is ToolLikePart => const hasToolOutput = (part: ToolLikePart): boolean => { const state = typeof part.state === "string" ? part.state : undefined; - if (state === "output-available" || state === "output-error" || state === "output-denied") { + if ( + state === "output-available" || + state === "output-streaming" || + state === "output-error" || + state === "output-denied" + ) { return true; } return part.output !== undefined; From f96df507c2955ff2ff79620dc9bb96efe5d2fda6 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 4 Feb 2026 16:50:08 -0600 Subject: [PATCH 07/14] fix(core): drop reasoning before memory tools --- packages/core/src/agent/message-normalizer.spec.ts | 8 ++++---- packages/core/src/agent/message-normalizer.ts | 9 +++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 741a063f5..02beab7a1 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -523,7 +523,7 @@ describe("message-normalizer", () => { expect(sanitized[1].role).toBe("user"); }); - it("drops empty reasoning that precedes working-memory tool calls", () => { + it("drops reasoning that precedes working-memory tool calls", () => { const message = baseMessage([ { type: "reasoning", @@ -538,7 +538,7 @@ describe("message-normalizer", () => { } as any, { type: "reasoning", - text: "", + text: "Updating memory.", providerMetadata: { openai: { itemId: "rs_empty" } }, } as any, { @@ -565,11 +565,11 @@ describe("message-normalizer", () => { expect(parts.some((part: any) => part.type === "text")).toBe(true); }); - it("drops empty reasoning when prunable parts sit before working-memory tools", () => { + it("drops reasoning when prunable parts sit before working-memory tools", () => { const message = baseMessage([ { type: "reasoning", - text: "", + text: "Prepping memory update.", providerMetadata: { openai: { itemId: "rs_empty" } }, } as any, { diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index 409e34139..794606ca5 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -389,12 +389,9 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => for (let index = 0; index < message.parts.length; index += 1) { const part = message.parts[index]; if (part?.type === "reasoning") { - const text = typeof (part as any).text === "string" ? (part as any).text.trim() : ""; - if (text.length === 0) { - const nextContent = findNextContentPart(message.parts, index + 1); - if (nextContent?.kind === "working-memory") { - continue; - } + const nextContent = findNextContentPart(message.parts, index + 1); + if (nextContent?.kind === "working-memory") { + continue; } } const normalized = normalizeGenericPart(part); From 7efcd71e926071eb891ed48e1918483cae0431b4 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 4 Feb 2026 17:18:18 -0600 Subject: [PATCH 08/14] fix(core): only drop empty reasoning before memory tools --- packages/core/src/agent/message-normalizer.spec.ts | 8 ++++---- packages/core/src/agent/message-normalizer.ts | 12 +++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 02beab7a1..741a063f5 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -523,7 +523,7 @@ describe("message-normalizer", () => { expect(sanitized[1].role).toBe("user"); }); - it("drops reasoning that precedes working-memory tool calls", () => { + it("drops empty reasoning that precedes working-memory tool calls", () => { const message = baseMessage([ { type: "reasoning", @@ -538,7 +538,7 @@ describe("message-normalizer", () => { } as any, { type: "reasoning", - text: "Updating memory.", + text: "", providerMetadata: { openai: { itemId: "rs_empty" } }, } as any, { @@ -565,11 +565,11 @@ describe("message-normalizer", () => { expect(parts.some((part: any) => part.type === "text")).toBe(true); }); - it("drops reasoning when prunable parts sit before working-memory tools", () => { + it("drops empty reasoning when prunable parts sit before working-memory tools", () => { const message = baseMessage([ { type: "reasoning", - text: "Prepping memory update.", + text: "", providerMetadata: { openai: { itemId: "rs_empty" } }, } as any, { diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index 794606ca5..94773d06a 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -389,8 +389,18 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => for (let index = 0; index < message.parts.length; index += 1) { const part = message.parts[index]; if (part?.type === "reasoning") { + const text = typeof (part as any).text === "string" ? (part as any).text.trim() : undefined; + const content = + typeof (part as any).content === "string" ? (part as any).content.trim() : undefined; + const id = + typeof (part as any).id === "string" + ? (part as any).id.trim() + : typeof (part as any).reasoningId === "string" + ? (part as any).reasoningId.trim() + : undefined; + const isEmptyReasoning = !text && !content && !id; const nextContent = findNextContentPart(message.parts, index + 1); - if (nextContent?.kind === "working-memory") { + if (isEmptyReasoning && nextContent?.kind === "working-memory") { continue; } } From 3243ecc2124dcf5b06576aca594b28ab2b1b52a0 Mon Sep 17 00:00:00 2001 From: Chris Sims <126508279+chrisisagile@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:26:04 -0600 Subject: [PATCH 09/14] fix(core): normalize reasoning before working-memory tools Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/core/src/agent/message-normalizer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index 94773d06a..4cf24b221 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -392,12 +392,16 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => const text = typeof (part as any).text === "string" ? (part as any).text.trim() : undefined; const content = typeof (part as any).content === "string" ? (part as any).content.trim() : undefined; - const id = + const explicitId = typeof (part as any).id === "string" ? (part as any).id.trim() : typeof (part as any).reasoningId === "string" ? (part as any).reasoningId.trim() : undefined; + const providerMetadata = (part as any).providerMetadata; + const metadataReasoningId = + isObject(providerMetadata) ? extractReasoningIdFromMetadata(providerMetadata) : undefined; + const id = explicitId || metadataReasoningId; const isEmptyReasoning = !text && !content && !id; const nextContent = findNextContentPart(message.parts, index + 1); if (isEmptyReasoning && nextContent?.kind === "working-memory") { From ed731fd644b3899567f7bec73a2f75298596e0fa Mon Sep 17 00:00:00 2001 From: "chris@rhythmic.com" Date: Thu, 5 Feb 2026 11:27:53 -0600 Subject: [PATCH 10/14] fix(core): drop empty reasoning before working-memory tools --- .../core/src/agent/message-normalizer.spec.ts | 48 +++++ packages/core/src/agent/message-normalizer.ts | 190 ++++++++++-------- 2 files changed, 150 insertions(+), 88 deletions(-) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 741a063f5..86253d93e 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -643,4 +643,52 @@ describe("message-normalizer", () => { expect(sanitized[0].parts).toHaveLength(3); expect(sanitized[0].parts[1]).toEqual({ type: "step-start" }); }); + + it("drops empty reasoning that precedes working-memory tool calls", () => { + const message = baseMessage([ + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_openai" } }, + } as any, + { + type: "tool-update_working_memory", + toolCallId: "call-1", + state: "output-available", + output: { content: "secret" }, + } as any, + { type: "text", text: "visible" } as any, + ]); + + const sanitized = sanitizeMessageForModel(message); + expect(sanitized).not.toBeNull(); + const parts = (sanitized as UIMessage).parts; + expect(parts.some((part: any) => part.type === "reasoning")).toBe(false); + expect(parts.some((part: any) => part.type === "text")).toBe(true); + }); + + it("drops empty reasoning when prunable parts sit before working-memory tools", () => { + const message = baseMessage([ + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_openai" } }, + } as any, + { type: "text", text: " " } as any, + { type: "step-start" } as any, + { + type: "tool-update_working_memory", + toolCallId: "call-2", + state: "input-available", + input: { content: "secret" }, + } as any, + { type: "text", text: "visible" } as any, + ]); + + const sanitized = sanitizeMessageForModel(message); + expect(sanitized).not.toBeNull(); + const parts = (sanitized as UIMessage).parts; + expect(parts.some((part: any) => part.type === "reasoning")).toBe(false); + expect(parts.some((part: any) => part.type === "text")).toBe(true); + }); }); diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index 4cf24b221..a84843c0c 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -261,12 +261,7 @@ const isToolLikePart = (part: UIMessagePart): part is ToolLikePart => const hasToolOutput = (part: ToolLikePart): boolean => { const state = typeof part.state === "string" ? part.state : undefined; - if ( - state === "output-available" || - state === "output-streaming" || - state === "output-error" || - state === "output-denied" - ) { + if (state === "output-available" || state === "output-error" || state === "output-denied") { return true; } return part.output !== undefined; @@ -281,6 +276,96 @@ const isWorkingMemoryTool = (part: ToolLikePart): boolean => { return WORKING_MEMORY_TOOL_NAMES.has(toolName); }; +const isWorkingMemoryToolPart = (part: UIMessagePart): boolean => { + if (typeof part.type !== "string") { + return false; + } + if (!part.type.startsWith("tool-")) { + return false; + } + const toolName = toolNameFromType(part.type); + if (!toolName) { + return false; + } + return WORKING_MEMORY_TOOL_NAMES.has(toolName); +}; + +const isEmptyTextPart = (part: UIMessagePart): boolean => { + if (part.type !== "text") { + return false; + } + const text = typeof (part as any).text === "string" ? (part as any).text : ""; + return text.trim().length === 0; +}; + +const isPrunableToolPart = (part: UIMessagePart): boolean => { + if (typeof part.type !== "string" || !part.type.startsWith("tool-")) { + return false; + } + const hasPendingState = (part as any).state === "input-available"; + const hasResult = + (part as any).state === "output-available" || (part as any).output !== undefined; + if (hasPendingState || hasResult) { + return false; + } + return (part as any).input == null; +}; + +const isPrunablePartBeforeWorkingMemory = (part: UIMessagePart): boolean => { + if (part.type === "step-start") { + return true; + } + if (part.type === "file" && !isObject(part as any)) { + return true; + } + if (part.type === "file" && !(part as any).url) { + return true; + } + if (isEmptyTextPart(part)) { + return true; + } + if (isWorkingMemoryToolPart(part)) { + return true; + } + if (isPrunableToolPart(part)) { + return true; + } + return false; +}; + +const shouldDropEmptyReasoningBeforeWorkingMemory = ( + parts: UIMessagePart[], + index: number, +): boolean => { + const part = parts[index]; + if (part.type !== "reasoning") { + return false; + } + + const text = typeof (part as any).text === "string" ? (part as any).text : ""; + if (text.trim().length > 0) { + return false; + } + + for (let nextIndex = index + 1; nextIndex < parts.length; nextIndex += 1) { + const next = parts[nextIndex]; + if (isPrunablePartBeforeWorkingMemory(next)) { + if (isWorkingMemoryToolPart(next)) { + return true; + } + continue; + } + + if (isWorkingMemoryToolPart(next)) { + return true; + } + + return false; + } + + return false; +}; + const normalizeToolOutputPayload = (output: unknown): unknown => { if (Array.isArray(output)) { return output.map((item) => normalizeToolOutputPayload(item)); @@ -356,58 +441,15 @@ export const sanitizeMessagesForModel = ( return addStepStartsBetweenToolRuns(filtered); }; -const findNextContentPart = ( - parts: UIMessagePart[], - startIndex: number, -): { kind: "working-memory" } | { kind: "part"; part: UIMessagePart } | undefined => { - for (let index = startIndex; index < parts.length; index += 1) { - const candidate = parts[index]; - if (!candidate) { - continue; - } - if (candidate.type === "step-start") { - continue; - } - if (isToolLikePart(candidate) && isWorkingMemoryTool(candidate as ToolLikePart)) { - return { kind: "working-memory" }; - } - const normalized = normalizeGenericPart(candidate); - if (!normalized) { - continue; - } - if (isPrunableEmptyToolRun(normalized)) { - continue; - } - return { kind: "part", part: normalized }; - } - return undefined; -}; - export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => { const sanitizedParts: UIMessagePart[] = []; for (let index = 0; index < message.parts.length; index += 1) { const part = message.parts[index]; - if (part?.type === "reasoning") { - const text = typeof (part as any).text === "string" ? (part as any).text.trim() : undefined; - const content = - typeof (part as any).content === "string" ? (part as any).content.trim() : undefined; - const explicitId = - typeof (part as any).id === "string" - ? (part as any).id.trim() - : typeof (part as any).reasoningId === "string" - ? (part as any).reasoningId.trim() - : undefined; - const providerMetadata = (part as any).providerMetadata; - const metadataReasoningId = - isObject(providerMetadata) ? extractReasoningIdFromMetadata(providerMetadata) : undefined; - const id = explicitId || metadataReasoningId; - const isEmptyReasoning = !text && !content && !id; - const nextContent = findNextContentPart(message.parts, index + 1); - if (isEmptyReasoning && nextContent?.kind === "working-memory") { - continue; - } + if (shouldDropEmptyReasoningBeforeWorkingMemory(message.parts, index)) { + continue; } + const normalized = normalizeGenericPart(part); if (!normalized) { continue; @@ -515,27 +557,7 @@ const filterIncompleteToolCallsForModel = (messages: UIMessage[]): UIMessage[] = return true; }); - let pruned = collapseRedundantStepStarts(parts); - const hasTextContent = pruned.some( - (part) => - part.type === "text" && - typeof (part as any).text === "string" && - (part as any).text.trim().length > 0, - ); - const hasToolPart = pruned.some((part) => isToolLikePart(part)); - const hasNonReasoningPart = pruned.some((part) => part.type !== "reasoning"); - const shouldDropEmptyReasoning = - (!hasTextContent && hasToolPart) || (mutated && !hasNonReasoningPart); - - if (shouldDropEmptyReasoning) { - pruned = pruned.filter((part) => { - if (part.type !== "reasoning") { - return true; - } - const text = typeof (part as any).text === "string" ? (part as any).text.trim() : ""; - return text.length > 0; - }); - } + const pruned = collapseRedundantStepStarts(parts); if (pruned.length === 0) { continue; } @@ -598,8 +620,13 @@ const addStepStartsBetweenToolRuns = (messages: UIMessage[]): UIMessage[] => { const pruneEmptyToolRuns = (parts: UIMessagePart[]): UIMessagePart[] => { const cleaned: UIMessagePart[] = []; for (const part of parts) { - if (isPrunableEmptyToolRun(part)) { - continue; + if (typeof part.type === "string" && part.type.startsWith("tool-")) { + const hasPendingState = (part as any).state === "input-available"; + const hasResult = + (part as any).state === "output-available" || (part as any).output !== undefined; + if (!hasPendingState && !hasResult && (part as any).input == null) { + continue; + } } cleaned.push(part); @@ -607,19 +634,6 @@ const pruneEmptyToolRuns = (parts: UIMessagePart[]): UIMessagePart): boolean => { - if (typeof part.type !== "string" || !part.type.startsWith("tool-")) { - return false; - } - const state = (part as any).state; - const hasPendingState = state === "input-available" || state === "input-streaming"; - const hasResult = hasToolOutput(part as ToolLikePart); - if (!hasPendingState && !hasResult && (part as any).input == null) { - return true; - } - return false; -}; - const removeProviderExecutedToolsWithoutReasoning = ( parts: UIMessagePart[], ): UIMessagePart[] => { From 68de6138d3278b82a166de2f95a117f80681e91a Mon Sep 17 00:00:00 2001 From: "chris@rhythmic.com" Date: Thu, 5 Feb 2026 11:55:24 -0600 Subject: [PATCH 11/14] fix(core): preserve tool states and prune orphaned reasoning --- .../core/src/agent/message-normalizer.spec.ts | 48 -------- packages/core/src/agent/message-normalizer.ts | 109 +++++++++++++++--- 2 files changed, 96 insertions(+), 61 deletions(-) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 86253d93e..741a063f5 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -643,52 +643,4 @@ describe("message-normalizer", () => { expect(sanitized[0].parts).toHaveLength(3); expect(sanitized[0].parts[1]).toEqual({ type: "step-start" }); }); - - it("drops empty reasoning that precedes working-memory tool calls", () => { - const message = baseMessage([ - { - type: "reasoning", - text: "", - providerMetadata: { openai: { itemId: "rs_openai" } }, - } as any, - { - type: "tool-update_working_memory", - toolCallId: "call-1", - state: "output-available", - output: { content: "secret" }, - } as any, - { type: "text", text: "visible" } as any, - ]); - - const sanitized = sanitizeMessageForModel(message); - expect(sanitized).not.toBeNull(); - const parts = (sanitized as UIMessage).parts; - expect(parts.some((part: any) => part.type === "reasoning")).toBe(false); - expect(parts.some((part: any) => part.type === "text")).toBe(true); - }); - - it("drops empty reasoning when prunable parts sit before working-memory tools", () => { - const message = baseMessage([ - { - type: "reasoning", - text: "", - providerMetadata: { openai: { itemId: "rs_openai" } }, - } as any, - { type: "text", text: " " } as any, - { type: "step-start" } as any, - { - type: "tool-update_working_memory", - toolCallId: "call-2", - state: "input-available", - input: { content: "secret" }, - } as any, - { type: "text", text: "visible" } as any, - ]); - - const sanitized = sanitizeMessageForModel(message); - expect(sanitized).not.toBeNull(); - const parts = (sanitized as UIMessage).parts; - expect(parts.some((part: any) => part.type === "reasoning")).toBe(false); - expect(parts.some((part: any) => part.type === "text")).toBe(true); - }); }); diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index a84843c0c..18b488112 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -261,12 +261,37 @@ const isToolLikePart = (part: UIMessagePart): part is ToolLikePart => const hasToolOutput = (part: ToolLikePart): boolean => { const state = typeof part.state === "string" ? part.state : undefined; - if (state === "output-available" || state === "output-error" || state === "output-denied") { + if ( + state === "output-available" || + state === "output-error" || + state === "output-denied" || + state === "output-streaming" + ) { return true; } return part.output !== undefined; }; +const isToolInputState = (state: string | undefined): boolean => + state === "input-available" || + state === "input-streaming" || + state === "approval-requested" || + state === "approval-responded"; + +const isToolOutputState = (state: string | undefined): boolean => + state === "output-available" || + state === "output-error" || + state === "output-denied" || + state === "output-streaming"; + +const hasToolInput = (part: ToolLikePart): boolean => { + const state = typeof part.state === "string" ? part.state : undefined; + if (isToolInputState(state)) { + return true; + } + return part.input !== undefined; +}; + const isApprovalResponded = (part: ToolLikePart): boolean => Boolean((part as any).approval && (part as any).approval.approved != null); @@ -298,14 +323,22 @@ const isEmptyTextPart = (part: UIMessagePart): boolean => { return text.trim().length === 0; }; +const isEmptyReasoningPart = (part: UIMessagePart): boolean => { + if (part.type !== "reasoning") { + return false; + } + const text = typeof (part as any).text === "string" ? (part as any).text : ""; + return text.trim().length === 0; +}; + const isPrunableToolPart = (part: UIMessagePart): boolean => { if (typeof part.type !== "string" || !part.type.startsWith("tool-")) { return false; } - const hasPendingState = (part as any).state === "input-available"; - const hasResult = - (part as any).state === "output-available" || (part as any).output !== undefined; - if (hasPendingState || hasResult) { + const state = typeof (part as any).state === "string" ? (part as any).state : undefined; + const hasPendingState = isToolInputState(state); + const hasResult = isToolOutputState(state) || (part as any).output !== undefined; + if (hasPendingState || hasResult || (part as any).input !== undefined) { return false; } return (part as any).input == null; @@ -342,8 +375,7 @@ const shouldDropEmptyReasoningBeforeWorkingMemory = ( return false; } - const text = typeof (part as any).text === "string" ? (part as any).text : ""; - if (text.trim().length > 0) { + if (!isEmptyReasoningPart(part)) { return false; } @@ -366,6 +398,51 @@ const shouldDropEmptyReasoningBeforeWorkingMemory = ( return false; }; +const removeEmptyReasoningWithOnlyToolOutputs = ( + parts: UIMessagePart[], +): UIMessagePart[] => { + const hasText = parts.some( + (part) => + part.type === "text" && + typeof (part as any).text === "string" && + (part as any).text.trim().length > 0, + ); + if (hasText) { + return parts; + } + + const toolParts = parts.filter((part) => isToolLikePart(part)) as ToolLikePart[]; + if (toolParts.length === 0) { + return parts; + } + + const hasOutput = toolParts.some((part) => hasToolOutput(part)); + const hasInput = toolParts.some((part) => hasToolInput(part)); + if (!hasOutput || hasInput) { + return parts; + } + + return parts.filter((part) => !isEmptyReasoningPart(part)); +}; + +const dropOrphanedEmptyReasoning = ( + parts: UIMessagePart[], +): UIMessagePart[] => { + const nonReasoning = parts.filter((part) => part.type !== "reasoning"); + if (nonReasoning.length > 0) { + return parts; + } + + const hasNonEmptyReasoning = parts.some( + (part) => part.type === "reasoning" && !isEmptyReasoningPart(part), + ); + if (hasNonEmptyReasoning) { + return parts; + } + + return []; +}; + const normalizeToolOutputPayload = (output: unknown): unknown => { if (Array.isArray(output)) { return output.map((item) => normalizeToolOutputPayload(item)); @@ -460,8 +537,9 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => const pruned = collapseRedundantStepStarts(pruneEmptyToolRuns(sanitizedParts)); const withoutDanglingTools = removeProviderExecutedToolsWithoutReasoning(pruned); const normalizedParts = stripReasoningLinkedProviderMetadata(withoutDanglingTools); + const trimmedReasoning = removeEmptyReasoningWithOnlyToolOutputs(normalizedParts); - const effectiveParts = normalizedParts.filter((part) => { + const effectiveParts = trimmedReasoning.filter((part) => { if (part.type === "text") { return typeof (part as any).text === "string" && (part as any).text.trim().length > 0; } @@ -562,12 +640,17 @@ const filterIncompleteToolCallsForModel = (messages: UIMessage[]): UIMessage[] = continue; } - if (!mutated && pruned.length === message.parts.length) { + const withoutOrphanedReasoning = mutated ? dropOrphanedEmptyReasoning(pruned) : pruned; + if (withoutOrphanedReasoning.length === 0) { + continue; + } + + if (!mutated && withoutOrphanedReasoning.length === message.parts.length) { filtered.push(message); } else { filtered.push({ ...message, - parts: pruned, + parts: withoutOrphanedReasoning, }); } } @@ -621,9 +704,9 @@ const pruneEmptyToolRuns = (parts: UIMessagePart[]): UIMessagePart[] = []; for (const part of parts) { if (typeof part.type === "string" && part.type.startsWith("tool-")) { - const hasPendingState = (part as any).state === "input-available"; - const hasResult = - (part as any).state === "output-available" || (part as any).output !== undefined; + const state = typeof (part as any).state === "string" ? (part as any).state : undefined; + const hasPendingState = isToolInputState(state); + const hasResult = isToolOutputState(state) || (part as any).output !== undefined; if (!hasPendingState && !hasResult && (part as any).input == null) { continue; } From cb65faa0a7e9352ba7ce971f1a67cc8fb3437923 Mon Sep 17 00:00:00 2001 From: "chris@rhythmic.com" Date: Thu, 5 Feb 2026 14:57:23 -0600 Subject: [PATCH 12/14] fix(core): drop standalone OpenAI reasoning items when they are the only assistant part --- .../core/src/agent/message-normalizer.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 741a063f5..28e027f87 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -523,6 +523,42 @@ describe("message-normalizer", () => { expect(sanitized[1].role).toBe("user"); }); + it("drops standalone OpenAI reasoning items when they are the only assistant part", () => { + const messages: UIMessage[] = [ + baseMessage([ + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_only" } }, + } as any, + ]), + baseMessage([{ type: "text", text: "next turn" } as any], "user"), + ]; + + const sanitized = sanitizeMessagesForModel(messages); + + expect(sanitized).toHaveLength(1); + expect(sanitized[0].role).toBe("user"); + }); + + it("drops standalone OpenAI reasoning items even when reasoning text exists", () => { + const messages: UIMessage[] = [ + baseMessage([ + { + type: "reasoning", + text: "Thinking...", + providerMetadata: { openai: { itemId: "rs_only_text" } }, + } as any, + ]), + baseMessage([{ type: "text", text: "next turn" } as any], "user"), + ]; + + const sanitized = sanitizeMessagesForModel(messages); + + expect(sanitized).toHaveLength(1); + expect(sanitized[0].role).toBe("user"); + }); + it("drops empty reasoning that precedes working-memory tool calls", () => { const message = baseMessage([ { From c1c465f50a58d0ac98bd9680952b3897f7ca3374 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 5 Feb 2026 18:06:51 -0600 Subject: [PATCH 13/14] fix(core): implement stripDanglingOpenAIReasoningFromModelMessages to clean up reasoning items --- packages/core/src/agent/agent.ts | 3 + .../core/src/agent/message-normalizer.spec.ts | 203 +++++------ packages/core/src/agent/message-normalizer.ts | 339 +++++++++--------- .../agent/model-message-normalizer.spec.ts | 114 ++++++ .../src/agent/model-message-normalizer.ts | 112 ++++++ .../src/memory/manager/memory-manager.spec.ts | 42 --- 6 files changed, 483 insertions(+), 330 deletions(-) create mode 100644 packages/core/src/agent/model-message-normalizer.spec.ts create mode 100644 packages/core/src/agent/model-message-normalizer.ts diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index f54e43aae..37e74bdf2 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -105,6 +105,7 @@ import { enqueueEvalScoring as enqueueEvalScoringHelper, } from "./eval"; import type { AgentHooks, OnToolEndHookResult } from "./hooks"; +import { stripDanglingOpenAIReasoningFromModelMessages } from "./model-message-normalizer"; import { AgentTraceContext, addModelAttributesToSpan } from "./open-telemetry/trace-context"; import type { BaseMessage, @@ -3086,6 +3087,8 @@ export class Agent { } } + messages = stripDanglingOpenAIReasoningFromModelMessages(messages); + // Calculate maxSteps (use provided option or calculate based on subagents) const maxSteps = options?.maxSteps ?? this.calculateMaxSteps(); diff --git a/packages/core/src/agent/message-normalizer.spec.ts b/packages/core/src/agent/message-normalizer.spec.ts index 28e027f87..67c1a2c7c 100644 --- a/packages/core/src/agent/message-normalizer.spec.ts +++ b/packages/core/src/agent/message-normalizer.spec.ts @@ -435,122 +435,127 @@ describe("message-normalizer", () => { expect(toolPart.callProviderMetadata).toEqual({ openai: { itemId: "fc_openai" } }); }); - it("sanitizes collections while preserving message ordering", () => { + it("drops OpenAI reasoning when no following item exists", () => { const messages: UIMessage[] = [ baseMessage([ { - type: "tool-update_working_memory", - toolCallId: "tool-1", - state: "input-available", - input: { content: "secret" }, + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_only" } }, } as any, ]), - baseMessage([{ type: "text", text: "visible" } as any]), ]; - const sanitized = sanitizeMessagesForModel(messages); + const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false }); - expect(sanitized).toHaveLength(1); - expect(sanitized[0].parts[0]).toEqual({ type: "text", text: "visible" }); + expect(sanitized).toHaveLength(0); }); - it("filters incomplete tool calls when preparing model messages", () => { + it("drops OpenAI reasoning when the next item lacks an OpenAI itemId", () => { const messages: UIMessage[] = [ baseMessage([ { - type: "tool-search", - toolCallId: "call-123", - state: "input-available", - input: { query: "hello" }, + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_no_follow" } }, + } as any, + { + type: "text", + text: "still keep this text", } as any, ]), - baseMessage([{ type: "text", text: "follow up" } as any], "user"), ]; - const sanitized = sanitizeMessagesForModel(messages); + const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false }); expect(sanitized).toHaveLength(1); - expect(sanitized[0].role).toBe("user"); + const parts = sanitized[0].parts; + expect(parts).toHaveLength(1); + expect(parts[0]).toEqual({ type: "text", text: "still keep this text" }); }); - it("drops orphaned empty reasoning when incomplete tool calls are pruned", () => { + it("preserves OpenAI tool metadata when reasoning exists in another message", () => { const messages: UIMessage[] = [ baseMessage([ { type: "reasoning", text: "", - providerMetadata: { openai: { itemId: "rs_123" } }, + providerMetadata: { openai: { itemId: "rs_cross" } }, } as any, + ]), + baseMessage([ { type: "tool-search", - toolCallId: "call-123", + toolCallId: "call-cross", state: "input-available", input: { query: "hello" }, + callProviderMetadata: { openai: { itemId: "fc_cross" } }, } as any, ]), - baseMessage([{ type: "text", text: "next turn" } as any], "user"), ]; - const sanitized = sanitizeMessagesForModel(messages); - + const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false }); expect(sanitized).toHaveLength(1); - expect(sanitized[0].role).toBe("user"); + const toolPart = sanitized[0].parts.find( + (part: any) => typeof part.type === "string" && part.type.startsWith("tool-"), + ) as any; + expect(toolPart.callProviderMetadata).toEqual({ openai: { itemId: "fc_cross" } }); }); - it("drops empty reasoning when assistant message only contains tool output", () => { + it("preserves OpenAI text metadata when reasoning exists in another message", () => { const messages: UIMessage[] = [ baseMessage([ { type: "reasoning", text: "", - providerMetadata: { openai: { itemId: "rs_123" } }, + providerMetadata: { openai: { itemId: "rs_cross_text" } }, } as any, + ]), + baseMessage([ { - type: "tool-search", - toolCallId: "call-456", - state: "output-available", - output: { results: [] }, + type: "text", + text: "final answer", + providerMetadata: { openai: { itemId: "msg_cross" } }, } as any, ]), - baseMessage([{ type: "text", text: "next turn" } as any], "user"), ]; - const sanitized = sanitizeMessagesForModel(messages); - - expect(sanitized).toHaveLength(2); - expect(sanitized[0].role).toBe("assistant"); - expect(sanitized[0].parts.some((part) => part.type === "reasoning")).toBe(false); - expect(sanitized[1].role).toBe("user"); + const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false }); + expect(sanitized).toHaveLength(1); + const textPart = sanitized[0].parts.find((part: any) => part.type === "text") as any; + expect(textPart.providerMetadata).toEqual({ openai: { itemId: "msg_cross" } }); }); - it("drops standalone OpenAI reasoning items when they are the only assistant part", () => { + it("sanitizes collections while preserving message ordering", () => { const messages: UIMessage[] = [ baseMessage([ { - type: "reasoning", - text: "", - providerMetadata: { openai: { itemId: "rs_only" } }, + type: "tool-update_working_memory", + toolCallId: "tool-1", + state: "input-available", + input: { content: "secret" }, } as any, ]), - baseMessage([{ type: "text", text: "next turn" } as any], "user"), + baseMessage([{ type: "text", text: "visible" } as any]), ]; const sanitized = sanitizeMessagesForModel(messages); expect(sanitized).toHaveLength(1); - expect(sanitized[0].role).toBe("user"); + expect(sanitized[0].parts[0]).toEqual({ type: "text", text: "visible" }); }); - it("drops standalone OpenAI reasoning items even when reasoning text exists", () => { + it("filters incomplete tool calls when preparing model messages", () => { const messages: UIMessage[] = [ baseMessage([ { - type: "reasoning", - text: "Thinking...", - providerMetadata: { openai: { itemId: "rs_only_text" } }, + type: "tool-search", + toolCallId: "call-123", + state: "input-available", + input: { query: "hello" }, } as any, ]), - baseMessage([{ type: "text", text: "next turn" } as any], "user"), + baseMessage([{ type: "text", text: "follow up" } as any], "user"), ]; const sanitized = sanitizeMessagesForModel(messages); @@ -559,79 +564,39 @@ describe("message-normalizer", () => { expect(sanitized[0].role).toBe("user"); }); - it("drops empty reasoning that precedes working-memory tool calls", () => { - const message = baseMessage([ - { - type: "reasoning", - text: "Planning next steps.", - providerMetadata: { openai: { itemId: "rs_text" } }, - } as any, - { - type: "tool-search", - toolCallId: "call-1", - state: "output-available", - output: { result: true }, - } as any, - { - type: "reasoning", - text: "", - providerMetadata: { openai: { itemId: "rs_empty" } }, - } as any, - { - type: "tool-update_working_memory", - toolCallId: "call-2", - state: "output-available", - output: { type: "text", value: "ok" }, - } as any, - { type: "step-start" } as any, - { type: "text", text: "final answer" } as any, - ]); - - const sanitized = sanitizeMessageForModel(message); - expect(sanitized).not.toBeNull(); - const parts = (sanitized as UIMessage).parts; - expect(parts.some((part: any) => part.type === "tool-update_working_memory")).toBe(false); - expect( - parts.some( - (part: any) => - part.type === "reasoning" && part.providerMetadata?.openai?.itemId === "rs_empty", - ), - ).toBe(false); - expect(parts.some((part: any) => part.type === "reasoning")).toBe(true); - expect(parts.some((part: any) => part.type === "text")).toBe(true); - }); + it("keeps tool calls when OpenAI reasoning items exist in the conversation", () => { + const messages: UIMessage[] = [ + baseMessage([ + { + type: "reasoning", + text: "", + providerMetadata: { openai: { itemId: "rs_123" } }, + } as any, + ]), + baseMessage([ + { + type: "tool-search", + toolCallId: "call-123", + state: "input-available", + input: { query: "hello" }, + callProviderMetadata: { openai: { itemId: "fc_123" } }, + } as any, + ]), + baseMessage([{ type: "text", text: "follow up" } as any], "user"), + ]; - it("drops empty reasoning when prunable parts sit before working-memory tools", () => { - const message = baseMessage([ - { - type: "reasoning", - text: "", - providerMetadata: { openai: { itemId: "rs_empty" } }, - } as any, - { - type: "tool-search", - toolCallId: "call-ghost", - } as any, - { - type: "tool-update_working_memory", - toolCallId: "call-mem", - state: "output-available", - output: { type: "text", value: "ok" }, - } as any, - { type: "text", text: "final answer" } as any, - ]); + const sanitized = sanitizeMessagesForModel(messages); - const sanitized = sanitizeMessageForModel(message); - expect(sanitized).not.toBeNull(); - const parts = (sanitized as UIMessage).parts; - expect( - parts.some( - (part: any) => - part.type === "reasoning" && part.providerMetadata?.openai?.itemId === "rs_empty", - ), - ).toBe(false); - expect(parts.some((part: any) => part.type === "tool-update_working_memory")).toBe(false); - expect(parts.some((part: any) => part.type === "text")).toBe(true); + expect(sanitized).toHaveLength(2); + const toolParts = sanitized[0].parts.filter( + (part: any) => typeof part.type === "string" && part.type.startsWith("tool-"), + ); + expect(toolParts).toHaveLength(1); + expect(toolParts[0]).toMatchObject({ + type: "tool-search", + toolCallId: "call-123", + callProviderMetadata: { openai: { itemId: "fc_123" } }, + }); }); it("preserves approval responses on the last assistant message", () => { diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index 18b488112..9f579d8cf 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -284,14 +284,6 @@ const isToolOutputState = (state: string | undefined): boolean => state === "output-denied" || state === "output-streaming"; -const hasToolInput = (part: ToolLikePart): boolean => { - const state = typeof part.state === "string" ? part.state : undefined; - if (isToolInputState(state)) { - return true; - } - return part.input !== undefined; -}; - const isApprovalResponded = (part: ToolLikePart): boolean => Boolean((part as any).approval && (part as any).approval.approved != null); @@ -301,148 +293,6 @@ const isWorkingMemoryTool = (part: ToolLikePart): boolean => { return WORKING_MEMORY_TOOL_NAMES.has(toolName); }; -const isWorkingMemoryToolPart = (part: UIMessagePart): boolean => { - if (typeof part.type !== "string") { - return false; - } - if (!part.type.startsWith("tool-")) { - return false; - } - const toolName = toolNameFromType(part.type); - if (!toolName) { - return false; - } - return WORKING_MEMORY_TOOL_NAMES.has(toolName); -}; - -const isEmptyTextPart = (part: UIMessagePart): boolean => { - if (part.type !== "text") { - return false; - } - const text = typeof (part as any).text === "string" ? (part as any).text : ""; - return text.trim().length === 0; -}; - -const isEmptyReasoningPart = (part: UIMessagePart): boolean => { - if (part.type !== "reasoning") { - return false; - } - const text = typeof (part as any).text === "string" ? (part as any).text : ""; - return text.trim().length === 0; -}; - -const isPrunableToolPart = (part: UIMessagePart): boolean => { - if (typeof part.type !== "string" || !part.type.startsWith("tool-")) { - return false; - } - const state = typeof (part as any).state === "string" ? (part as any).state : undefined; - const hasPendingState = isToolInputState(state); - const hasResult = isToolOutputState(state) || (part as any).output !== undefined; - if (hasPendingState || hasResult || (part as any).input !== undefined) { - return false; - } - return (part as any).input == null; -}; - -const isPrunablePartBeforeWorkingMemory = (part: UIMessagePart): boolean => { - if (part.type === "step-start") { - return true; - } - if (part.type === "file" && !isObject(part as any)) { - return true; - } - if (part.type === "file" && !(part as any).url) { - return true; - } - if (isEmptyTextPart(part)) { - return true; - } - if (isWorkingMemoryToolPart(part)) { - return true; - } - if (isPrunableToolPart(part)) { - return true; - } - return false; -}; - -const shouldDropEmptyReasoningBeforeWorkingMemory = ( - parts: UIMessagePart[], - index: number, -): boolean => { - const part = parts[index]; - if (part.type !== "reasoning") { - return false; - } - - if (!isEmptyReasoningPart(part)) { - return false; - } - - for (let nextIndex = index + 1; nextIndex < parts.length; nextIndex += 1) { - const next = parts[nextIndex]; - if (isPrunablePartBeforeWorkingMemory(next)) { - if (isWorkingMemoryToolPart(next)) { - return true; - } - continue; - } - - if (isWorkingMemoryToolPart(next)) { - return true; - } - - return false; - } - - return false; -}; - -const removeEmptyReasoningWithOnlyToolOutputs = ( - parts: UIMessagePart[], -): UIMessagePart[] => { - const hasText = parts.some( - (part) => - part.type === "text" && - typeof (part as any).text === "string" && - (part as any).text.trim().length > 0, - ); - if (hasText) { - return parts; - } - - const toolParts = parts.filter((part) => isToolLikePart(part)) as ToolLikePart[]; - if (toolParts.length === 0) { - return parts; - } - - const hasOutput = toolParts.some((part) => hasToolOutput(part)); - const hasInput = toolParts.some((part) => hasToolInput(part)); - if (!hasOutput || hasInput) { - return parts; - } - - return parts.filter((part) => !isEmptyReasoningPart(part)); -}; - -const dropOrphanedEmptyReasoning = ( - parts: UIMessagePart[], -): UIMessagePart[] => { - const nonReasoning = parts.filter((part) => part.type !== "reasoning"); - if (nonReasoning.length > 0) { - return parts; - } - - const hasNonEmptyReasoning = parts.some( - (part) => part.type === "reasoning" && !isEmptyReasoningPart(part), - ); - if (hasNonEmptyReasoning) { - return parts; - } - - return []; -}; - const normalizeToolOutputPayload = (output: unknown): unknown => { if (Array.isArray(output)) { return output.map((item) => normalizeToolOutputPayload(item)); @@ -501,6 +351,157 @@ const normalizeToolPart = (part: ToolLikePart): UIMessagePart | null = return normalized as UIMessagePart; }; +const hasOpenAIReasoningInMessages = (messages: UIMessage[]): boolean => + messages.some( + (message) => message.role === "assistant" && hasOpenAIReasoningContext(message.parts), + ); + +const countToolLikeParts = (messages: UIMessage[]): number => + messages.reduce( + (count, message) => count + message.parts.filter((part) => isToolLikePart(part)).length, + 0, + ); + +const isOpenAIReasoningPart = (part: UIMessagePart): boolean => { + if (part.type !== "reasoning") { + return false; + } + + const reasoningId = + typeof (part as any).reasoningId === "string" ? (part as any).reasoningId.trim() : ""; + if (reasoningId && isOpenAIReasoningId(reasoningId)) { + return true; + } + + const providerMetadata = (part as any).providerMetadata; + if (isObject(providerMetadata)) { + const openai = providerMetadata.openai; + if (isObject(openai)) { + const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : ""; + if (itemId && isOpenAIReasoningId(itemId)) { + return true; + } + if (typeof openai.reasoning_trace_id === "string" && openai.reasoning_trace_id.trim()) { + return true; + } + if (isObject(openai.reasoning)) { + const id = typeof openai.reasoning.id === "string" ? openai.reasoning.id.trim() : ""; + if (id && isOpenAIReasoningId(id)) { + return true; + } + } + } + } + + return false; +}; + +const endsWithOpenAIReasoning = (parts: UIMessagePart[]): boolean => { + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (part.type === "step-start") { + continue; + } + return isOpenAIReasoningPart(part); + } + return false; +}; + +const hasOpenAIItemId = (metadata: unknown): boolean => { + if (!isObject(metadata)) { + return false; + } + const openai = (metadata as { openai?: unknown }).openai; + if (!isObject(openai)) { + return false; + } + const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : ""; + return Boolean(itemId); +}; + +const hasOpenAIItemIdForPart = (part: UIMessagePart): boolean => { + if (typeof part.type === "string" && part.type.startsWith("tool-")) { + if (hasOpenAIItemId((part as any).callProviderMetadata)) { + return true; + } + } + return hasOpenAIItemId((part as any).providerMetadata); +}; + +const stripDanglingOpenAIReasoning = (messages: UIMessage[]): UIMessage[] => { + const result: UIMessage[] = []; + + for (const message of messages) { + if (message.role !== "assistant") { + result.push(message); + continue; + } + + const parts: UIMessagePart[] = []; + for (let index = 0; index < message.parts.length; index += 1) { + const part = message.parts[index]; + if (!isOpenAIReasoningPart(part)) { + parts.push(part); + continue; + } + + let next: UIMessagePart | undefined; + for (let nextIndex = index + 1; nextIndex < message.parts.length; nextIndex += 1) { + const candidate = message.parts[nextIndex]; + if (candidate.type === "step-start") { + continue; + } + next = candidate; + break; + } + + if (!next) { + continue; + } + if (isOpenAIReasoningPart(next)) { + continue; + } + if (!hasOpenAIItemIdForPart(next)) { + continue; + } + + parts.push(part); + } + + if (parts.length === 0) { + continue; + } + + result.push({ + ...message, + parts, + ...(message.metadata ? { metadata: safeClone(message.metadata) } : {}), + }); + } + + return result; +}; + +const mergeTrailingReasoningAssistantMessages = (messages: UIMessage[]): UIMessage[] => { + const merged: UIMessage[] = []; + + for (const message of messages) { + const last = merged.at(-1); + if ( + last && + last.role === "assistant" && + message.role === "assistant" && + endsWithOpenAIReasoning(last.parts) + ) { + last.parts = [...last.parts, ...message.parts]; + continue; + } + merged.push(message); + } + + return merged; +}; + export const sanitizeMessagesForModel = ( messages: UIMessage[], options: SanitizeMessagesOptions = {}, @@ -509,24 +510,30 @@ export const sanitizeMessagesForModel = ( .map((message) => sanitizeMessageForModel(message)) .filter((message): message is UIMessage => Boolean(message)); + const merged = mergeTrailingReasoningAssistantMessages(sanitized); const shouldFilterIncomplete = options.filterIncompleteToolCalls !== false; - const filtered = shouldFilterIncomplete - ? filterIncompleteToolCallsForModel(sanitized) - : sanitized; + if (!shouldFilterIncomplete) { + return addStepStartsBetweenToolRuns(stripDanglingOpenAIReasoning(merged)); + } + + const filtered = filterIncompleteToolCallsForModel(merged); + const hasOpenAIReasoning = hasOpenAIReasoningInMessages(merged); + if (hasOpenAIReasoning) { + const sanitizedToolCount = countToolLikeParts(merged); + const filteredToolCount = countToolLikeParts(filtered); + if (filteredToolCount < sanitizedToolCount) { + return addStepStartsBetweenToolRuns(stripDanglingOpenAIReasoning(merged)); + } + } - return addStepStartsBetweenToolRuns(filtered); + return addStepStartsBetweenToolRuns(stripDanglingOpenAIReasoning(filtered)); }; export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => { const sanitizedParts: UIMessagePart[] = []; - for (let index = 0; index < message.parts.length; index += 1) { - const part = message.parts[index]; - if (shouldDropEmptyReasoningBeforeWorkingMemory(message.parts, index)) { - continue; - } - + for (const part of message.parts) { const normalized = normalizeGenericPart(part); if (!normalized) { continue; @@ -537,9 +544,8 @@ export const sanitizeMessageForModel = (message: UIMessage): UIMessage | null => const pruned = collapseRedundantStepStarts(pruneEmptyToolRuns(sanitizedParts)); const withoutDanglingTools = removeProviderExecutedToolsWithoutReasoning(pruned); const normalizedParts = stripReasoningLinkedProviderMetadata(withoutDanglingTools); - const trimmedReasoning = removeEmptyReasoningWithOnlyToolOutputs(normalizedParts); - const effectiveParts = trimmedReasoning.filter((part) => { + const effectiveParts = normalizedParts.filter((part) => { if (part.type === "text") { return typeof (part as any).text === "string" && (part as any).text.trim().length > 0; } @@ -640,17 +646,12 @@ const filterIncompleteToolCallsForModel = (messages: UIMessage[]): UIMessage[] = continue; } - const withoutOrphanedReasoning = mutated ? dropOrphanedEmptyReasoning(pruned) : pruned; - if (withoutOrphanedReasoning.length === 0) { - continue; - } - - if (!mutated && withoutOrphanedReasoning.length === message.parts.length) { + if (!mutated && pruned.length === message.parts.length) { filtered.push(message); } else { filtered.push({ ...message, - parts: withoutOrphanedReasoning, + parts: pruned, }); } } diff --git a/packages/core/src/agent/model-message-normalizer.spec.ts b/packages/core/src/agent/model-message-normalizer.spec.ts new file mode 100644 index 000000000..12885fd7a --- /dev/null +++ b/packages/core/src/agent/model-message-normalizer.spec.ts @@ -0,0 +1,114 @@ +import type { ModelMessage } from "@ai-sdk/provider-utils"; +import { describe, expect, it } from "vitest"; + +import { stripDanglingOpenAIReasoningFromModelMessages } from "./model-message-normalizer"; + +describe("model-message-normalizer", () => { + it("drops dangling OpenAI reasoning items", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "", + providerOptions: { openai: { itemId: "rs_only" } }, + }, + ], + } as ModelMessage, + { + role: "user", + content: "hello", + }, + ]; + + const sanitized = stripDanglingOpenAIReasoningFromModelMessages(messages); + + expect(sanitized).toHaveLength(1); + expect(sanitized[0].role).toBe("user"); + }); + + it("drops reasoning when the next part lacks an OpenAI itemId", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "", + providerOptions: { openai: { itemId: "rs_drop" } }, + }, + { + type: "text", + text: "keep this", + }, + ], + } as ModelMessage, + ]; + + const sanitized = stripDanglingOpenAIReasoningFromModelMessages(messages); + const content = sanitized[0].content as any[]; + + expect(content).toHaveLength(1); + expect(content[0]).toEqual({ type: "text", text: "keep this" }); + }); + + it("keeps reasoning when followed by an OpenAI itemId part", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "", + providerOptions: { openai: { itemId: "rs_keep" } }, + }, + { + type: "text", + text: "paired", + providerOptions: { openai: { itemId: "msg_keep" } }, + }, + ], + } as ModelMessage, + ]; + + const sanitized = stripDanglingOpenAIReasoningFromModelMessages(messages); + const content = sanitized[0].content as any[]; + + expect(content).toHaveLength(2); + expect(content[0].type).toBe("reasoning"); + expect(content[1].type).toBe("text"); + }); + + it("drops earlier reasoning when another reasoning follows", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "", + providerOptions: { openai: { itemId: "rs_first" } }, + }, + { + type: "reasoning", + text: "", + providerOptions: { openai: { itemId: "rs_second" } }, + }, + { + type: "text", + text: "paired", + providerOptions: { openai: { itemId: "msg_keep" } }, + }, + ], + } as ModelMessage, + ]; + + const sanitized = stripDanglingOpenAIReasoningFromModelMessages(messages); + const content = sanitized[0].content as any[]; + + expect(content).toHaveLength(2); + expect(content[0].providerOptions).toEqual({ openai: { itemId: "rs_second" } }); + expect(content[1].type).toBe("text"); + }); +}); diff --git a/packages/core/src/agent/model-message-normalizer.ts b/packages/core/src/agent/model-message-normalizer.ts new file mode 100644 index 000000000..9e679fc88 --- /dev/null +++ b/packages/core/src/agent/model-message-normalizer.ts @@ -0,0 +1,112 @@ +import type { ModelMessage } from "@ai-sdk/provider-utils"; + +const OPENAI_REASONING_ID_PREFIX = "rs_"; + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const hasOpenAIItemId = (providerOptions: unknown): boolean => { + if (!isObject(providerOptions)) { + return false; + } + + const openai = (providerOptions as { openai?: unknown }).openai; + if (!isObject(openai)) { + return false; + } + + const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : ""; + return itemId.length > 0; +}; + +const isOpenAIReasoningId = (value: string): boolean => + value.trim().startsWith(OPENAI_REASONING_ID_PREFIX); + +const isOpenAIReasoningPart = (part: unknown): boolean => { + if (!isObject(part)) { + return false; + } + if ((part as { type?: unknown }).type !== "reasoning") { + return false; + } + + const providerOptions = (part as { providerOptions?: unknown }).providerOptions; + if (hasOpenAIItemId(providerOptions)) { + const openai = (providerOptions as { openai?: unknown }).openai as { itemId?: unknown }; + const itemId = typeof openai?.itemId === "string" ? openai.itemId.trim() : ""; + if (itemId && isOpenAIReasoningId(itemId)) { + return true; + } + } + + const reasoningId = + typeof (part as { id?: unknown }).id === "string" ? (part as any).id.trim() : ""; + return Boolean(reasoningId && isOpenAIReasoningId(reasoningId)); +}; + +const hasOpenAIItemIdForPart = (part: unknown): boolean => { + if (!isObject(part)) { + return false; + } + const providerOptions = (part as { providerOptions?: unknown }).providerOptions; + return hasOpenAIItemId(providerOptions); +}; + +export const stripDanglingOpenAIReasoningFromModelMessages = ( + messages: ModelMessage[], +): ModelMessage[] => { + let changed = false; + + const sanitized = messages + .map((message) => { + if (message.role !== "assistant" || !Array.isArray(message.content)) { + return message; + } + + const parts = []; + const content = message.content as unknown[]; + + for (let index = 0; index < content.length; index += 1) { + const part = content[index]; + if (!isOpenAIReasoningPart(part)) { + parts.push(part); + continue; + } + + const next = content[index + 1]; + + if (!next) { + changed = true; + continue; + } + if (isOpenAIReasoningPart(next)) { + changed = true; + continue; + } + if (!hasOpenAIItemIdForPart(next)) { + changed = true; + continue; + } + + parts.push(part); + } + + if (parts.length === content.length) { + return message; + } + + changed = true; + + if (parts.length === 0) { + return null; + } + + return { + ...message, + content: parts as ModelMessage["content"], + } satisfies ModelMessage; + }) + .filter((message): message is ModelMessage => Boolean(message)); + + return changed ? sanitized : messages; +}; diff --git a/packages/core/src/memory/manager/memory-manager.spec.ts b/packages/core/src/memory/manager/memory-manager.spec.ts index bfc633b08..3750e83d5 100644 --- a/packages/core/src/memory/manager/memory-manager.spec.ts +++ b/packages/core/src/memory/manager/memory-manager.spec.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { sanitizeMessagesForModel } from "../../agent/message-normalizer"; import type { OperationContext } from "../../agent/types"; import { getGlobalLogger } from "../../logger"; import { Memory } from "../../memory"; @@ -162,47 +161,6 @@ describe("MemoryManager", () => { expect(messages[1].id).toBe("msg-2"); }); - it("drops empty reasoning that precedes working-memory tool calls on reload", async () => { - const context = createMockOperationContext(); - - const assistantMessage = createTestUIMessage({ - id: "assistant-1", - role: "assistant", - parts: [ - { - type: "reasoning", - text: "", - providerMetadata: { openai: { itemId: "rs_123" } }, - } as any, - { - type: "tool-search", - toolCallId: "call-123", - state: "input-available", - input: { query: "hello" }, - } as any, - { - type: "tool-update_working_memory", - toolCallId: "call-999", - state: "output-available", - output: { type: "text", value: "ok" }, - } as any, - ], - }); - - await manager.saveMessage(context, assistantMessage, "user-1", "conv-1"); - - const reloaded = await manager.getMessages(context, "user-1", "conv-1"); - const nextTurn = { - id: "user-2", - role: "user", - parts: [{ type: "text", text: "next turn" }], - }; - - const sanitized = sanitizeMessagesForModel([...reloaded, nextTurn]); - expect(sanitized).toHaveLength(1); - expect(sanitized[0].role).toBe("user"); - }); - it("should return empty array when memory is disabled", async () => { const disabledManager = new MemoryManager("agent-3", false); From de0cf2d4ba4f1b1aded71d4ac4556ac266fc5871 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 5 Feb 2026 18:56:25 -0600 Subject: [PATCH 14/14] refactor(core): modularize OpenAI reasoning utilities and enhance message sanitization --- packages/core/src/agent/message-normalizer.ts | 87 +++++++------------ .../src/agent/model-message-normalizer.ts | 72 +++++---------- .../core/src/agent/openai-reasoning-utils.ts | 78 +++++++++++++++++ 3 files changed, 128 insertions(+), 109 deletions(-) create mode 100644 packages/core/src/agent/openai-reasoning-utils.ts diff --git a/packages/core/src/agent/message-normalizer.ts b/packages/core/src/agent/message-normalizer.ts index 9f579d8cf..ea163abf8 100644 --- a/packages/core/src/agent/message-normalizer.ts +++ b/packages/core/src/agent/message-normalizer.ts @@ -1,13 +1,19 @@ +import { safeStringify } from "@voltagent/internal"; import type { UIMessage, UIMessagePart } from "ai"; +import { + hasOpenAIItemIdForPart as hasOpenAIItemIdForPartBase, + isObject, + isOpenAIReasoningId, + stripDanglingOpenAIReasoningFromParts, +} from "./openai-reasoning-utils"; + const WORKING_MEMORY_TOOL_NAMES = new Set([ "update_working_memory", "get_working_memory", "clear_working_memory", ]); -const OPENAI_REASONING_ID_PREFIX = "rs_"; - type ToolLikePart = UIMessagePart & { toolCallId?: string; state?: string; @@ -27,12 +33,6 @@ type SanitizeMessagesOptions = { filterIncompleteToolCalls?: boolean; }; -const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const isOpenAIReasoningId = (value: string): boolean => - value.trim().startsWith(OPENAI_REASONING_ID_PREFIX); - const safeClone = (value: T): T => { if (!isObject(value) && !Array.isArray(value)) { return value; @@ -47,7 +47,7 @@ const safeClone = (value: T): T => { } try { - return JSON.parse(JSON.stringify(value)) as T; + return JSON.parse(safeStringify(value)) as T; } catch (_error) { if (Array.isArray(value)) { return value.slice() as T; @@ -407,25 +407,13 @@ const endsWithOpenAIReasoning = (parts: UIMessagePart[]): boolean => { return false; }; -const hasOpenAIItemId = (metadata: unknown): boolean => { - if (!isObject(metadata)) { - return false; - } - const openai = (metadata as { openai?: unknown }).openai; - if (!isObject(openai)) { - return false; - } - const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : ""; - return Boolean(itemId); -}; - const hasOpenAIItemIdForPart = (part: UIMessagePart): boolean => { - if (typeof part.type === "string" && part.type.startsWith("tool-")) { - if (hasOpenAIItemId((part as any).callProviderMetadata)) { - return true; - } - } - return hasOpenAIItemId((part as any).providerMetadata); + return hasOpenAIItemIdForPartBase(part, { + isToolPart: (candidate) => + typeof (candidate as any).type === "string" && (candidate as any).type.startsWith("tool-"), + getCallProviderMetadata: (candidate) => (candidate as any).callProviderMetadata, + getProviderMetadata: (candidate) => (candidate as any).providerMetadata, + }); }; const stripDanglingOpenAIReasoning = (messages: UIMessage[]): UIMessage[] => { @@ -437,36 +425,20 @@ const stripDanglingOpenAIReasoning = (messages: UIMessage[]): UIMessage[] => { continue; } - const parts: UIMessagePart[] = []; - for (let index = 0; index < message.parts.length; index += 1) { - const part = message.parts[index]; - if (!isOpenAIReasoningPart(part)) { - parts.push(part); - continue; - } - - let next: UIMessagePart | undefined; - for (let nextIndex = index + 1; nextIndex < message.parts.length; nextIndex += 1) { - const candidate = message.parts[nextIndex]; - if (candidate.type === "step-start") { - continue; + const { parts } = stripDanglingOpenAIReasoningFromParts(message.parts, { + isReasoningPart: isOpenAIReasoningPart, + hasOpenAIItemIdForPart, + getNextPart: (parts, index) => { + for (let nextIndex = index + 1; nextIndex < parts.length; nextIndex += 1) { + const candidate = parts[nextIndex]; + if (candidate.type === "step-start") { + continue; + } + return candidate; } - next = candidate; - break; - } - - if (!next) { - continue; - } - if (isOpenAIReasoningPart(next)) { - continue; - } - if (!hasOpenAIItemIdForPart(next)) { - continue; - } - - parts.push(part); - } + return undefined; + }, + }); if (parts.length === 0) { continue; @@ -496,7 +468,7 @@ const mergeTrailingReasoningAssistantMessages = (messages: UIMessage[]): UIMessa last.parts = [...last.parts, ...message.parts]; continue; } - merged.push(message); + merged.push({ ...message, parts: [...message.parts] }); } return merged; @@ -523,6 +495,7 @@ export const sanitizeMessagesForModel = ( const sanitizedToolCount = countToolLikeParts(merged); const filteredToolCount = countToolLikeParts(filtered); if (filteredToolCount < sanitizedToolCount) { + // Keep the merged set to avoid orphaning reasoning item references when tools were removed. return addStepStartsBetweenToolRuns(stripDanglingOpenAIReasoning(merged)); } } diff --git a/packages/core/src/agent/model-message-normalizer.ts b/packages/core/src/agent/model-message-normalizer.ts index 9e679fc88..e3e5164e1 100644 --- a/packages/core/src/agent/model-message-normalizer.ts +++ b/packages/core/src/agent/model-message-normalizer.ts @@ -1,26 +1,12 @@ import type { ModelMessage } from "@ai-sdk/provider-utils"; -const OPENAI_REASONING_ID_PREFIX = "rs_"; - -const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const hasOpenAIItemId = (providerOptions: unknown): boolean => { - if (!isObject(providerOptions)) { - return false; - } - - const openai = (providerOptions as { openai?: unknown }).openai; - if (!isObject(openai)) { - return false; - } - - const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : ""; - return itemId.length > 0; -}; - -const isOpenAIReasoningId = (value: string): boolean => - value.trim().startsWith(OPENAI_REASONING_ID_PREFIX); +import { + hasOpenAIItemId, + hasOpenAIItemIdForPart as hasOpenAIItemIdForPartBase, + isObject, + isOpenAIReasoningId, + stripDanglingOpenAIReasoningFromParts, +} from "./openai-reasoning-utils"; const isOpenAIReasoningPart = (part: unknown): boolean => { if (!isObject(part)) { @@ -48,8 +34,9 @@ const hasOpenAIItemIdForPart = (part: unknown): boolean => { if (!isObject(part)) { return false; } - const providerOptions = (part as { providerOptions?: unknown }).providerOptions; - return hasOpenAIItemId(providerOptions); + return hasOpenAIItemIdForPartBase(part, { + getProviderMetadata: (value) => (value as { providerOptions?: unknown }).providerOptions, + }); }; export const stripDanglingOpenAIReasoningFromModelMessages = ( @@ -63,35 +50,14 @@ export const stripDanglingOpenAIReasoningFromModelMessages = ( return message; } - const parts = []; const content = message.content as unknown[]; + const { parts, changed: partsChanged } = stripDanglingOpenAIReasoningFromParts(content, { + isReasoningPart: isOpenAIReasoningPart, + hasOpenAIItemIdForPart, + getNextPart: (parts, index) => parts[index + 1], + }); - for (let index = 0; index < content.length; index += 1) { - const part = content[index]; - if (!isOpenAIReasoningPart(part)) { - parts.push(part); - continue; - } - - const next = content[index + 1]; - - if (!next) { - changed = true; - continue; - } - if (isOpenAIReasoningPart(next)) { - changed = true; - continue; - } - if (!hasOpenAIItemIdForPart(next)) { - changed = true; - continue; - } - - parts.push(part); - } - - if (parts.length === content.length) { + if (!partsChanged) { return message; } @@ -101,9 +67,11 @@ export const stripDanglingOpenAIReasoningFromModelMessages = ( return null; } + const assistantMessage = message as Extract; + return { - ...message, - content: parts as ModelMessage["content"], + ...assistantMessage, + content: parts as typeof assistantMessage.content, } satisfies ModelMessage; }) .filter((message): message is ModelMessage => Boolean(message)); diff --git a/packages/core/src/agent/openai-reasoning-utils.ts b/packages/core/src/agent/openai-reasoning-utils.ts new file mode 100644 index 000000000..329688d31 --- /dev/null +++ b/packages/core/src/agent/openai-reasoning-utils.ts @@ -0,0 +1,78 @@ +export const OPENAI_REASONING_ID_PREFIX = "rs_"; + +export const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export const isOpenAIReasoningId = (value: string): boolean => + value.trim().startsWith(OPENAI_REASONING_ID_PREFIX); + +export const hasOpenAIItemId = (metadata: unknown): boolean => { + if (!isObject(metadata)) { + return false; + } + const openai = (metadata as { openai?: unknown }).openai; + if (!isObject(openai)) { + return false; + } + const itemId = typeof openai.itemId === "string" ? openai.itemId.trim() : ""; + return Boolean(itemId); +}; + +type OpenAIItemIdAccessors = { + getProviderMetadata: (part: TPart) => unknown; + isToolPart?: (part: TPart) => boolean; + getCallProviderMetadata?: (part: TPart) => unknown; +}; + +export const hasOpenAIItemIdForPart = ( + part: TPart, + accessors: OpenAIItemIdAccessors, +): boolean => { + if (accessors.isToolPart?.(part)) { + const callProviderMetadata = accessors.getCallProviderMetadata?.(part); + if (callProviderMetadata !== undefined && hasOpenAIItemId(callProviderMetadata)) { + return true; + } + } + return hasOpenAIItemId(accessors.getProviderMetadata(part)); +}; + +type StripDanglingOpenAIReasoningOptions = { + isReasoningPart: (part: TPart) => boolean; + hasOpenAIItemIdForPart: (part: TPart) => boolean; + getNextPart: (parts: TPart[], index: number) => TPart | undefined; +}; + +export const stripDanglingOpenAIReasoningFromParts = ( + parts: TPart[], + options: StripDanglingOpenAIReasoningOptions, +): { parts: TPart[]; changed: boolean } => { + const sanitized: TPart[] = []; + let changed = false; + + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (!options.isReasoningPart(part)) { + sanitized.push(part); + continue; + } + + const next = options.getNextPart(parts, index); + if (!next) { + changed = true; + continue; + } + if (options.isReasoningPart(next)) { + changed = true; + continue; + } + if (!options.hasOpenAIItemIdForPart(next)) { + changed = true; + continue; + } + + sanitized.push(part); + } + + return { parts: sanitized, changed }; +};