From 56a3ed19e85767ea7278c0620f3ada16f062466a Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sun, 29 Mar 2026 11:15:52 +0800 Subject: [PATCH] fix(toolcall): support canonical xml params and guard json shadowing --- docs/toolcall-semantics.md | 3 +- .../js/helpers/stream-tool-sieve/parse.js | 42 ++++++++++++++++++ .../stream-tool-sieve/parse_payload.js | 2 + internal/util/toolcalls_parse.go | 43 +++++++++++++++++++ internal/util/toolcalls_parse_markup.go | 28 ++++++++++++ internal/util/toolcalls_test.go | 28 ++++++++++++ ...json_payload_with_incidental_xml_text.json | 13 ++++++ ...olcalls_xml_tool_name_parameters_json.json | 14 ++++++ ...json_payload_with_incidental_xml_text.json | 6 +++ .../xml_tool_name_parameters_json.json | 6 +++ 10 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 tests/compat/expected/toolcalls_json_payload_with_incidental_xml_text.json create mode 100644 tests/compat/expected/toolcalls_xml_tool_name_parameters_json.json create mode 100644 tests/compat/fixtures/toolcalls/json_payload_with_incidental_xml_text.json create mode 100644 tests/compat/fixtures/toolcalls/xml_tool_name_parameters_json.json diff --git a/docs/toolcall-semantics.md b/docs/toolcall-semantics.md index e3bae97..8399f1c 100644 --- a/docs/toolcall-semantics.md +++ b/docs/toolcall-semantics.md @@ -16,7 +16,8 @@ 1. **示例保护**:若判定为 fenced code block 示例上下文,则跳过执行型解析。 2. **候选片段构建**:从完整文本中构建候选(原文、围绕 `tool_calls` 的 JSON 片段、首尾大括号切片等)。 3. **按序尝试解析(命中即停)**: - - XML 解析(`` / `` / `` / `tool_use` / `antml:function_call` 等); + - 对“明显 JSON 工具载荷候选”(以 `{`/`[` 开头且包含 `tool_calls`/`\"function\"`)先走 JSON 解析,避免 JSON 字符串内偶发 XML 片段误命中; + - 其余候选优先 XML 解析(`` / `` / `` / `tool_use` / `antml:function_call` 等); - JSON 解析(`{"tool_calls": [...]}`、列表、单对象); - Markup 解析; - Text-KV 回退(如 `function.name:` + `function.arguments:`)。 diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index 6b0318f..0930c08 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -52,6 +52,21 @@ function parseToolCallsDetailed(text, toolNames) { } const candidates = buildToolCallCandidates(normalized); + for (const c of candidates) { + if (!isLikelyJSONToolPayloadCandidate(c)) { + continue; + } + const jsonParsed = parseToolCallsPayload(c); + if (jsonParsed.length === 0) { + continue; + } + result.sawToolCallSyntax = true; + const filteredJSON = filterToolCallsDetailed(jsonParsed, toolNames); + result.calls = filteredJSON.calls; + result.rejectedToolNames = filteredJSON.rejectedToolNames; + result.rejectedByPolicy = filteredJSON.rejectedToolNames.length > 0 && filteredJSON.calls.length === 0; + return result; + } let parsed = []; for (const c of candidates) { parsed = parseMarkupToolCalls(c); @@ -100,6 +115,21 @@ function parseStandaloneToolCallsDetailed(text, toolNames) { } const candidates = buildToolCallCandidates(trimmed); let parsed = []; + for (const c of candidates) { + if (!isLikelyJSONToolPayloadCandidate(c)) { + continue; + } + parsed = parseToolCallsPayload(c); + if (parsed.length === 0) { + continue; + } + result.sawToolCallSyntax = true; + const filteredJSON = filterToolCallsDetailed(parsed, toolNames); + result.calls = filteredJSON.calls; + result.rejectedToolNames = filteredJSON.rejectedToolNames; + result.rejectedByPolicy = filteredJSON.rejectedToolNames.length > 0 && filteredJSON.calls.length === 0; + return result; + } for (const c of candidates) { parsed = parseMarkupToolCalls(c); if (parsed.length === 0) { @@ -198,6 +228,18 @@ function shouldSkipToolCallParsingForCodeFenceExample(text) { return !looksLikeToolCallSyntax(stripped); } +function isLikelyJSONToolPayloadCandidate(text) { + const trimmed = toStringSafe(text).trim(); + if (!trimmed) { + return false; + } + if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) { + return false; + } + const lower = trimmed.toLowerCase(); + return lower.includes('tool_calls') || lower.includes('"function"'); +} + module.exports = { extractToolNames, parseToolCalls, diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index eb27008..e658be5 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -6,6 +6,8 @@ const TOOL_CALL_MARKUP_SELFCLOSE_PATTERN = /<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)\/ const TOOL_CALL_MARKUP_KV_PATTERN = /<(?:[a-z0-9_:-]+:)?([a-z0-9_.-]+)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi; const TOOL_CALL_MARKUP_ATTR_PATTERN = /(name|function|tool)\s*=\s*"([^"]+)"/i; const TOOL_CALL_MARKUP_NAME_PATTERNS = [ + /<(?:[a-z0-9_:-]+:)?tool_name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?tool_name>/i, + /<(?:[a-z0-9_:-]+:)?function_name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?function_name>/i, /<(?:[a-z0-9_:-]+:)?name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?name>/i, /<(?:[a-z0-9_:-]+:)?function\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?function>/i, ]; diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index ebd1552..7fe8068 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -32,6 +32,22 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa } candidates := buildToolCallCandidates(text) + for _, candidate := range candidates { + if !isLikelyJSONToolPayloadCandidate(candidate) { + continue + } + tc := parseToolCallsPayload(candidate) + if len(tc) == 0 { + continue + } + parsed := tc + calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames) + result.Calls = calls + result.RejectedToolNames = rejectedNames + result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0 + result.SawToolCallSyntax = true + return result + } var parsed []ParsedToolCall for _, candidate := range candidates { tc := parseXMLToolCalls(candidate) @@ -83,6 +99,21 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) } candidates := buildToolCallCandidates(trimmed) var parsed []ParsedToolCall + for _, candidate := range candidates { + if !isLikelyJSONToolPayloadCandidate(candidate) { + continue + } + parsed = parseToolCallsPayload(candidate) + if len(parsed) == 0 { + continue + } + result.SawToolCallSyntax = true + calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames) + result.Calls = calls + result.RejectedToolNames = rejectedNames + result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0 + return result + } for _, candidate := range candidates { candidate = strings.TrimSpace(candidate) if candidate == "" { @@ -165,6 +196,18 @@ func parseToolCallsPayload(payload string) []ParsedToolCall { return nil } +func isLikelyJSONToolPayloadCandidate(candidate string) bool { + trimmed := strings.TrimSpace(candidate) + if trimmed == "" { + return false + } + if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) { + return false + } + lower := strings.ToLower(trimmed) + return strings.Contains(lower, "tool_calls") || strings.Contains(lower, "\"function\"") +} + func isLikelyChatMessageEnvelope(v map[string]any) bool { if v == nil { return false diff --git a/internal/util/toolcalls_parse_markup.go b/internal/util/toolcalls_parse_markup.go index 36c0618..1d3741f 100644 --- a/internal/util/toolcalls_parse_markup.go +++ b/internal/util/toolcalls_parse_markup.go @@ -104,6 +104,34 @@ func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) { } case "parameters": inParams = true + var node struct { + Inner string `xml:",innerxml"` + } + if err := dec.DecodeElement(&node, &t); err == nil { + inner := strings.TrimSpace(node.Inner) + if inner != "" { + if parsed := parseToolCallInput(inner); len(parsed) > 0 { + if len(parsed) == 1 { + if _, onlyRaw := parsed["_raw"]; onlyRaw { + if kv := parseMarkupKVObject(inner); len(kv) > 0 { + for k, vv := range kv { + params[k] = vv + } + break + } + } + } + for k, vv := range parsed { + params[k] = vv + } + } else if kv := parseMarkupKVObject(inner); len(kv) > 0 { + for k, vv := range kv { + params[k] = vv + } + } + } + } + inParams = false case "tool_name", "name": var v string if err := dec.DecodeElement(&v, &t); err == nil && strings.TrimSpace(v) != "" { diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index c5fbd52..44dc929 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -162,6 +162,34 @@ func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) { } } +func TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) { + text := `get_weather{"city":"beijing","unit":"c"}` + calls := ParseToolCalls(text, []string{"get_weather"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %#v", calls) + } + if calls[0].Name != "get_weather" { + t.Fatalf("expected tool name get_weather, got %q", calls[0].Name) + } + if calls[0].Input["city"] != "beijing" || calls[0].Input["unit"] != "c" { + t.Fatalf("expected parsed json parameters, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsPrefersJSONPayloadOverIncidentalXMLInString(t *testing.T) { + text := `{"tool_calls":[{"name":"search","input":{"q":"latest wrong{\"x\":1}"}}]}` + calls := ParseToolCallsDetailed(text, []string{"search"}).Calls + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %#v", calls) + } + if calls[0].Name != "search" { + t.Fatalf("expected tool name search, got %q", calls[0].Name) + } + if calls[0].Input["q"] == nil { + t.Fatalf("expected q argument from json payload, got %#v", calls[0].Input) + } +} + func TestParseToolCallsDetailedMarksXMLToolCallSyntax(t *testing.T) { text := `Bashpwd` res := ParseToolCallsDetailed(text, []string{"bash"}) diff --git a/tests/compat/expected/toolcalls_json_payload_with_incidental_xml_text.json b/tests/compat/expected/toolcalls_json_payload_with_incidental_xml_text.json new file mode 100644 index 0000000..467d0de --- /dev/null +++ b/tests/compat/expected/toolcalls_json_payload_with_incidental_xml_text.json @@ -0,0 +1,13 @@ +{ + "calls": [ + { + "name": "search", + "input": { + "q": "latest wrong{\"x\":1}" + } + } + ], + "sawToolCallSyntax": true, + "rejectedByPolicy": false, + "rejectedToolNames": [] +} diff --git a/tests/compat/expected/toolcalls_xml_tool_name_parameters_json.json b/tests/compat/expected/toolcalls_xml_tool_name_parameters_json.json new file mode 100644 index 0000000..8eabce0 --- /dev/null +++ b/tests/compat/expected/toolcalls_xml_tool_name_parameters_json.json @@ -0,0 +1,14 @@ +{ + "calls": [ + { + "name": "get_weather", + "input": { + "city": "beijing", + "unit": "c" + } + } + ], + "sawToolCallSyntax": true, + "rejectedByPolicy": false, + "rejectedToolNames": [] +} diff --git a/tests/compat/fixtures/toolcalls/json_payload_with_incidental_xml_text.json b/tests/compat/fixtures/toolcalls/json_payload_with_incidental_xml_text.json new file mode 100644 index 0000000..598f9a4 --- /dev/null +++ b/tests/compat/fixtures/toolcalls/json_payload_with_incidental_xml_text.json @@ -0,0 +1,6 @@ +{ + "text": "{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"latest wrong{\\\"x\\\":1}\"}}]}", + "tool_names": [ + "search" + ] +} diff --git a/tests/compat/fixtures/toolcalls/xml_tool_name_parameters_json.json b/tests/compat/fixtures/toolcalls/xml_tool_name_parameters_json.json new file mode 100644 index 0000000..6ccd51e --- /dev/null +++ b/tests/compat/fixtures/toolcalls/xml_tool_name_parameters_json.json @@ -0,0 +1,6 @@ +{ + "text": "get_weather{\"city\":\"beijing\",\"unit\":\"c\"}", + "tool_names": [ + "get_weather" + ] +}