From 77057789031141ec2f2a6fb0d486558a08704ce0 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 26 Jun 2026 11:19:19 +0800 Subject: [PATCH] fix(agent): normalize replayed tool-call arguments to valid JSON (#410) Strict OpenAI-compatible providers reject the /chat/completions request with HTTP 400 when an assistant message in history carries a tool call whose function.arguments is not parseable JSON. Normalize blank or non-JSON arguments to "{}" at the send chokepoint so streaming, history-replay, and older-persisted tool calls all stay well-formed. --- .../agent/graph/NodeStreamingChatHelper.java | 69 +++++++++++++++++++ ...deStreamingChatHelperToolCallArgsTest.java | 44 ++++++++++++ 2 files changed, 113 insertions(+) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index b378c137..ed178f8a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -585,6 +585,15 @@ public class NodeStreamingChatHelper { private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt, String conversationId, String phase, boolean broadcast) { + // Normalize every assistant tool call in the outgoing history to valid + // JSON arguments. The streaming aggregator already does this for the + // current turn's calls, but tool calls replayed from persisted history + // (e.g. an earlier MCP tool call with empty arguments, or messages + // stored by an older build) bypass that path. Strict providers reject + // the whole request with HTTP 400 when any function.arguments is not + // parseable JSON, so harmonize them here at the single send chokepoint. + prompt = normalizeToolCallArguments(prompt); + // 在开始 LLM 调用前检查停止标志 if (streamTracker.isStopRequested(conversationId)) { log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, conversationId); @@ -1539,6 +1548,66 @@ public class NodeStreamingChatHelper { return new Prompt(new ArrayList<>(messages.subList(0, end)), prompt.getOptions()); } + /** + * Rebuild any {@link AssistantMessage} whose tool calls carry blank or + * non-JSON {@code function.arguments} so the entire outgoing prompt stays + * acceptable to strict OpenAI-compatible providers (e.g. aliyun-codingplan, + * which 400s the whole request otherwise). Messages with no tool calls, or + * whose tool-call arguments are already valid JSON, pass through untouched — + * preserving content, metadata, and media. Returns the input unchanged when + * nothing needs fixing. + */ + static Prompt normalizeToolCallArguments(Prompt prompt) { + if (prompt == null) { + return null; + } + List messages = prompt.getInstructions(); + if (messages == null || messages.isEmpty()) { + return prompt; + } + List rebuilt = null; + for (int i = 0; i < messages.size(); i++) { + Message m = messages.get(i); + if (!(m instanceof AssistantMessage am) + || am.getToolCalls() == null || am.getToolCalls().isEmpty()) { + if (rebuilt != null) rebuilt.add(m); + continue; + } + List fixedCalls = null; + List calls = am.getToolCalls(); + for (int j = 0; j < calls.size(); j++) { + AssistantMessage.ToolCall tc = calls.get(j); + String safe = sanitizeToolCallArguments(tc.name(), tc.arguments()); + if (!safe.equals(tc.arguments())) { + if (fixedCalls == null) fixedCalls = new ArrayList<>(calls); + fixedCalls.set(j, new AssistantMessage.ToolCall(tc.id(), tc.type(), tc.name(), safe)); + } + } + if (fixedCalls == null) { + if (rebuilt != null) rebuilt.add(m); + continue; + } + if (rebuilt == null) { + rebuilt = new ArrayList<>(messages.subList(0, i)); + } + AssistantMessage.Builder builder = AssistantMessage.builder() + .content(am.getText() == null ? "" : am.getText()) + .toolCalls(fixedCalls); + if (am.getMetadata() != null && !am.getMetadata().isEmpty()) { + builder.properties(am.getMetadata()); + } + if (am.getMedia() != null && !am.getMedia().isEmpty()) { + builder.media(am.getMedia()); + } + rebuilt.add(builder.build()); + } + if (rebuilt == null) { + return prompt; + } + log.debug("[normalizeToolCallArguments] normalized non-JSON tool-call arguments in outgoing prompt"); + return new Prompt(rebuilt, prompt.getOptions()); + } + private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) { log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg); if (streamTracker != null && conversationId != null) { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java index acedb909..8b10e1ac 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java @@ -119,4 +119,48 @@ class NodeStreamingChatHelperToolCallArgsTest { assertEquals(validArgs, result.toolCalls().get(0).arguments(), "valid JSON arguments must not be rewritten"); } + + @Test + @DisplayName("Prompt-history tool call with empty arguments normalized before send") + void promptHistory_emptyArguments_normalized() { + // Mirrors a tool call replayed from persisted history (e.g. an earlier + // MCP call with no arguments): it never passes through the streaming + // aggregator, so the prompt-level pass must repair it. + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-hist", "function", "mcp_excel_import", ""); + AssistantMessage historyMsg = AssistantMessage.builder() + .content("calling tool") + .toolCalls(List.of(tc)) + .build(); + Prompt prompt = new Prompt(List.of(new UserMessage("hi"), historyMsg)); + + Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(prompt); + + AssistantMessage out = (AssistantMessage) normalized.getInstructions().get(1); + assertEquals(1, out.getToolCalls().size()); + assertEquals("{}", out.getToolCalls().get(0).arguments(), + "replayed empty arguments must be normalized to '{}'"); + assertEquals("calling tool", out.getText(), "assistant content must be preserved"); + assertEquals("mcp_excel_import", out.getToolCalls().get(0).name(), + "tool name must be preserved"); + assertEquals("id-hist", out.getToolCalls().get(0).id(), + "tool call id must be preserved so the tool_call pairing holds"); + } + + @Test + @DisplayName("Prompt with only valid tool-call arguments returned unchanged") + void promptHistory_validArguments_returnsSameInstance() { + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-ok", "function", "search", "{\"q\":\"x\"}"); + AssistantMessage historyMsg = AssistantMessage.builder() + .content("") + .toolCalls(List.of(tc)) + .build(); + Prompt prompt = new Prompt(List.of(new UserMessage("hi"), historyMsg)); + + Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(prompt); + + assertTrue(normalized == prompt, + "a prompt that needs no fix must be returned unchanged (no copy)"); + } }