mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 04:14:46 +08:00
* fix(web): handle incomplete JSON in MCP tool call arguments (#528) When using stream_mode=["messages", "updates"] with MCP tools, tool call arguments arrive in chunks that may be incomplete JSON (missing closing braces). This caused JSON.parse() to throw errors in the frontend. Changes: - Add safeParseToolArgs() function using best-effort-json-parser to gracefully handle incomplete JSON from streaming - Replace direct JSON.parse() with safe parser in mergeMessage() - Add comprehensive tests for tool call argument parsing scenarios * Address the code review comments
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import { parse as bestEffortParse } from "best-effort-json-parser";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChatEvent,
|
ChatEvent,
|
||||||
InterruptEvent,
|
InterruptEvent,
|
||||||
@@ -13,6 +15,34 @@ import { deepClone } from "../utils/deep-clone";
|
|||||||
|
|
||||||
import type { Message } from "./types";
|
import type { Message } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse JSON from streamed tool call argument chunks.
|
||||||
|
* Uses best-effort-json-parser to handle incomplete JSON from streaming.
|
||||||
|
* This addresses issue #528 where MCP tool call arguments may be incomplete
|
||||||
|
* when using stream_mode="messages".
|
||||||
|
*/
|
||||||
|
function safeParseToolArgs(argsString: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
// First try standard JSON.parse for complete JSON
|
||||||
|
return JSON.parse(argsString) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
// If standard parsing fails, use best-effort parser for incomplete JSON
|
||||||
|
try {
|
||||||
|
const result = bestEffortParse(argsString);
|
||||||
|
// Ensure we return an object
|
||||||
|
if (result && typeof result === "object" && !Array.isArray(result)) {
|
||||||
|
return result as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
// If parsing returns something unexpected, wrap in an object
|
||||||
|
return { _parsed: result };
|
||||||
|
} catch {
|
||||||
|
// If all parsing fails, return empty object
|
||||||
|
console.warn("Failed to parse tool call arguments:", argsString);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeMessage(message: Message, event: ChatEvent) {
|
export function mergeMessage(message: Message, event: ChatEvent) {
|
||||||
if (event.type === "message_chunk") {
|
if (event.type === "message_chunk") {
|
||||||
mergeTextMessage(message, event);
|
mergeTextMessage(message, event);
|
||||||
@@ -29,7 +59,7 @@ export function mergeMessage(message: Message, event: ChatEvent) {
|
|||||||
if (message.toolCalls) {
|
if (message.toolCalls) {
|
||||||
message.toolCalls.forEach((toolCall) => {
|
message.toolCalls.forEach((toolCall) => {
|
||||||
if (toolCall.argsChunks?.length) {
|
if (toolCall.argsChunks?.length) {
|
||||||
toolCall.args = JSON.parse(toolCall.argsChunks.join(""));
|
toolCall.args = safeParseToolArgs(toolCall.argsChunks.join(""));
|
||||||
delete toolCall.argsChunks;
|
delete toolCall.argsChunks;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
333
web/tests/merge-message.test.ts
Normal file
333
web/tests/merge-message.test.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for Issue #528 Fix: MCP Tool Call Argument Parsing
|
||||||
|
*
|
||||||
|
* These tests verify:
|
||||||
|
* - Complete JSON tool call arguments parsing
|
||||||
|
* - Incomplete JSON tool call arguments handling (using best-effort-json-parser)
|
||||||
|
* - Multiple argument chunks merging
|
||||||
|
* - Escaped character conversion in tool call arguments
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mergeMessage } from "../src/core/messages/merge-message";
|
||||||
|
import type { Message, ToolCallRuntime } from "../src/core/messages/types";
|
||||||
|
import type {
|
||||||
|
ChatEvent,
|
||||||
|
ToolCallChunksEvent,
|
||||||
|
ToolCallsEvent,
|
||||||
|
} from "../src/core/api/types";
|
||||||
|
|
||||||
|
function createBaseMessage(): Message {
|
||||||
|
return {
|
||||||
|
id: "test-msg-1",
|
||||||
|
threadId: "thread-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
contentChunks: [],
|
||||||
|
isStreaming: true,
|
||||||
|
agent: "researcher",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToolCall(
|
||||||
|
overrides: Partial<ToolCallRuntime> & { id: string; name: string }
|
||||||
|
): ToolCallRuntime {
|
||||||
|
return {
|
||||||
|
args: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mergeMessage", () => {
|
||||||
|
describe("tool call argument parsing", () => {
|
||||||
|
it("should parse complete JSON tool call arguments", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
message.toolCalls = [
|
||||||
|
createToolCall({
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
argsChunks: ['{"query": "test query"}'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const event: ChatEvent = {
|
||||||
|
type: "message_chunk",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeMessage(message, event);
|
||||||
|
expect(result.toolCalls?.[0]?.args).toEqual({ query: "test query" });
|
||||||
|
expect(result.toolCalls?.[0]?.argsChunks).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle incomplete JSON tool call arguments (issue #528)", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
// Simulate incomplete JSON from streaming - missing closing brace
|
||||||
|
message.toolCalls = [
|
||||||
|
createToolCall({
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
argsChunks: ['{"query": "test query"'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const event: ChatEvent = {
|
||||||
|
type: "message_chunk",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw an error and should attempt to parse
|
||||||
|
const result = mergeMessage(message, event);
|
||||||
|
expect(result.toolCalls?.[0]?.args).toBeDefined();
|
||||||
|
expect(result.toolCalls?.[0]?.argsChunks).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple argument chunks", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
message.toolCalls = [
|
||||||
|
createToolCall({
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
argsChunks: ['{"query":', ' "test', ' query"', "}"],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const event: ChatEvent = {
|
||||||
|
type: "message_chunk",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeMessage(message, event);
|
||||||
|
expect(result.toolCalls?.[0]?.args).toEqual({ query: "test query" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle incomplete nested JSON", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
// Simulate incomplete nested JSON from streaming
|
||||||
|
message.toolCalls = [
|
||||||
|
createToolCall({
|
||||||
|
id: "call-1",
|
||||||
|
name: "complex_tool",
|
||||||
|
argsChunks: ['{"query": "test", "options": {"limit": 10'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const event: ChatEvent = {
|
||||||
|
type: "message_chunk",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw an error
|
||||||
|
const result = mergeMessage(message, event);
|
||||||
|
expect(result.toolCalls?.[0]?.args).toBeDefined();
|
||||||
|
expect(result.toolCalls?.[0]?.argsChunks).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty args string gracefully", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
message.toolCalls = [
|
||||||
|
createToolCall({
|
||||||
|
id: "call-1",
|
||||||
|
name: "simple_tool",
|
||||||
|
argsChunks: [""],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const event: ChatEvent = {
|
||||||
|
type: "message_chunk",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw an error
|
||||||
|
const result = mergeMessage(message, event);
|
||||||
|
expect(result.toolCalls?.[0]?.args).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle MCP tool call with complex arguments", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
// Simulate MCP tool with complex arguments, partially streamed
|
||||||
|
message.toolCalls = [
|
||||||
|
createToolCall({
|
||||||
|
id: "call-1",
|
||||||
|
name: "mcp_github_tool",
|
||||||
|
argsChunks: [
|
||||||
|
'{"repo": "bytedance/deer-flow", "action": "get_trending", "params": {"limit": 5, "language": "python"',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const event: ChatEvent = {
|
||||||
|
type: "message_chunk",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeMessage(message, event);
|
||||||
|
const args = result.toolCalls?.[0]?.args;
|
||||||
|
expect(args).toBeDefined();
|
||||||
|
// The best-effort parser should recover the available data
|
||||||
|
expect(args?.repo).toBe("bytedance/deer-flow");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool call chunks merging", () => {
|
||||||
|
it("should accumulate tool call chunks", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
|
||||||
|
// First chunk with tool call ID
|
||||||
|
const event1: ToolCallsEvent = {
|
||||||
|
type: "tool_calls",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
args: { query: "test" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool_call_chunks: [
|
||||||
|
{
|
||||||
|
type: "tool_call_chunk",
|
||||||
|
index: 0,
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
args: '{"query": "test"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result1 = mergeMessage(message, event1);
|
||||||
|
expect(result1.toolCalls).toBeDefined();
|
||||||
|
expect(result1.toolCalls?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert escaped characters and parse args end-to-end", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
|
||||||
|
// First event: tool call with escaped JSON characters
|
||||||
|
const event1: ToolCallsEvent = {
|
||||||
|
type: "tool_calls",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: "tool_call",
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
args: { query: "test" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool_call_chunks: [
|
||||||
|
{
|
||||||
|
type: "tool_call_chunk",
|
||||||
|
index: 0,
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
args: '{"query": "test"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result1 = mergeMessage(message, event1);
|
||||||
|
// Verify escaped chars were converted in argsChunks
|
||||||
|
expect(result1.toolCalls?.[0]?.argsChunks?.[0]).toBe('{"query": "test"}');
|
||||||
|
|
||||||
|
// Second event: finish reason triggers safeParseToolArgs
|
||||||
|
const finishEvent: ChatEvent = {
|
||||||
|
type: "message_chunk",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result2 = mergeMessage(result1, finishEvent);
|
||||||
|
// Verify args were successfully parsed
|
||||||
|
expect(result2.toolCalls?.[0]?.args).toEqual({ query: "test" });
|
||||||
|
expect(result2.toolCalls?.[0]?.argsChunks).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should convert escaped characters in args", () => {
|
||||||
|
const message = createBaseMessage();
|
||||||
|
message.toolCalls = [
|
||||||
|
createToolCall({
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const event: ToolCallChunksEvent = {
|
||||||
|
type: "tool_call_chunks",
|
||||||
|
data: {
|
||||||
|
id: "test-msg-1",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
agent: "researcher",
|
||||||
|
role: "assistant",
|
||||||
|
tool_call_chunks: [
|
||||||
|
{
|
||||||
|
type: "tool_call_chunk",
|
||||||
|
index: 0,
|
||||||
|
id: "call-1",
|
||||||
|
name: "search",
|
||||||
|
args: "{[test]}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mergeMessage(message, event);
|
||||||
|
// The argsChunks should have converted the escaped chars
|
||||||
|
expect(result.toolCalls?.[0]?.argsChunks?.[0]).toBe("{[test]}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user