mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
fix(toolcall): support canonical xml params and guard json shadowing
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
1. **示例保护**:若判定为 fenced code block 示例上下文,则跳过执行型解析。
|
||||
2. **候选片段构建**:从完整文本中构建候选(原文、围绕 `tool_calls` 的 JSON 片段、首尾大括号切片等)。
|
||||
3. **按序尝试解析(命中即停)**:
|
||||
- XML 解析(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / `antml:function_call` 等);
|
||||
- 对“明显 JSON 工具载荷候选”(以 `{`/`[` 开头且包含 `tool_calls`/`\"function\"`)先走 JSON 解析,避免 JSON 字符串内偶发 XML 片段误命中;
|
||||
- 其余候选优先 XML 解析(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / `antml:function_call` 等);
|
||||
- JSON 解析(`{"tool_calls": [...]}`、列表、单对象);
|
||||
- Markup 解析;
|
||||
- Text-KV 回退(如 `function.name:` + `function.arguments:`)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -162,6 +162,34 @@ func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) {
|
||||
text := `<tool_call><tool_name>get_weather</tool_name><parameters>{"city":"beijing","unit":"c"}</parameters></tool_call>`
|
||||
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 <tool_call><tool_name>wrong</tool_name><parameters>{\"x\":1}</parameters></tool_call>"}}]}`
|
||||
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 := `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command></parameters></tool_call>`
|
||||
res := ParseToolCallsDetailed(text, []string{"bash"})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "search",
|
||||
"input": {
|
||||
"q": "latest <tool_call><tool_name>wrong</tool_name><parameters>{\"x\":1}</parameters></tool_call>"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"input": {
|
||||
"city": "beijing",
|
||||
"unit": "c"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"text": "{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"latest <tool_call><tool_name>wrong</tool_name><parameters>{\\\"x\\\":1}</parameters></tool_call>\"}}]}",
|
||||
"tool_names": [
|
||||
"search"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"text": "<tool_call><tool_name>get_weather</tool_name><parameters>{\"city\":\"beijing\",\"unit\":\"c\"}</parameters></tool_call>",
|
||||
"tool_names": [
|
||||
"get_weather"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user