fix: align tool call protocol and thinking controls

This commit is contained in:
CJACK
2026-04-26 04:26:51 +08:00
parent f13ad231ac
commit 7475defeca
51 changed files with 799 additions and 489 deletions

View File

@@ -13,18 +13,18 @@ func TestRegression_RobustXMLAndCDATA(t *testing.T) {
}{
{
name: "Standard JSON parameters (Regression)",
text: `<tools><tool_call><tool_name>foo</tool_name><param>{"a": 1}</param></tool_call></tools>`,
expected: []ParsedToolCall{{Name: "foo", Input: map[string]any{"a": float64(1)}}},
text: `<tool_calls><invoke name="foo"><parameter name="a">1</parameter></invoke></tool_calls>`,
expected: []ParsedToolCall{{Name: "foo", Input: map[string]any{"a": "1"}}},
},
{
name: "XML tags parameters (Regression)",
text: `<tools><tool_call><tool_name>foo</tool_name><param><arg1>hello</arg1></param></tool_call></tools>`,
text: `<tool_calls><invoke name="foo"><parameter name="arg1">hello</parameter></invoke></tool_calls>`,
expected: []ParsedToolCall{{Name: "foo", Input: map[string]any{"arg1": "hello"}}},
},
{
name: "CDATA parameters (New Feature)",
text: `<tools><tool_call><tool_name>write_file</tool_name><param><content><![CDATA[line 1
line 2 with <tags> and & symbols]]></content></param></tool_call></tools>`,
text: `<tool_calls><invoke name="write_file"><parameter name="content"><![CDATA[line 1
line 2 with <tags> and & symbols]]></parameter></invoke></tool_calls>`,
expected: []ParsedToolCall{{
Name: "write_file",
Input: map[string]any{"content": "line 1\nline 2 with <tags> and & symbols"},
@@ -32,9 +32,9 @@ line 2 with <tags> and & symbols]]></content></param></tool_call></tools>`,
},
{
name: "Nested XML with repeated parameters (New Feature)",
text: `<tools><tool_call><tool_name>write_file</tool_name><param><path>script.sh</path><content><![CDATA[#!/bin/bash
text: `<tool_calls><invoke name="write_file"><parameter name="path">script.sh</parameter><parameter name="content"><![CDATA[#!/bin/bash
echo "hello"
]]></content><item>first</item><item>second</item></param></tool_call></tools>`,
]]></parameter><parameter name="item">first</parameter><parameter name="item">second</parameter></invoke></tool_calls>`,
expected: []ParsedToolCall{{
Name: "write_file",
Input: map[string]any{
@@ -46,7 +46,7 @@ echo "hello"
},
{
name: "Dirty XML with unescaped symbols (Robustness Improvement)",
text: `<tools><tool_call><tool_name>bash</tool_name><param><command>echo "hello" > out.txt && cat out.txt</command></param></tool_call></tools>`,
text: `<tool_calls><invoke name="bash"><parameter name="command">echo "hello" > out.txt && cat out.txt</parameter></invoke></tool_calls>`,
expected: []ParsedToolCall{{
Name: "bash",
Input: map[string]any{"command": "echo \"hello\" > out.txt && cat out.txt"},
@@ -54,7 +54,7 @@ echo "hello"
},
{
name: "Mixed JSON inside CDATA (New Hybrid Case)",
text: `<tools><tool_call><tool_name>foo</tool_name><param><![CDATA[{"json_param": "works"}]]></param></tool_call></tools>`,
text: `<tool_calls><invoke name="foo"><parameter name="json_param"><![CDATA[works]]></parameter></invoke></tool_calls>`,
expected: []ParsedToolCall{{
Name: "foo",
Input: map[string]any{"json_param": "works"},

View File

@@ -36,93 +36,139 @@ func BuildToolCallInstructions(toolNames []string) string {
return `TOOL CALL FORMAT — FOLLOW EXACTLY:
<tools>
<tool_call>
<tool_name>TOOL_NAME_HERE</tool_name>
<param>
<PARAMETER_NAME><![CDATA[PARAMETER_VALUE]]></PARAMETER_NAME>
</param>
</tool_call>
</tools>
<tool_calls>
<invoke name="TOOL_NAME_HERE">
<parameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></parameter>
</invoke>
</tool_calls>
RULES:
1) Use the <tools> XML wrapper format only.
2) Put one or more <tool_call> entries under a single <tools> root.
3) Use <tool_name> for the tool name and <param> for the argument container.
1) Use the <tool_calls> XML wrapper format only.
2) Put one or more <invoke> entries under a single <tool_calls> root.
3) Put the tool name in the invoke name attribute: <invoke name="TOOL_NAME">.
4) All string values must use <![CDATA[...]]>, even short ones. This includes code, scripts, file contents, prompts, paths, names, and queries.
5) Objects use nested XML elements. Arrays may repeat the same tag or use <item> children.
6) Numbers, booleans, and null stay plain text.
7) Use only the parameter names in the tool schema. Do not invent fields.
8) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue.
5) Every top-level argument must be a <parameter name="ARG_NAME">...</parameter> node.
6) Objects use nested XML elements inside the parameter body. Arrays may repeat <item> children.
7) Numbers, booleans, and null stay plain text.
8) Use only the parameter names in the tool schema. Do not invent fields.
9) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue.
PARAMETER SHAPES:
- string => <name><![CDATA[value]]></name>
- object => nested XML elements
- array => repeated tags or <item> children
- number/bool/null => plain text
- string => <parameter name="x"><![CDATA[value]]></parameter>
- object => <parameter name="x"><field>...</field></parameter>
- array => <parameter name="x"><item>...</item><item>...</item></parameter>
- number/bool/null => <parameter name="x">plain_text</parameter>
【WRONG — Do NOT do these】:
Wrong 1 — mixed text after XML:
<tools>...</tools> I hope this helps.
Wrong 2 — JSON payload inside <param>:
<tool_call><tool_name>` + ex1 + `</tool_name><param>{"path":"x"}</param></tool_call>
Wrong 3 — Markdown code fences:
<tool_calls>...</tool_calls> I hope this helps.
Wrong 2 — Markdown code fences:
` + "```xml" + `
<tools>...</tools>
<tool_calls>...</tool_calls>
` + "```" + `
Remember: The ONLY valid way to use tools is the <tools>...</tools> XML block at the end of your response.
Remember: The ONLY valid way to use tools is the <tool_calls>...</tool_calls> XML block at the end of your response.
【CORRECT EXAMPLES】:
Example A — Single tool:
<tools>
<tool_call>
<tool_name>` + ex1 + `</tool_name>
<param>` + ex1Params + `</param>
</tool_call>
</tools>
<tool_calls>
<invoke name="` + ex1 + `">
` + indentPromptParameters(ex1Params, " ") + `
</invoke>
</tool_calls>
Example B — Two tools in parallel:
<tools>
<tool_call>
<tool_name>` + ex1 + `</tool_name>
<param>` + ex1Params + `</param>
</tool_call>
<tool_call>
<tool_name>` + ex2 + `</tool_name>
<param>` + ex2Params + `</param>
</tool_call>
</tools>
<tool_calls>
<invoke name="` + ex1 + `">
` + indentPromptParameters(ex1Params, " ") + `
</invoke>
<invoke name="` + ex2 + `">
` + indentPromptParameters(ex2Params, " ") + `
</invoke>
<invoke name="Read">
<parameter name="file_path">` + promptCDATA("/abs/path/to/another-file.txt") + `</parameter>
</invoke>
</tool_calls>
Example C — Tool with nested XML parameters:
<tools>
<tool_call>
<tool_name>` + ex3 + `</tool_name>
<param>` + ex3Params + `</param>
</tool_call>
</tools>
<tool_calls>
<invoke name="` + ex3 + `">
` + indentPromptParameters(ex3Params, " ") + `
</invoke>
</tool_calls>
Example D — Tool with long script using CDATA (RELIABLE FOR CODE/SCRIPTS):
<tools>
<tool_call>
<tool_name>` + ex2 + `</tool_name>
<param>
<path>` + promptCDATA("script.sh") + `</path>
<content><![CDATA[
<tool_calls>
<invoke name="` + ex2 + `">
<parameter name="path">` + promptCDATA("script.sh") + `</parameter>
<parameter name="content"><![CDATA[
#!/bin/bash
if [ "$1" == "test" ]; then
echo "Success!"
fi
]]></content>
</param>
</tool_call>
</tools>
]]></parameter>
</invoke>
</tool_calls>
`
}
func indentPromptParameters(body, indent string) string {
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="content"></parameter>`
}
lines := strings.Split(body, "\n")
for i, line := range lines {
if strings.TrimSpace(line) == "" {
lines[i] = line
continue
}
lines[i] = indent + line
}
return strings.Join(lines, "\n")
}
func wrapParameter(name, inner string) string {
return `<parameter name="` + name + `">` + inner + `</parameter>`
}
func exampleReadParams(name string) string {
switch strings.TrimSpace(name) {
case "Read":
return wrapParameter("file_path", promptCDATA("README.md"))
case "Glob":
return wrapParameter("pattern", promptCDATA("**/*.go")) + "\n" + wrapParameter("path", promptCDATA("."))
default:
return wrapParameter("path", promptCDATA("src/main.go"))
}
}
func exampleWriteOrExecParams(name string) string {
switch strings.TrimSpace(name) {
case "Bash", "execute_command":
return wrapParameter("command", promptCDATA("pwd"))
case "exec_command":
return wrapParameter("cmd", promptCDATA("pwd"))
case "Edit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar"))
case "MultiEdit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></parameter>`
default:
return wrapParameter("path", promptCDATA("output.txt")) + "\n" + wrapParameter("content", promptCDATA("Hello world"))
}
}
func exampleInteractiveParams(name string) string {
switch strings.TrimSpace(name) {
case "Task":
return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures"))
default:
return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></parameter>`
}
}
func matchAny(name string, candidates ...string) bool {
for _, c := range candidates {
if name == c {
@@ -132,41 +178,6 @@ func matchAny(name string, candidates ...string) bool {
return false
}
func exampleReadParams(name string) string {
switch strings.TrimSpace(name) {
case "Read":
return `<file_path>` + promptCDATA("README.md") + `</file_path>`
case "Glob":
return `<pattern>` + promptCDATA("**/*.go") + `</pattern><path>` + promptCDATA(".") + `</path>`
default:
return `<path>` + promptCDATA("src/main.go") + `</path>`
}
}
func exampleWriteOrExecParams(name string) string {
switch strings.TrimSpace(name) {
case "Bash", "execute_command":
return `<command>` + promptCDATA("pwd") + `</command>`
case "exec_command":
return `<cmd>` + promptCDATA("pwd") + `</cmd>`
case "Edit":
return `<file_path>` + promptCDATA("README.md") + `</file_path><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string>`
case "MultiEdit":
return `<file_path>` + promptCDATA("README.md") + `</file_path><edits><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></edits>`
default:
return `<path>` + promptCDATA("output.txt") + `</path><content>` + promptCDATA("Hello world") + `</content>`
}
}
func exampleInteractiveParams(name string) string {
switch strings.TrimSpace(name) {
case "Task":
return `<description>` + promptCDATA("Investigate flaky tests") + `</description><prompt>` + promptCDATA("Run targeted tests and summarize failures") + `</prompt>`
default:
return `<question>` + promptCDATA("Which approach do you prefer?") + `</question><follow_up><text>` + promptCDATA("Option A") + `</text></follow_up><follow_up><text>` + promptCDATA("Option B") + `</text></follow_up>`
}
}
func promptCDATA(text string) string {
if text == "" {
return ""

View File

@@ -7,20 +7,20 @@ import (
func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"exec_command"})
if !strings.Contains(out, `<tool_name>exec_command</tool_name>`) {
if !strings.Contains(out, `<invoke name="exec_command">`) {
t.Fatalf("expected exec_command in examples, got: %s", out)
}
if !strings.Contains(out, `<param><cmd><![CDATA[pwd]]></cmd></param>`) {
if !strings.Contains(out, `<parameter name="cmd"><![CDATA[pwd]]></parameter>`) {
t.Fatalf("expected cmd parameter example for exec_command, got: %s", out)
}
}
func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"execute_command"})
if !strings.Contains(out, `<tool_name>execute_command</tool_name>`) {
if !strings.Contains(out, `<invoke name="execute_command">`) {
t.Fatalf("expected execute_command in examples, got: %s", out)
}
if !strings.Contains(out, `<param><command><![CDATA[pwd]]></command></param>`) {
if !strings.Contains(out, `<parameter name="command"><![CDATA[pwd]]></parameter>`) {
t.Fatalf("expected command parameter example for execute_command, got: %s", out)
}
}

View File

@@ -74,12 +74,7 @@ func filterToolCallsDetailed(parsed []ParsedToolCall) ([]ParsedToolCall, []strin
func looksLikeToolCallSyntax(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "<tools") ||
strings.Contains(lower, "<tool_call") ||
strings.Contains(lower, "<attempt_completion") ||
strings.Contains(lower, "<ask_followup_question") ||
strings.Contains(lower, "<new_task") ||
strings.Contains(lower, "<result")
return strings.Contains(lower, "<tool_calls")
}
func stripFencedCodeBlocks(text string) string {

View File

@@ -7,12 +7,13 @@ import (
"strings"
)
var xmlToolsWrapperPattern = regexp.MustCompile(`(?is)<tools\b[^>]*>\s*(.*?)\s*</tools>`)
var xmlToolCallPattern = regexp.MustCompile(`(?is)<tool_call\b[^>]*>\s*(.*?)\s*</tool_call>`)
var xmlCanonicalToolCallBodyPattern = regexp.MustCompile(`(?is)^\s*<(?:[a-z0-9_:-]+:)?tool_name\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?tool_name>\s*<(?:[a-z0-9_:-]+:)?param\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?param>\s*$`)
var xmlToolCallsWrapperPattern = regexp.MustCompile(`(?is)<tool_calls\b[^>]*>\s*(.*?)\s*</tool_calls>`)
var xmlInvokePattern = regexp.MustCompile(`(?is)<invoke\b([^>]*)>\s*(.*?)\s*</invoke>`)
var xmlParameterPattern = regexp.MustCompile(`(?is)<parameter\b([^>]*)>\s*(.*?)\s*</parameter>`)
var xmlAttrPattern = regexp.MustCompile(`(?is)\b([a-z0-9_:-]+)\s*=\s*("([^"]*)"|'([^']*)')`)
func parseXMLToolCalls(text string) []ParsedToolCall {
wrappers := xmlToolsWrapperPattern.FindAllStringSubmatch(text, -1)
wrappers := xmlToolCallsWrapperPattern.FindAllStringSubmatch(text, -1)
if len(wrappers) == 0 {
return nil
}
@@ -21,7 +22,7 @@ func parseXMLToolCalls(text string) []ParsedToolCall {
if len(wrapper) < 2 {
continue
}
for _, block := range xmlToolCallPattern.FindAllString(wrapper[1], -1) {
for _, block := range xmlInvokePattern.FindAllStringSubmatch(wrapper[1], -1) {
call, ok := parseSingleXMLToolCall(block)
if !ok {
continue
@@ -35,37 +36,90 @@ func parseXMLToolCalls(text string) []ParsedToolCall {
return out
}
func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) {
inner := strings.TrimSpace(block)
inner = strings.TrimPrefix(inner, "<tool_call>")
inner = strings.TrimSuffix(inner, "</tool_call>")
inner = strings.TrimSpace(inner)
func parseSingleXMLToolCall(block []string) (ParsedToolCall, bool) {
if len(block) < 3 {
return ParsedToolCall{}, false
}
attrs := parseXMLTagAttributes(block[1])
name := strings.TrimSpace(html.UnescapeString(attrs["name"]))
if name == "" {
return ParsedToolCall{}, false
}
inner := strings.TrimSpace(block[2])
if strings.HasPrefix(inner, "{") {
var payload map[string]any
if err := json.Unmarshal([]byte(inner), &payload); err == nil {
name := strings.TrimSpace(asString(payload["name"]))
if name != "" {
input := map[string]any{}
if params, ok := payload["input"].(map[string]any); ok {
input := map[string]any{}
if params, ok := payload["input"].(map[string]any); ok {
input = params
}
if len(input) == 0 {
if params, ok := payload["parameters"].(map[string]any); ok {
input = params
}
return ParsedToolCall{Name: name, Input: input}, true
}
return ParsedToolCall{Name: name, Input: input}, true
}
}
m := xmlCanonicalToolCallBodyPattern.FindStringSubmatch(inner)
if len(m) < 3 {
return ParsedToolCall{}, false
input := map[string]any{}
for _, paramMatch := range xmlParameterPattern.FindAllStringSubmatch(inner, -1) {
if len(paramMatch) < 3 {
continue
}
paramAttrs := parseXMLTagAttributes(paramMatch[1])
paramName := strings.TrimSpace(html.UnescapeString(paramAttrs["name"]))
if paramName == "" {
continue
}
value := parseInvokeParameterValue(paramMatch[2])
appendMarkupValue(input, paramName, value)
}
name := strings.TrimSpace(html.UnescapeString(extractRawTagValue(m[1])))
if strings.TrimSpace(name) == "" {
return ParsedToolCall{}, false
if len(input) == 0 {
if strings.TrimSpace(inner) != "" {
return ParsedToolCall{}, false
}
return ParsedToolCall{Name: name, Input: map[string]any{}}, true
}
return ParsedToolCall{Name: name, Input: parseStructuredToolCallInput(m[2])}, true
return ParsedToolCall{Name: name, Input: input}, true
}
func asString(v any) string {
s, _ := v.(string)
return s
func parseXMLTagAttributes(raw string) map[string]string {
if strings.TrimSpace(raw) == "" {
return map[string]string{}
}
out := map[string]string{}
for _, m := range xmlAttrPattern.FindAllStringSubmatch(raw, -1) {
if len(m) < 5 {
continue
}
key := strings.ToLower(strings.TrimSpace(m[1]))
if key == "" {
continue
}
value := m[3]
if value == "" {
value = m[4]
}
out[key] = value
}
return out
}
func parseInvokeParameterValue(raw string) any {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if parsed := parseStructuredToolCallInput(trimmed); len(parsed) > 0 {
if len(parsed) == 1 {
if rawValue, ok := parsed["_raw"].(string); ok {
return rawValue
}
}
return parsed
}
return html.UnescapeString(extractRawTagValue(trimmed))
}

View File

@@ -16,8 +16,8 @@ func TestFormatOpenAIToolCalls(t *testing.T) {
}
}
func TestParseToolCallsSupportsToolsWrapper(t *testing.T) {
text := `<tools><tool_call><tool_name>Bash</tool_name><param><command>pwd</command><description>show cwd</description></param></tool_call></tools>`
func TestParseToolCallsSupportsToolCallsWrapper(t *testing.T) {
text := `<tool_calls><invoke name="Bash"><parameter name="command">pwd</parameter><parameter name="description">show cwd</parameter></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -31,9 +31,9 @@ func TestParseToolCallsSupportsToolsWrapper(t *testing.T) {
}
func TestParseToolCallsSupportsStandaloneToolWithMultilineCDATAAndRepeatedXMLTags(t *testing.T) {
text := `<tools><tool_call><tool_name>write_file</tool_name><param><path>script.sh</path><content><![CDATA[#!/bin/bash
text := `<tool_calls><invoke name="write_file"><parameter name="path">script.sh</parameter><parameter name="content"><![CDATA[#!/bin/bash
echo "hello"
]]></content><item>first</item><item>second</item></param></tool_call></tools>`
]]></parameter><parameter name="item">first</parameter><parameter name="item">second</parameter></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -54,8 +54,8 @@ echo "hello"
}
}
func TestParseToolCallsSupportsCanonicalParamsJSON(t *testing.T) {
text := `<tools><tool_call><tool_name>get_weather</tool_name><param>{"city":"beijing","unit":"c"}</param></tool_call></tools>`
func TestParseToolCallsSupportsInvokeParameters(t *testing.T) {
text := `<tool_calls><invoke name="get_weather"><parameter name="city">beijing</parameter><parameter name="unit">c</parameter></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"get_weather"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -69,7 +69,7 @@ func TestParseToolCallsSupportsCanonicalParamsJSON(t *testing.T) {
}
func TestParseToolCallsPreservesRawMalformedParams(t *testing.T) {
text := `<tools><tool_call><tool_name>execute_command</tool_name><param>cd /root && git status</param></tool_call></tools>`
text := `<tool_calls><invoke name="execute_command"><parameter name="command">cd /root && git status</parameter></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -77,9 +77,9 @@ func TestParseToolCallsPreservesRawMalformedParams(t *testing.T) {
if calls[0].Name != "execute_command" {
t.Fatalf("expected tool name execute_command, got %q", calls[0].Name)
}
raw, ok := calls[0].Input["_raw"].(string)
raw, ok := calls[0].Input["command"].(string)
if !ok {
t.Fatalf("expected raw argument tracking, got %#v", calls[0].Input)
t.Fatalf("expected raw command tracking, got %#v", calls[0].Input)
}
if raw != "cd /root && git status" {
t.Fatalf("expected raw arguments to be preserved, got %q", raw)
@@ -87,7 +87,7 @@ func TestParseToolCallsPreservesRawMalformedParams(t *testing.T) {
}
func TestParseToolCallsSupportsParamsJSONWithAmpersandCommand(t *testing.T) {
text := `<tools><tool_call><tool_name>execute_command</tool_name><param>{"command":"sshpass -p 'xxx' ssh -o StrictHostKeyChecking=no -p 1111 root@111.111.111.111 'cd /root && git clone https://github.com/ericc-ch/copilot-api.git'","cwd":null,"timeout":null}</param></tool_call></tools>`
text := `<tool_calls><invoke name="execute_command"><parameter name="command">sshpass -p 'xxx' ssh -o StrictHostKeyChecking=no -p 1111 root@111.111.111.111 'cd /root && git clone https://github.com/ericc-ch/copilot-api.git'</parameter><parameter name="cwd"></parameter><parameter name="timeout"></parameter></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -102,7 +102,7 @@ func TestParseToolCallsSupportsParamsJSONWithAmpersandCommand(t *testing.T) {
}
func TestParseToolCallsDoesNotTreatParamsNameTagAsToolName(t *testing.T) {
text := `<tools><tool_call><tool_name>execute_command</tool_name><param><tool_name>file.txt</tool_name><command>pwd</command></param></tool_call></tools>`
text := `<tool_calls><invoke name="execute_command"><parameter name="tool_name">file.txt</parameter><parameter name="command">pwd</parameter></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -115,8 +115,8 @@ func TestParseToolCallsDoesNotTreatParamsNameTagAsToolName(t *testing.T) {
}
}
func TestParseToolCallsDetailedMarksToolsSyntax(t *testing.T) {
text := `<tools><tool_call><tool_name>Bash</tool_name><param><command>pwd</command></param></tool_call></tools>`
func TestParseToolCallsDetailedMarksToolCallsSyntax(t *testing.T) {
text := `<tool_calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></tool_calls>`
res := ParseToolCallsDetailed(text, []string{"bash"})
if !res.SawToolCallSyntax {
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
@@ -127,7 +127,7 @@ func TestParseToolCallsDetailedMarksToolsSyntax(t *testing.T) {
}
func TestParseToolCallsSupportsInlineJSONToolObject(t *testing.T) {
text := `<tools><tool_call>{"name":"Bash","input":{"command":"pwd","description":"show cwd"}}</tool_call></tools>`
text := `<tool_calls><invoke name="Bash">{"input":{"command":"pwd","description":"show cwd"}}</invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -141,7 +141,7 @@ func TestParseToolCallsSupportsInlineJSONToolObject(t *testing.T) {
}
func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) {
text := `<tools><tool_call><tool_name>read_file</function><param>{"path":"README.md"}</param></tool_call></tools>`
text := `<tool_calls><invoke name="read_file"><parameter name="path">README.md</function></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 0 {
t.Fatalf("expected mismatched tags to be rejected, got %#v", calls)
@@ -149,26 +149,37 @@ func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) {
}
func TestParseToolCallsDoesNotTreatNameInsideParamsAsToolName(t *testing.T) {
text := `<tools><tool_call><param><tool_name>data_only</tool_name><path>README.md</path></param></tool_call></tools>`
text := `<tool_calls><invoke><parameter name="path">README.md</parameter></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 0 {
t.Fatalf("expected no tool call when name appears only under params, got %#v", calls)
}
}
func TestParseToolCallsRejectsLegacyToolCallsRoot(t *testing.T) {
text := `<tool_calls><tool_call><tool_name>read_file</tool_name><param>{"path":"README.md"}</param></tool_call></tool_calls>`
func TestParseToolCallsRejectsLegacyToolsWrapper(t *testing.T) {
text := `<tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.md"}</param></tool_call></tools>`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 0 {
t.Fatalf("expected legacy tool_calls root to be rejected, got %#v", calls)
t.Fatalf("expected legacy tools wrapper to be rejected, got %#v", calls)
}
}
func TestParseToolCallsRejectsLegacyParametersTag(t *testing.T) {
text := `<tools><tool_call><tool_name>read_file</tool_name><parameters>{"path":"README.md"}</parameters></tool_call></tools>`
func TestParseToolCallsRejectsBareInvokeWithoutToolCallsWrapper(t *testing.T) {
text := `<invoke name="read_file"><parameter name="path">README.md</parameter></invoke>`
res := ParseToolCallsDetailed(text, []string{"read_file"})
if len(res.Calls) != 0 {
t.Fatalf("expected bare invoke to be rejected, got %#v", res.Calls)
}
if res.SawToolCallSyntax {
t.Fatalf("expected bare invoke to no longer count as supported syntax, got %#v", res)
}
}
func TestParseToolCallsRejectsLegacyCanonicalBody(t *testing.T) {
text := `<tool_calls><invoke name="read_file"><tool_name>read_file</tool_name><param>{"path":"README.md"}</param></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 0 {
t.Fatalf("expected legacy parameters tag to be rejected, got %#v", calls)
t.Fatalf("expected legacy canonical body to be rejected, got %#v", calls)
}
}
@@ -310,7 +321,7 @@ func TestRepairLooseJSONWithNestedObjects(t *testing.T) {
}
func TestParseToolCallsUnescapesHTMLEntityArguments(t *testing.T) {
text := `<tools><tool_call><tool_name>Bash</tool_name><param>{"command":"echo a &gt; out.txt"}</param></tool_call></tools>`
text := `<tool_calls><invoke name="Bash"><parameter name="command">echo a &gt; out.txt</parameter></invoke></tool_calls>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected one call, got %#v", calls)
@@ -322,7 +333,7 @@ func TestParseToolCallsUnescapesHTMLEntityArguments(t *testing.T) {
}
func TestParseToolCallsIgnoresXMLInsideFencedCodeBlock(t *testing.T) {
text := "Here is an example:\n```xml\n<tools><tool_call><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\nDo not execute it."
text := "Here is an example:\n```xml\n<tool_calls><invoke name=\"read_file\"><parameter name=\"path\">README.md</parameter></invoke></tool_calls>\n```\nDo not execute it."
res := ParseToolCallsDetailed(text, []string{"read_file"})
if len(res.Calls) != 0 {
t.Fatalf("expected no parsed calls for fenced example, got %#v", res.Calls)
@@ -330,7 +341,7 @@ func TestParseToolCallsIgnoresXMLInsideFencedCodeBlock(t *testing.T) {
}
func TestParseToolCallsParsesOnlyNonFencedXMLToolCall(t *testing.T) {
text := "```xml\n<tools><tool_call><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n<tools><tool_call><tool_name>search</tool_name><param>{\"q\":\"golang\"}</param></tool_call></tools>"
text := "```xml\n<tool_calls><invoke name=\"read_file\"><parameter name=\"path\">README.md</parameter></invoke></tool_calls>\n```\n<tool_calls><invoke name=\"search\"><parameter name=\"q\">golang</parameter></invoke></tool_calls>"
res := ParseToolCallsDetailed(text, []string{"read_file", "search"})
if len(res.Calls) != 1 {
t.Fatalf("expected exactly one parsed call outside fence, got %#v", res.Calls)
@@ -341,7 +352,7 @@ func TestParseToolCallsParsesOnlyNonFencedXMLToolCall(t *testing.T) {
}
func TestParseToolCallsParsesAfterFourBacktickFence(t *testing.T) {
text := "````markdown\n```xml\n<tools><tool_call><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n````\n<tools><tool_call><tool_name>search</tool_name><param>{\"q\":\"outside\"}</param></tool_call></tools>"
text := "````markdown\n```xml\n<tool_calls><invoke name=\"read_file\"><parameter name=\"path\">README.md</parameter></invoke></tool_calls>\n```\n````\n<tool_calls><invoke name=\"search\"><parameter name=\"q\">outside</parameter></invoke></tool_calls>"
res := ParseToolCallsDetailed(text, []string{"read_file", "search"})
if len(res.Calls) != 1 {
t.Fatalf("expected exactly one parsed call outside four-backtick fence, got %#v", res.Calls)