mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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,
|
private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt,
|
||||||
String conversationId, String phase,
|
String conversationId, String phase,
|
||||||
boolean broadcast) {
|
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 调用前检查停止标志
|
// 在开始 LLM 调用前检查停止标志
|
||||||
if (streamTracker.isStopRequested(conversationId)) {
|
if (streamTracker.isStopRequested(conversationId)) {
|
||||||
log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, 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());
|
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) {
|
private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
|
||||||
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
|
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
|
||||||
if (streamTracker != null && conversationId != null) {
|
if (streamTracker != null && conversationId != null) {
|
||||||
|
|||||||
@ -119,4 +119,48 @@ class NodeStreamingChatHelperToolCallArgsTest {
|
|||||||
assertEquals(validArgs, result.toolCalls().get(0).arguments(),
|
assertEquals(validArgs, result.toolCalls().get(0).arguments(),
|
||||||
"valid JSON arguments must not be rewritten");
|
"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