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
6 changes: 6 additions & 0 deletions .changeset/drop-orphan-reasoning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@voltagent/core": patch
---

Drop orphan reasoning parts when tool-only messages are sanitized.
Fixes #1019.
3 changes: 3 additions & 0 deletions packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down
171 changes: 171 additions & 0 deletions packages/core/src/agent/message-normalizer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,51 @@ 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",
]);
});
Comment on lines +150 to +179
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Explore codebase structure
cd packages/core || exit 1
find . -name "message-normalizer*" -type f | head -20

Repository: VoltAgent/voltagent

Length of output: 135


🏁 Script executed:

#!/bin/bash
# Get the implementation file to understand pruneEmptyToolRuns logic
cd packages/core
find . -name "*.ts" -not -name "*.spec.ts" | xargs rg "pruneEmptyToolRuns" -l

Repository: VoltAgent/voltagent

Length of output: 96


🏁 Script executed:

#!/bin/bash
# Read the pruneEmptyToolRuns implementation
cd packages/core
cat -n src/agent/message-normalizer.ts | sed -n '600,650p'

Repository: VoltAgent/voltagent

Length of output: 1762


🏁 Script executed:

#!/bin/bash
# Also check if there's more context around the function
cd packages/core
cat -n src/agent/message-normalizer.ts | sed -n '615,640p'

Repository: VoltAgent/voltagent

Length of output: 1082


🏁 Script executed:

#!/bin/bash
# Run the specific test mentioned in the review
cd packages/core
npx vitest run src/agent/message-normalizer.spec.ts -t "keeps tool runs with pending, error, or denied states" 2>&1

Repository: VoltAgent/voltagent

Length of output: 1100


🏁 Script executed:

#!/bin/bash
# Read the test directly to understand exact expectations
cd packages/core
cat -n src/agent/message-normalizer.spec.ts | sed -n '150,185p'

Repository: VoltAgent/voltagent

Length of output: 1331


🏁 Script executed:

#!/bin/bash
# Check the sanitizeMessageForModel function to see all steps in the flow
cd packages/core
cat -n src/agent/message-normalizer.ts | grep -n "sanitizeMessageForModel" | head -5

Repository: VoltAgent/voltagent

Length of output: 223


🏁 Script executed:

#!/bin/bash
# Read the sanitizeMessageForModel function
cd packages/core
cat -n src/agent/message-normalizer.ts | sed -n '444,500p'

Repository: VoltAgent/voltagent

Length of output: 2380


🏁 Script executed:

#!/bin/bash
# Verify if errorText is used elsewhere or if there's a pattern for handling error states
cd packages/core
rg "errorText\|output-error\|output-denied" src/agent/message-normalizer.ts -B2 -A2

Repository: VoltAgent/voltagent

Length of output: 45


🏁 Script executed:

#!/bin/bash
# Check if there's any special handling for error states in the type definitions
cd packages/core
rg "output-error|output-denied" src/ -t ts --max-count 20

Repository: VoltAgent/voltagent

Length of output: 899


🏁 Script executed:

#!/bin/bash
# Find the exact context of the line checking output-error and output-denied
cd packages/core
rg "output-available.*output-error.*output-denied|output-error.*output-denied" src/agent/message-normalizer.ts -B5 -A5

Repository: VoltAgent/voltagent

Length of output: 424


🏁 Script executed:

#!/bin/bash
# Look for normalizeToolRun or similar functions that might handle tool normalization
cd packages/core
ast-grep --pattern 'function normalize$_($$$) { $$$ }' src/agent/message-normalizer.ts

Repository: VoltAgent/voltagent

Length of output: 45


🏁 Script executed:

#!/bin/bash
# Find where hasToolOutput is used
cd packages/core
rg "hasToolOutput" src/agent/message-normalizer.ts -B3 -A3

Repository: VoltAgent/voltagent

Length of output: 476


🏁 Script executed:

#!/bin/bash
# Look for normalizeTool or tool part normalization
cd packages/core
cat -n src/agent/message-normalizer.ts | grep -n "tool-\|normalizeTool\|normalizeGenericPart" | head -30

Repository: VoltAgent/voltagent

Length of output: 1419


🏁 Script executed:

#!/bin/bash
# Look at normalizeToolPart implementation
cd packages/core
cat -n src/agent/message-normalizer.ts | sed -n '389,420p'

Repository: VoltAgent/voltagent

Length of output: 1615


🏁 Script executed:

#!/bin/bash
# Find the full context of where hasToolOutput is used
cd packages/core
rg "hasToolOutput" src/agent/message-normalizer.ts -B10 -A5

Repository: VoltAgent/voltagent

Length of output: 980


🏁 Script executed:

#!/bin/bash
# Check what line hasToolOutput is defined and see if it matches pruneEmptyToolRuns logic
cd packages/core
rg -n "const hasToolOutput|const pruneEmptyToolRuns" src/agent/message-normalizer.ts

Repository: VoltAgent/voltagent

Length of output: 221


🏁 Script executed:

#!/bin/bash
# Get exact line numbers and context for both functions
cd packages/core
cat -n src/agent/message-normalizer.ts | grep -A8 "const hasToolOutput = " | head -15

Repository: VoltAgent/voltagent

Length of output: 472


🏁 Script executed:

#!/bin/bash
# Compare the two functions side by side
cd packages/core
echo "=== hasToolOutput ===" && cat -n src/agent/message-normalizer.ts | sed -n '256,266p' && echo "" && echo "=== pruneEmptyToolRuns ===" && cat -n src/agent/message-normalizer.ts | sed -n '620,635p'

Repository: VoltAgent/voltagent

Length of output: 1275


Update pruneEmptyToolRuns to recognize output-error and output-denied states as valid outputs.

The test expects tool parts with output-error and output-denied states to be preserved, but pruneEmptyToolRuns (lines 620-635) only recognizes output-available and the output property as valid results. It should mirror the logic in hasToolOutput (line 264), which correctly identifies these states as having meaningful content.

The mismatch causes output-error and output-denied parts without an input property to be incorrectly pruned. Update the hasResult check to include:

const hasResult =
  (part as any).state === "output-available" || 
  (part as any).state === "output-error" || 
  (part as any).state === "output-denied" || 
  (part as any).output !== undefined;
🤖 Prompt for AI Agents
In `@packages/core/src/agent/message-normalizer.spec.ts` around lines 150 - 179,
pruneEmptyToolRuns currently treats only "output-available" or presence of
output as a valid result, causing parts with "output-error" and "output-denied"
to be pruned; update the hasResult logic inside pruneEmptyToolRuns to mirror
hasToolOutput by treating state values "output-available", "output-error", and
"output-denied" (or a defined output property) as valid results so parts with
those states are preserved during sanitization.


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([
{
Expand Down Expand Up @@ -390,6 +435,97 @@ describe("message-normalizer", () => {
expect(toolPart.callProviderMetadata).toEqual({ openai: { itemId: "fc_openai" } });
});

it("drops OpenAI reasoning when no following item exists", () => {
const messages: UIMessage[] = [
baseMessage([
{
type: "reasoning",
text: "",
providerMetadata: { openai: { itemId: "rs_only" } },
} as any,
]),
];

const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false });

expect(sanitized).toHaveLength(0);
});

it("drops OpenAI reasoning when the next item lacks an OpenAI itemId", () => {
const messages: UIMessage[] = [
baseMessage([
{
type: "reasoning",
text: "",
providerMetadata: { openai: { itemId: "rs_no_follow" } },
} as any,
{
type: "text",
text: "still keep this text",
} as any,
]),
];

const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false });

expect(sanitized).toHaveLength(1);
const parts = sanitized[0].parts;
expect(parts).toHaveLength(1);
expect(parts[0]).toEqual({ type: "text", text: "still keep this text" });
});

it("preserves OpenAI tool metadata when reasoning exists in another message", () => {
const messages: UIMessage[] = [
baseMessage([
{
type: "reasoning",
text: "",
providerMetadata: { openai: { itemId: "rs_cross" } },
} as any,
]),
baseMessage([
{
type: "tool-search",
toolCallId: "call-cross",
state: "input-available",
input: { query: "hello" },
callProviderMetadata: { openai: { itemId: "fc_cross" } },
} as any,
]),
];

const sanitized = sanitizeMessagesForModel(messages, { filterIncompleteToolCalls: false });
expect(sanitized).toHaveLength(1);
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("preserves OpenAI text metadata when reasoning exists in another message", () => {
const messages: UIMessage[] = [
baseMessage([
{
type: "reasoning",
text: "",
providerMetadata: { openai: { itemId: "rs_cross_text" } },
} as any,
]),
baseMessage([
{
type: "text",
text: "final answer",
providerMetadata: { openai: { itemId: "msg_cross" } },
} as any,
]),
];

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("sanitizes collections while preserving message ordering", () => {
const messages: UIMessage[] = [
baseMessage([
Expand Down Expand Up @@ -428,6 +564,41 @@ describe("message-normalizer", () => {
expect(sanitized[0].role).toBe("user");
});

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"),
];

const sanitized = sanitizeMessagesForModel(messages);

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", () => {
const messages: UIMessage[] = [
baseMessage([
Expand Down
Loading