mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
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.
This commit is contained in:
parent
e0ce5e2ea7
commit
7705778903
@ -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<Message> messages = prompt.getInstructions();
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return prompt;
|
||||
}
|
||||
List<Message> 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<AssistantMessage.ToolCall> fixedCalls = null;
|
||||
List<AssistantMessage.ToolCall> 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) {
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user