feat(util): 增加对混杂文本中 Tool Call 的 fallback 解析支持

- 引入 parseTextKVToolCalls 解析器以处理混杂文本或带历史记录套壳(如 [TOOL_CALL_HISTORY])输出的函数调用提取。
- 将其作为 JSON 和 XML 的 fallback 解析手段集成到主流程。
- 添加单元测试用例且更新相关语义说明文档。
This commit is contained in:
huangxun
2026-03-09 15:00:16 +08:00
parent fa8affe1b7
commit 0f1985af4a
4 changed files with 121 additions and 3 deletions

View File

@@ -19,7 +19,8 @@ This document defines the cross-runtime contract for `ParseToolCallsDetailed` /
- first `{` to last `}` object slice.
3. Parse each candidate in order:
- JSON payload parser (`tool_calls`, list, single call object),
- markup parser (`<tool_call>`, `<function_call>`, `<invoke>`; supports attributes + nested fields).
- XML/Markup parser (`<tool_call>`, `<function_call>`, `<invoke>`; supports attributes + nested fields),
- Text KV fallback parser (`function.name: <name>` ... `function.arguments: {json}`).
4. Stop at first candidate that yields at least one call.
## Name normalization policy

View File

@@ -45,6 +45,9 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
if len(tc) == 0 {
tc = parseMarkupToolCalls(candidate)
}
if len(tc) == 0 {
tc = parseTextKVToolCalls(candidate)
}
if len(tc) > 0 {
parsed = tc
result.SawToolCallSyntax = true
@@ -54,7 +57,10 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
if len(parsed) == 0 {
parsed = parseXMLToolCalls(text)
if len(parsed) == 0 {
return result
parsed = parseTextKVToolCalls(text)
if len(parsed) == 0 {
return result
}
}
result.SawToolCallSyntax = true
}
@@ -93,6 +99,9 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
if len(parsed) == 0 {
parsed = parseMarkupToolCalls(candidate)
}
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(candidate)
}
if len(parsed) > 0 {
result.SawToolCallSyntax = true
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
@@ -207,7 +216,8 @@ func looksLikeToolCallSyntax(text string) bool {
return strings.Contains(lower, "tool_calls") ||
strings.Contains(lower, "<tool_call") ||
strings.Contains(lower, "<function_call") ||
strings.Contains(lower, "<invoke")
strings.Contains(lower, "<invoke") ||
strings.Contains(lower, "function.name:")
}
func parseToolCallList(v any) []ParsedToolCall {

View File

@@ -0,0 +1,55 @@
package util
import (
"regexp"
"strings"
)
var textKVNamePattern = regexp.MustCompile(`(?is)function\.name:\s*([a-zA-Z0-9_\-.]+)`)
func parseTextKVToolCalls(text string) []ParsedToolCall {
var out []ParsedToolCall
matches := textKVNamePattern.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return nil
}
for i, match := range matches {
name := text[match[2]:match[3]]
offset := match[1]
endSearch := len(text)
if i+1 < len(matches) {
endSearch = matches[i+1][0]
}
searchArea := text[offset:endSearch]
argIdx := strings.Index(searchArea, "function.arguments:")
if argIdx < 0 {
continue
}
startIdx := offset + argIdx + len("function.arguments:")
braceIdx := strings.IndexByte(text[startIdx:endSearch], '{')
if braceIdx < 0 {
continue
}
actualStart := startIdx + braceIdx
objJson, _, ok := extractJSONObject(text, actualStart)
if !ok {
continue
}
input := parseToolCallInput(objJson)
out = append(out, ParsedToolCall{
Name: name,
Input: input,
})
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -0,0 +1,52 @@
package util
import (
"testing"
)
func TestParseTextKVToolCalls_Basic(t *testing.T) {
text := `
[TOOL_CALL_HISTORY]
status: already_called
origin: assistant
not_user_input: true
tool_call_id: call_3fcd15235eb94f7eae3a8de5a9cfa36b
function.name: execute_command
function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}
[/TOOL_CALL_HISTORY]
Some other text thinking...
`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "execute_command" {
t.Fatalf("unexpected name: %s", calls[0].Name)
}
if calls[0].Input["command"] != "cd scripts && python check_syntax.py example.py" {
t.Fatalf("unexpected command arg: %v", calls[0].Input["command"])
}
}
func TestParseTextKVToolCalls_Multiple(t *testing.T) {
text := `
function.name: read_file
function.arguments: {
"path": "abc.txt"
}
function.name: bash
function.arguments: {"command": "ls"}
`
calls := ParseToolCalls(text, []string{"read_file", "bash"})
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if calls[0].Name != "read_file" {
t.Fatalf("unexpected 1st name: %s", calls[0].Name)
}
if calls[1].Name != "bash" {
t.Fatalf("unexpected 2nd name: %s", calls[1].Name)
}
}