Tighten XML tool call parsing and upstream empty handling

This commit is contained in:
CJACK
2026-04-26 01:17:16 +08:00
parent a44afb335a
commit 1b0e8cbadb
37 changed files with 273 additions and 991 deletions

View File

@@ -247,16 +247,18 @@ func asString(v any) string {
func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.T) {
tests := []struct {
name string
payload string
name string
payload string
wantToolUse bool
}{
{name: "xml_tool_call", payload: `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command></parameters></tool_call>`},
{name: "xml_json_tool_call", payload: `<tool_call>{"tool":"Bash","params":{"command":"pwd"}}</tool_call>`},
{name: "nested_tool_tag_style", payload: `<tool_call><tool name="Bash"><command>pwd</command></tool></tool_call>`},
{name: "function_tag_style", payload: `<function_call>Bash</function_call><function parameter name="command">pwd</function parameter>`},
{name: "antml_argument_style", payload: `<antml:function_calls><antml:function_call id="1" name="Bash"><antml:argument name="command">pwd</antml:argument></antml:function_call></antml:function_calls>`},
{name: "antml_function_attr_parameters", payload: `<antml:function_calls><antml:function_call id="1" function="Bash"><antml:parameters>{"command":"pwd"}</antml:parameters></antml:function_call></antml:function_calls>`},
{name: "invoke_parameter_style", payload: `<function_calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></function_calls>`},
{name: "canonical_tools_wrapper", payload: `<tools><tool_call><tool_name>Bash</tool_name><param><command>pwd</command></param></tool_call></tools>`, wantToolUse: true},
{name: "legacy_single_tool_root", payload: `<tool><tool_name>Bash</tool_name><param><command>pwd</command></param></tool>`, wantToolUse: false},
{name: "legacy_tool_call_json", payload: `<tool>{"tool":"Bash","params":{"command":"pwd"}}</tool>`, wantToolUse: false},
{name: "legacy_nested_tool_tag_style", payload: `<tool><tool name="Bash"><command>pwd</command></tool_call></tool>`, wantToolUse: false},
{name: "legacy_function_tag_style", payload: `<function_call>Bash</function_call><function parameter name="command">pwd</function parameter>`, wantToolUse: false},
{name: "legacy_antml_argument_style", payload: `<antml:function_calls><antml:function_call id="1" name="Bash"><antml:argument name="command">pwd</antml:argument></antml:function_call></antml:function_calls>`, wantToolUse: false},
{name: "legacy_antml_function_attr_parameters", payload: `<antml:function_calls><antml:function_call id="1" function="Bash"><antml:parameters>{"command":"pwd"}</antml:parameters></antml:function_call></antml:function_calls>`, wantToolUse: false},
{name: "legacy_invoke_parameter_style", payload: `<function_calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></function_calls>`, wantToolUse: false},
}
for _, tc := range tests {
@@ -280,8 +282,8 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use block for format %s, body=%s", tc.name, rec.Body.String())
if foundToolUse != tc.wantToolUse {
t.Fatalf("unexpected tool_use=%v for format %s, body=%s", foundToolUse, tc.name, rec.Body.String())
}
})
}
@@ -289,7 +291,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) {
h := &Handler{}
payload := "I'll call a tool now.\\n<tool_use><tool_name>write_file</tool_name><parameters>{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}</parameters></tool_use>"
payload := "I'll call a tool now.\\n<tools><tool_call><tool_name>write_file</tool_name><param>{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}</param></tool_call></tools>"
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/content","v":"`+payload+`"}`,
`data: [DONE]`,

View File

@@ -93,10 +93,10 @@ func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) {
t.Fatalf("expected call id preserved, got %#v", call)
}
content, _ := m["content"].(string)
if !containsStr(content, "<tool_calls>") || !containsStr(content, "<tool_name>search_web</tool_name>") {
if !containsStr(content, "<tools>") || !containsStr(content, "<tool_name>search_web</tool_name>") {
t.Fatalf("expected assistant content to include XML tool call history, got %q", content)
}
if !containsStr(content, "<parameters>\n <query><![CDATA[latest]]></query>\n </parameters>") {
if !containsStr(content, "<param>\n <query><![CDATA[latest]]></query>\n </param>") {
t.Fatalf("expected assistant content to include serialized parameters, got %q", content)
}
}
@@ -292,7 +292,7 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "Search the web") {
t.Fatalf("expected description in prompt")
}
if !containsStr(prompt, "<tool_calls>") {
if !containsStr(prompt, "<tools>") {
t.Fatalf("expected XML tool_calls format in prompt")
}
if !containsStr(prompt, "TOOL CALL FORMAT") {

View File

@@ -201,17 +201,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
finishReason = "tool_calls"
}
if len(detected.Calls) == 0 && !s.toolCallsEmitted && strings.TrimSpace(finalText) == "" {
status := http.StatusTooManyRequests
message := "Upstream model returned empty output."
code := "upstream_empty_output"
if strings.TrimSpace(finalThinking) != "" {
message = "Upstream model returned reasoning without visible output."
}
if finishReason == "content_filter" {
status = http.StatusBadRequest
message = "Upstream content filtered the response and returned no output."
code = "content_filter"
}
status, message, code := upstreamEmptyOutputDetail(finishReason == "content_filter", finalText, finalThinking)
s.sendFailedChunk(status, message, code)
return
}

View File

@@ -166,7 +166,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co
if historySession != nil {
historySession.error(status, message, code, finalThinking, finalText)
}
writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter)
writeUpstreamEmptyOutputError(w, finalText, finalThinking, result.ContentFilter)
return
}
respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames)

View File

@@ -217,8 +217,8 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin
func TestHandleStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"前置文本\n<tool_calls>\n <tool_call>\n <tool_name>read_file</tool_name>\n <parameters>{\"path\":\"README.MD\"}</parameters>\n </tool_call>\n</tool_calls>"}`,
`data: {"p":"response/content","v":"中间文本\n<tool_calls>\n <tool_call>\n <tool_name>search</tool_name>\n <parameters>{\"q\":\"golang\"}</parameters>\n </tool_call>\n</tool_calls>"}`,
`data: {"p":"response/content","v":"前置文本\n<tools>\n <tool_call>\n <tool_name>read_file</tool_name>\n <param>{\"path\":\"README.MD\"}</param>\n </tool_call>\n</tools>"}`,
`data: {"p":"response/content","v":"中间文本\n<tools>\n <tool_call>\n <tool_name>search</tool_name>\n <param>{\"q\":\"golang\"}</param>\n </tool_call>\n</tools>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()

View File

@@ -76,7 +76,7 @@ func TestBuildOpenAIHistoryTranscriptUsesInjectedFileWrapper(t *testing.T) {
if !strings.Contains(transcript, "[reasoning_content]") || !strings.Contains(transcript, "hidden reasoning") {
t.Fatalf("expected reasoning block preserved, got %q", transcript)
}
if !strings.Contains(transcript, "<tool_calls>") {
if !strings.Contains(transcript, "<tools>") {
t.Fatalf("expected tool calls preserved, got %q", transcript)
}
if !strings.HasSuffix(transcript, "\n[file name]: IGNORE\n[file content begin]\n") {

View File

@@ -38,7 +38,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized))
}
assistantContent, _ := normalized[2]["content"].(string)
if !strings.Contains(assistantContent, "<tool_calls>") {
if !strings.Contains(assistantContent, "<tools>") {
t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent)
}
if !strings.Contains(assistantContent, "<tool_name>get_weather</tool_name>") {
@@ -49,7 +49,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
}
prompt := util.MessagesPrepare(normalized)
if !strings.Contains(prompt, "<tool_calls>") {
if !strings.Contains(prompt, "<tools>") {
t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt)
}
}
@@ -258,7 +258,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi
if strings.Contains(content, "null") {
t.Fatalf("expected no null literal injection, got %q", content)
}
if !strings.Contains(content, "<tool_calls>") {
if !strings.Contains(content, "<tools>") {
t.Fatalf("expected assistant tool history in normalized content, got %q", content)
}
}

View File

@@ -47,7 +47,7 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes
if !strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "<tool_calls>") {
if !strings.Contains(finalPrompt, "<tools>") {
t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "<tool_name>get_weather</tool_name>") {
@@ -74,7 +74,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
}
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <tool_calls> XML block at the end of your response.") {
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <tools>...</tools> XML block at the end of your response.") {
t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") {

View File

@@ -135,7 +135,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
if searchEnabled {
sanitizedText = replaceCitationMarkersWithLinks(sanitizedText, result.CitationLinks)
}
if writeUpstreamEmptyOutputError(w, sanitizedText, result.ContentFilter) {
if writeUpstreamEmptyOutputError(w, sanitizedText, sanitizedThinking, result.ContentFilter) {
return
}
textParsed := toolcall.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)

View File

@@ -99,7 +99,7 @@ func newResponsesStreamRuntime(
}
}
func (s *responsesStreamRuntime) failResponse(message, code string) {
func (s *responsesStreamRuntime) failResponse(status int, message, code string) {
s.failed = true
failedResp := map[string]any{
"id": s.responseID,
@@ -107,11 +107,12 @@ func (s *responsesStreamRuntime) failResponse(message, code string) {
"object": "response",
"model": s.model,
"status": "failed",
"status_code": status,
"output": []any{},
"output_text": "",
"error": map[string]any{
"message": message,
"type": "invalid_request_error",
"type": openAIErrorType(status),
"code": code,
"param": nil,
},
@@ -119,7 +120,7 @@ func (s *responsesStreamRuntime) failResponse(message, code string) {
if s.persistResponse != nil {
s.persistResponse(failedResp)
}
s.sendEvent("response.failed", openaifmt.BuildResponsesFailedPayload(s.responseID, s.model, message, code))
s.sendEvent("response.failed", openaifmt.BuildResponsesFailedPayload(s.responseID, s.model, status, message, code))
s.sendDone()
}
@@ -145,16 +146,12 @@ func (s *responsesStreamRuntime) finalize() {
s.closeMessageItem()
if s.toolChoice.IsRequired() && len(detected) == 0 {
s.failResponse("tool_choice requires at least one valid tool call.", "tool_choice_violation")
s.failResponse(http.StatusUnprocessableEntity, "tool_choice requires at least one valid tool call.", "tool_choice_violation")
return
}
if len(detected) == 0 && strings.TrimSpace(finalText) == "" {
code := "upstream_empty_output"
message := "Upstream model returned empty output."
if finalThinking != "" {
message = "Upstream model returned reasoning without visible output."
}
s.failResponse(message, code)
status, message, code := upstreamEmptyOutputDetail(false, finalText, finalThinking)
s.failResponse(status, message, code)
return
}
s.closeIncompleteFunctionItems()

View File

@@ -122,8 +122,8 @@ func TestHandleResponsesStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t
return "data: " + string(b) + "\n"
}
streamBody := sseLine("前置文本\n<tool_calls>\n <tool_call>\n <tool_name>read_file</tool_name>\n <parameters>{\"path\":\"README.MD\"}</parameters>\n </tool_call>\n</tool_calls>") +
sseLine("中间文本\n<tool_calls>\n <tool_call>\n <tool_name>search</tool_name>\n <parameters>{\"q\":\"golang\"}</parameters>\n </tool_call>\n</tool_calls>") +
streamBody := sseLine("前置文本\n<tools>\n <tool_call>\n <tool_name>read_file</tool_name>\n <param>{\"path\":\"README.MD\"}</param>\n </tool_call>\n</tools>") +
sseLine("中间文本\n<tools>\n <tool_call>\n <tool_name>search</tool_name>\n <param>{\"q\":\"golang\"}</param>\n </tool_call>\n</tools>") +
"data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,

View File

@@ -9,22 +9,18 @@ import (
// --- XML tool call support for the streaming sieve ---
//nolint:unused // kept as explicit tag inventory for future XML sieve refinements.
var xmlToolCallClosingTags = []string{"</tool_calls>", "</tool_call>", "</invoke>", "</function_call>", "</function_calls>", "</tool_use>",
var xmlToolCallClosingTags = []string{"</tools>", "</tool_call>",
// Agent-style XML tags (Roo Code, Cline, etc.)
"</attempt_completion>", "</ask_followup_question>", "</new_task>", "</result>"}
var xmlToolCallOpeningTags = []string{"<tool_calls", "<tool_call", "<invoke", "<function_call", "<function_calls", "<tool_use",
var xmlToolCallOpeningTags = []string{"<tools", "<tool_call",
// Agent-style XML tags
"<attempt_completion", "<ask_followup_question", "<new_task", "<result"}
// xmlToolCallTagPairs maps each opening tag to its expected closing tag.
// Order matters: longer/wrapper tags must be checked first.
var xmlToolCallTagPairs = []struct{ open, close string }{
{"<tool_calls", "</tool_calls>"},
{"<tools", "</tools>"},
{"<tool_call", "</tool_call>"},
{"<function_calls", "</function_calls>"},
{"<function_call", "</function_call>"},
{"<invoke", "</invoke>"},
{"<tool_use", "</tool_use>"},
// Agent-style: these are XML "tool call" patterns from coding agents.
// They get captured → parsed. If parsing fails, the raw XML is preserved
// so the caller can still see the original text.
@@ -36,11 +32,10 @@ var xmlToolCallTagPairs = []struct{ open, close string }{
// xmlToolCallBlockPattern matches a complete XML tool call block (wrapper or standalone).
//
//nolint:unused // reserved for future fast-path XML block detection.
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(<tool_calls>\s*(?:.*?)\s*</tool_calls>|<tool_call>\s*(?:.*?)\s*</tool_call>|<invoke\b[^>]*>(?:.*?)</invoke>|<function_calls?\b[^>]*>(?:.*?)</function_calls?>|<tool_use>(?:.*?)</tool_use>|<attempt_completion>(?:.*?)</attempt_completion>|<ask_followup_question>(?:.*?)</ask_followup_question>|<new_task>(?:.*?)</new_task>)`)
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(<tools\b[^>]*>\s*(?:.*?)\s*</tools>|<tool_call\b[^>]*>(?:.*?)</tool_call>|<attempt_completion>(?:.*?)</attempt_completion>|<ask_followup_question>(?:.*?)</ask_followup_question>|<new_task>(?:.*?)</new_task>)`)
// xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart.
var xmlToolTagsToDetect = []string{"<tool_calls>", "<tool_calls\n", "<tool_call>", "<tool_call\n",
"<invoke ", "<invoke>", "<function_call", "<function_calls", "<tool_use>",
var xmlToolTagsToDetect = []string{"<tools>", "<tools\n", "<tools ", "<tool_call>", "<tool_call\n", "<tool_call ",
// Agent-style tags
"<attempt_completion>", "<ask_followup_question>", "<new_task>"}

View File

@@ -9,12 +9,12 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) {
var state toolStreamSieveState
// Simulate a model producing XML tool call output chunk by chunk.
chunks := []string{
"<tool_calls>\n",
"<tools>\n",
" <tool_call>\n",
" <tool_name>read_file</tool_name>\n",
` <parameters>{"path":"README.MD"}</parameters>` + "\n",
` <param>{"path":"README.MD"}</param>` + "\n",
" </tool_call>\n",
"</tool_calls>",
"</tools>",
}
var events []toolStreamEvent
for _, c := range chunks {
@@ -48,10 +48,10 @@ func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) {
payload := strings.Repeat("x", 4096)
splitAt := len(payload) / 2
chunks := []string{
"<tool_calls>\n <tool_call>\n <tool_name>" + toolName + "</tool_name>\n <parameters>\n <content><![CDATA[",
"<tools>\n <tool_call>\n <tool_name>" + toolName + "</tool_name>\n <param>\n <content><![CDATA[",
payload[:splitAt],
payload[splitAt:],
"]]></content>\n </parameters>\n </tool_call>\n</tool_calls>",
"]]></content>\n </param>\n </tool_call>\n</tools>",
}
var events []toolStreamEvent
@@ -90,8 +90,8 @@ func TestProcessToolSieveXMLWithLeadingText(t *testing.T) {
// Model outputs some prose then an XML tool call.
chunks := []string{
"Let me check the file.\n",
"<tool_calls>\n <tool_call>\n <tool_name>read_file</tool_name>\n",
` <parameters>{"path":"go.mod"}</parameters>` + "\n </tool_call>\n</tool_calls>",
"<tools>\n <tool_call>\n <tool_name>read_file</tool_name>\n",
` <param>{"path":"go.mod"}</param>` + "\n </tool_call>\n</tools>",
}
var events []toolStreamEvent
for _, c := range chunks {
@@ -123,7 +123,7 @@ func TestProcessToolSieveXMLWithLeadingText(t *testing.T) {
func TestProcessToolSievePassesThroughNonToolXMLBlock(t *testing.T) {
var state toolStreamSieveState
chunk := `<tool_call><title>示例 XML</title><body>plain text xml payload</body></tool_call>`
chunk := `<tool><title>示例 XML</title><body>plain text xml payload</body></tool>`
events := processToolSieveChunk(&state, chunk, []string{"read_file"})
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
@@ -143,7 +143,7 @@ func TestProcessToolSievePassesThroughNonToolXMLBlock(t *testing.T) {
func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) {
var state toolStreamSieveState
chunk := `<tool_call><title>plain xml</title></tool_call><invoke name="read_file"><parameters>{"path":"README.MD"}</parameters></invoke>`
chunk := `<tool><title>plain xml</title></tool><tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.MD"}</param></tool_call></tools>`
events := processToolSieveChunk(&state, chunk, []string{"read_file"})
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
@@ -153,11 +153,11 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) {
textContent.WriteString(evt.Content)
toolCalls += len(evt.ToolCalls)
}
if !strings.Contains(textContent.String(), `<tool_call><title>plain xml</title></tool_call>`) {
if !strings.Contains(textContent.String(), `<tool><title>plain xml</title></tool>`) {
t.Fatalf("expected leading non-tool XML to be preserved, got %q", textContent.String())
}
if strings.Contains(textContent.String(), `<invoke name="read_file">`) {
t.Fatalf("expected invoke tool XML to be intercepted, got %q", textContent.String())
if strings.Contains(textContent.String(), `<tools><tool_call>`) {
t.Fatalf("expected canonical tool XML to be intercepted, got %q", textContent.String())
}
if toolCalls != 1 {
t.Fatalf("expected exactly one parsed tool call from suffix, got %d events=%#v", toolCalls, events)
@@ -166,7 +166,7 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) {
func TestProcessToolSievePassesThroughMalformedExecutableXMLBlock(t *testing.T) {
var state toolStreamSieveState
chunk := `<tool_call><parameters>{"path":"README.md"}</parameters></tool_call>`
chunk := `<tools><tool_call><param>{"path":"README.md"}</param></tool_call></tools>`
events := processToolSieveChunk(&state, chunk, []string{"read_file"})
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
@@ -189,17 +189,17 @@ func TestProcessToolSievePassesThroughFencedXMLToolCallExamples(t *testing.T) {
var state toolStreamSieveState
input := strings.Join([]string{
"Before first example.\n```",
"xml\n<tool_call><tool_name>read_file</tool_name><parameters>{\"path\":\"README.md\"}</parameters></tool_call>\n```\n",
"xml\n<tools><tool_call><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n",
"Between examples.\n```xml\n",
"<tool_call><tool_name>search</tool_name><parameters>{\"q\":\"golang\"}</parameters></tool_call>\n",
"<tools><tool_call><tool_name>search</tool_name><param>{\"q\":\"golang\"}</param></tool_call></tools>\n",
"```\nAfter examples.",
}, "")
chunks := []string{
"Before first example.\n```",
"xml\n<tool_call><tool_name>read_file</tool_name><parameters>{\"path\":\"README.md\"}</parameters></tool_call>\n```\n",
"xml\n<tools><tool_call><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n",
"Between examples.\n```xml\n",
"<tool_call><tool_name>search</tool_name><parameters>{\"q\":\"golang\"}</parameters></tool_call>\n",
"<tools><tool_call><tool_name>search</tool_name><param>{\"q\":\"golang\"}</param></tool_call></tools>\n",
"```\nAfter examples.",
}
@@ -230,13 +230,13 @@ func TestProcessToolSieveKeepsPartialXMLTagInsideFencedExample(t *testing.T) {
var state toolStreamSieveState
input := strings.Join([]string{
"Example:\n```xml\n<tool_ca",
"ll><tool_name>read_file</tool_name><parameters>{\"path\":\"README.md\"}</parameters></tool_call>\n```\n",
"ll><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n",
"Done.",
}, "")
chunks := []string{
"Example:\n```xml\n<tool_ca",
"ll><tool_name>read_file</tool_name><parameters>{\"path\":\"README.md\"}</parameters></tool_call>\n```\n",
"ll><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n",
"Done.",
}
@@ -288,11 +288,9 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) {
input string
want int
}{
{"tool_calls_tag", "some text <tool_calls>\n", 10},
{"tools_tag", "some text <tools>\n", 10},
{"tool_call_tag", "prefix <tool_call>\n", 7},
{"invoke_tag", "text <invoke name=\"foo\">body</invoke>", 5},
{"xml_inside_code_fence", "```xml\n<tool_call><tool_name>read_file</tool_name></tool_call>\n```", -1},
{"function_call_tag", "<function_call name=\"foo\">body</function_call>", 0},
{"xml_inside_code_fence", "```xml\n<tools><tool_call><tool_name>read_file</tool_name></tool_call></tools>\n```", -1},
{"no_xml", "just plain text", -1},
{"gemini_json_no_detect", `some text {"functionCall":{"name":"search"}}`, -1},
}
@@ -312,10 +310,10 @@ func TestFindPartialXMLToolTagStart(t *testing.T) {
input string
want int
}{
{"partial_tools", "Hello <too", 6},
{"partial_tool_call", "Hello <tool_ca", 6},
{"partial_invoke", "Prefix <inv", 7},
{"partial_lt_only", "Text <", 5},
{"complete_tag", "Text <tool_call>done", -1},
{"complete_tag", "Text <tools>done", -1},
{"no_lt", "plain text", -1},
{"closed_lt", "a < b > c", -1},
}
@@ -330,10 +328,10 @@ func TestFindPartialXMLToolTagStart(t *testing.T) {
}
func TestHasOpenXMLToolTag(t *testing.T) {
if !hasOpenXMLToolTag("<tool_call>\n<tool_name>foo</tool_name>") {
if !hasOpenXMLToolTag("<tools>\n<tool_call>\n<tool_name>foo</tool_name>") {
t.Fatal("should detect open XML tool tag without closing tag")
}
if hasOpenXMLToolTag("<tool_call>\n<tool_name>foo</tool_name></tool_call>") {
if hasOpenXMLToolTag("<tools>\n<tool_call>\n<tool_name>foo</tool_name></tool_call>\n</tools>") {
t.Fatal("should return false when closing tag is present")
}
if hasOpenXMLToolTag("plain text without any XML") {
@@ -342,14 +340,14 @@ func TestHasOpenXMLToolTag(t *testing.T) {
}
// Test the EXACT scenario the user reports: token-by-token streaming where
// <tool_calls> tag arrives in small pieces.
// <tools> tag arrives in small pieces.
func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) {
var state toolStreamSieveState
// Simulate DeepSeek model generating tokens one at a time.
chunks := []string{
"<",
"tool",
"_calls",
"s",
">\n",
" <",
"tool",
@@ -366,21 +364,20 @@ func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) {
"_name",
">\n",
" <",
"parameters",
"param",
">",
`{"path"`,
`: "README.MD"`,
`}`,
"</",
"parameters",
"param",
">\n",
" </",
"tool",
"_call",
">\n",
"</",
"tool",
"_calls",
"tools",
">",
}
var events []toolStreamEvent
@@ -401,7 +398,7 @@ func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) {
if strings.Contains(textContent, "<tool_call") {
t.Fatalf("XML tool call content leaked to text in token-by-token mode: %q", textContent)
}
if strings.Contains(textContent, "tool_calls>") {
if strings.Contains(textContent, "tools>") {
t.Fatalf("closing tag fragment leaked to text: %q", textContent)
}
if strings.Contains(textContent, "read_file") {
@@ -417,7 +414,7 @@ func TestFlushToolSieveIncompleteXMLFallsBackToText(t *testing.T) {
var state toolStreamSieveState
// XML block starts but stream ends before completion.
chunks := []string{
"<tool_calls>\n",
"<tools>\n",
" <tool_call>\n",
" <tool_name>read_file</tool_name>\n",
}
@@ -440,19 +437,19 @@ func TestFlushToolSieveIncompleteXMLFallsBackToText(t *testing.T) {
}
}
// Test that the opening tag "<tool_calls>\n " is NOT emitted as text content.
// Test that the opening tag "<tools>\n " is NOT emitted as text content.
func TestOpeningXMLTagNotLeakedAsContent(t *testing.T) {
var state toolStreamSieveState
// First chunk is the opening tag - should be held, not emitted.
evts1 := processToolSieveChunk(&state, "<tool_calls>\n ", []string{"read_file"})
evts1 := processToolSieveChunk(&state, "<tools>\n ", []string{"read_file"})
for _, evt := range evts1 {
if strings.Contains(evt.Content, "<tool_calls>") {
if strings.Contains(evt.Content, "<tools>") {
t.Fatalf("opening tag leaked on first chunk: %q", evt.Content)
}
}
// Remaining content arrives.
evts2 := processToolSieveChunk(&state, "<tool_call>\n <tool_name>read_file</tool_name>\n <parameters>{\"path\":\"README.MD\"}</parameters>\n </tool_call>\n</tool_calls>", []string{"read_file"})
evts2 := processToolSieveChunk(&state, "<tool_call>\n <tool_name>read_file</tool_name>\n <param>{\"path\":\"README.MD\"}</param>\n </tool_call>\n</tools>", []string{"read_file"})
evts2 = append(evts2, flushToolSieve(&state, []string{"read_file"})...)
var textContent string

View File

@@ -12,16 +12,16 @@ func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int,
return http.StatusBadRequest, "Upstream content filtered the response and returned no output.", "content_filter"
}
if thinking != "" {
return http.StatusTooManyRequests, "Upstream model returned reasoning without visible output.", "upstream_empty_output"
return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned reasoning without visible output.", "upstream_empty_output"
}
return http.StatusTooManyRequests, "Upstream model returned empty output.", "upstream_empty_output"
return http.StatusTooManyRequests, "Upstream account hit a rate limit and returned empty output.", "upstream_empty_output"
}
func writeUpstreamEmptyOutputError(w http.ResponseWriter, text string, contentFilter bool) bool {
func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool {
if !shouldWriteUpstreamEmptyOutputError(text) {
return false
}
status, message, code := upstreamEmptyOutputDetail(contentFilter, text, "")
status, message, code := upstreamEmptyOutputDetail(contentFilter, text, thinking)
writeOpenAIErrorWithCode(w, status, message, code)
return true
}

View File

@@ -117,7 +117,7 @@ func BuildResponsesFunctionCallArgumentsDonePayload(responseID, itemID string, o
}
}
func BuildResponsesFailedPayload(responseID, model, message, code string) map[string]any {
func BuildResponsesFailedPayload(responseID, model string, status int, message, code string) map[string]any {
code = strings.TrimSpace(code)
if code == "" {
code = "api_error"
@@ -129,15 +129,36 @@ func BuildResponsesFailedPayload(responseID, model, message, code string) map[st
"object": "response",
"model": model,
"status": "failed",
"status_code": status,
"error": map[string]any{
"message": message,
"type": "invalid_request_error",
"type": responsesErrorType(status),
"code": code,
"param": nil,
},
}
}
func responsesErrorType(status int) string {
switch status {
case 400, 404, 422:
return "invalid_request_error"
case 401:
return "authentication_error"
case 403:
return "permission_error"
case 429:
return "rate_limit_error"
case 503:
return "service_unavailable_error"
default:
if status >= 500 {
return "api_error"
}
return "invalid_request_error"
}
}
func BuildResponsesCompletedPayload(response map[string]any) map[string]any {
responseID, _ := response["id"].(string)
return map[string]any{

View File

@@ -8,7 +8,7 @@ const {
stripFencedCodeBlocks,
} = require('./parse_payload');
const TOOL_MARKUP_PREFIXES = ['<tool_call', '<function_call', '<invoke'];
const TOOL_MARKUP_PREFIXES = ['<tools', '<tool_call'];
function extractToolNames(tools) {
if (!Array.isArray(tools) || tools.length === 0) {
@@ -45,7 +45,6 @@ function parseToolCallsDetailed(text, toolNames) {
if (shouldSkipToolCallParsingForCodeFenceExample(normalized)) {
return result;
}
// XML markup parsing only.
const parsed = parseMarkupToolCalls(normalized);
if (parsed.length === 0) {
@@ -73,7 +72,6 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
if (shouldSkipToolCallParsingForCodeFenceExample(trimmed)) {
return result;
}
// XML markup parsing only.
const parsed = parseMarkupToolCalls(trimmed);
if (parsed.length === 0) {

View File

@@ -1,26 +1,10 @@
'use strict';
const TOOL_CALL_MARKUP_BLOCK_PATTERN = /<(?:[a-z0-9_:-]+:)?(tool_call|function_call|invoke)\b([^>]*)>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi;
const TOOL_CALL_MARKUP_SELFCLOSE_PATTERN = /<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)\/>/gi;
const TOOLS_WRAPPER_PATTERN = /<tools\b[^>]*>([\s\S]*?)<\/tools>/gi;
const TOOL_CALL_MARKUP_BLOCK_PATTERN = /<(?:[a-z0-9_:-]+:)?tool_call\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?tool_call>/gi;
const TOOL_CALL_CANONICAL_BODY_PATTERN = /^\s*<(?:[a-z0-9_:-]+:)?tool_name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?tool_name>\s*<(?:[a-z0-9_:-]+:)?param\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?param>\s*$/i;
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,
];
const TOOL_CALL_MARKUP_ARGS_PATTERNS = [
/<(?:[a-z0-9_:-]+:)?input\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?input>/i,
/<(?:[a-z0-9_:-]+:)?arguments\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?arguments>/i,
/<(?:[a-z0-9_:-]+:)?argument\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?argument>/i,
/<(?:[a-z0-9_:-]+:)?parameters\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameters>/i,
/<(?:[a-z0-9_:-]+:)?parameter\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameter>/i,
/<(?:[a-z0-9_:-]+:)?args\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i,
/<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i,
];
const CDATA_PATTERN = /^<!\[CDATA\[([\s\S]*?)]]>$/i;
const HTML_ENTITIES_PATTERN = /&[a-z0-9#]+;/gi;
const {
toStringSafe,
@@ -40,22 +24,19 @@ function parseMarkupToolCalls(text) {
return [];
}
const out = [];
for (const m of raw.matchAll(TOOL_CALL_MARKUP_BLOCK_PATTERN)) {
const parsed = parseMarkupSingleToolCall(toStringSafe(m[2]).trim(), toStringSafe(m[3]).trim());
if (parsed) {
out.push(parsed);
}
}
for (const m of raw.matchAll(TOOL_CALL_MARKUP_SELFCLOSE_PATTERN)) {
const parsed = parseMarkupSingleToolCall(toStringSafe(m[1]).trim(), '');
if (parsed) {
out.push(parsed);
for (const wrapper of raw.matchAll(TOOLS_WRAPPER_PATTERN)) {
const body = toStringSafe(wrapper[1]);
for (const block of body.matchAll(TOOL_CALL_MARKUP_BLOCK_PATTERN)) {
const parsed = parseMarkupSingleToolCall(toStringSafe(block[1]).trim());
if (parsed) {
out.push(parsed);
}
}
}
return out;
}
function parseMarkupSingleToolCall(attrs, inner) {
function parseMarkupSingleToolCall(inner) {
// Try inline JSON parse for the inner content.
if (inner) {
try {
@@ -70,28 +51,18 @@ function parseMarkupSingleToolCall(attrs, inner) {
// Not JSON, continue with markup parsing.
}
}
let name = '';
const attrMatch = attrs.match(TOOL_CALL_MARKUP_ATTR_PATTERN);
if (attrMatch && attrMatch[2]) {
name = toStringSafe(attrMatch[2]).trim();
}
if (!name) {
name = extractRawTagValue(findMarkupTagValue(inner, TOOL_CALL_MARKUP_NAME_PATTERNS));
const match = inner.match(TOOL_CALL_CANONICAL_BODY_PATTERN);
if (!match || match.length < 3) {
return null;
}
const name = extractRawTagValue(match[1]).trim();
if (!name) {
return null;
}
let input = {};
const argsRaw = findMarkupTagValue(inner, TOOL_CALL_MARKUP_ARGS_PATTERNS);
if (argsRaw) {
input = parseMarkupInput(argsRaw);
} else {
const kv = parseMarkupKVObject(inner);
if (Object.keys(kv).length > 0) {
input = kv;
}
}
const input = parseMarkupInput(match[2]);
return { name, input };
}
@@ -187,21 +158,6 @@ function unescapeHtml(safe) {
.replace(/&#x27;/g, "'");
}
function stripTagText(text) {
return toStringSafe(text).replace(/<[^>]+>/g, ' ').trim();
}
function findMarkupTagValue(text, patterns) {
const source = toStringSafe(text);
for (const p of patterns) {
const m = source.match(p);
if (m && m[1] !== undefined) {
return toStringSafe(m[1]);
}
}
return '';
}
function parseToolCallInput(v) {
if (v == null) {
return {};

View File

@@ -3,12 +3,8 @@ const { parseToolCalls } = require('./parse');
// Tag pairs ordered longest-first: wrapper tags checked before inner tags.
const XML_TOOL_TAG_PAIRS = [
{ open: '<tool_calls', close: '</tool_calls>' },
{ open: '<tools', close: '</tools>' },
{ open: '<tool_call', close: '</tool_call>' },
{ open: '<function_calls', close: '</function_calls>' },
{ open: '<function_call', close: '</function_call>' },
{ open: '<invoke', close: '</invoke>' },
{ open: '<tool_use', close: '</tool_use>' },
];
const XML_TOOL_OPENING_TAGS = XML_TOOL_TAG_PAIRS.map(p => p.open);

View File

@@ -1,16 +1,15 @@
'use strict';
const XML_TOOL_SEGMENT_TAGS = [
'<tool_calls>', '<tool_calls\n', '<tool_calls ', '<tool_call>', '<tool_call\n', '<tool_call ',
'<invoke ', '<invoke>', '<function_call', '<function_calls', '<tool_use>',
'<tools>', '<tools\n', '<tools ', '<tool_call>', '<tool_call\n', '<tool_call ',
];
const XML_TOOL_OPENING_TAGS = [
'<tool_calls', '<tool_call', '<invoke', '<function_call', '<function_calls', '<tool_use',
'<tools', '<tool_call',
];
const XML_TOOL_CLOSING_TAGS = [
'</tool_calls>', '</tool_call>', '</invoke>', '</function_call>', '</function_calls>', '</tool_use>',
'</tools>', '</tool_call>',
];
module.exports = {
@@ -18,4 +17,3 @@ module.exports = {
XML_TOOL_OPENING_TAGS,
XML_TOOL_CLOSING_TAGS,
};

View File

@@ -38,7 +38,7 @@ func FormatToolCallsForPrompt(raw any) string {
if len(blocks) == 0 {
return ""
}
return "<tool_calls>\n" + strings.Join(blocks, "\n") + "\n</tool_calls>"
return "<tools>\n" + strings.Join(blocks, "\n") + "\n</tools>"
}
// StringifyToolCallArguments normalizes tool arguments into a compact string
@@ -105,16 +105,16 @@ func formatToolCallParametersForPrompt(raw any) string {
body, ok := renderPromptToolXMLBody(value, " ")
if ok {
if strings.TrimSpace(body) == "" {
return " <parameters></parameters>"
return " <param></param>"
}
return " <parameters>\n" + body + "\n </parameters>"
return " <param>\n" + body + "\n </param>"
}
fallback := StringifyToolCallArguments(raw)
if strings.TrimSpace(fallback) == "" {
fallback = "{}"
}
return " <parameters><content>" + renderPromptXMLText(fallback) + "</content></parameters>"
return " <param><content>" + renderPromptXMLText(fallback) + "</content></param>"
}
func normalizePromptToolCallValue(raw any) any {

View File

@@ -22,7 +22,7 @@ func TestFormatToolCallsForPromptXML(t *testing.T) {
if got == "" {
t.Fatal("expected non-empty formatted tool calls")
}
if got != "<tool_calls>\n <tool_call>\n <tool_name>search_web</tool_name>\n <parameters>\n <query><![CDATA[latest]]></query>\n </parameters>\n </tool_call>\n</tool_calls>" {
if got != "<tools>\n <tool_call>\n <tool_name>search_web</tool_name>\n <param>\n <query><![CDATA[latest]]></query>\n </param>\n </tool_call>\n</tools>" {
t.Fatalf("unexpected formatted tool call XML: %q", got)
}
}
@@ -34,7 +34,7 @@ func TestFormatToolCallsForPromptEscapesXMLEntities(t *testing.T) {
"arguments": `{"q":"a < b && c > d"}`,
},
})
want := "<tool_calls>\n <tool_call>\n <tool_name>search&lt;&amp;&gt;</tool_name>\n <parameters>\n <q><![CDATA[a < b && c > d]]></q>\n </parameters>\n </tool_call>\n</tool_calls>"
want := "<tools>\n <tool_call>\n <tool_name>search&lt;&amp;&gt;</tool_name>\n <param>\n <q><![CDATA[a < b && c > d]]></q>\n </param>\n </tool_call>\n</tools>"
if got != want {
t.Fatalf("unexpected escaped tool call XML: %q", got)
}
@@ -50,7 +50,7 @@ func TestFormatToolCallsForPromptUsesCDATAForMultilineContent(t *testing.T) {
},
},
})
want := "<tool_calls>\n <tool_call>\n <tool_name>write_file</tool_name>\n <parameters>\n <content><![CDATA[#!/bin/bash\nprintf \"hello\"\n]]></content>\n <path><![CDATA[script.sh]]></path>\n </parameters>\n </tool_call>\n</tool_calls>"
want := "<tools>\n <tool_call>\n <tool_name>write_file</tool_name>\n <param>\n <content><![CDATA[#!/bin/bash\nprintf \"hello\"\n]]></content>\n <path><![CDATA[script.sh]]></path>\n </param>\n </tool_call>\n</tools>"
if got != want {
t.Fatalf("unexpected multiline cdata tool call XML: %q", got)
}

View File

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

View File

@@ -36,19 +36,19 @@ func BuildToolCallInstructions(toolNames []string) string {
return `TOOL CALL FORMAT — FOLLOW EXACTLY:
<tool_calls>
<tools>
<tool_call>
<tool_name>TOOL_NAME_HERE</tool_name>
<parameters>
<param>
<PARAMETER_NAME><![CDATA[PARAMETER_VALUE]]></PARAMETER_NAME>
</parameters>
</param>
</tool_call>
</tool_calls>
</tools>
RULES:
1) Use the <tool_calls> XML format only. Never emit JSON or function-call syntax.
2) Put one or more <tool_call> entries under a single <tool_calls> root.
3) Parameters must be XML, not JSON.
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.
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.
@@ -64,53 +64,51 @@ PARAMETER SHAPES:
【WRONG — Do NOT do these】:
Wrong 1 — mixed text after XML:
<tool_calls>...</tool_calls> I hope this helps.
Wrong 2 — function-call syntax:
Grep({"pattern": "token"})
Wrong 3 — JSON parameters:
<tool_call><tool_name>` + ex1 + `</tool_name><parameters>{"path":"x"}</parameters></tool_call>
Wrong 4 — Markdown code fences:
<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:
` + "```xml" + `
<tool_calls>...</tool_calls>
<tools>...</tools>
` + "```" + `
Remember: The ONLY valid way to use tools is the <tool_calls> XML block at the end of your response.
Remember: The ONLY valid way to use tools is the <tools>...</tools> XML block at the end of your response.
【CORRECT EXAMPLES】:
Example A — Single tool:
<tool_calls>
<tools>
<tool_call>
<tool_name>` + ex1 + `</tool_name>
<parameters>` + ex1Params + `</parameters>
<param>` + ex1Params + `</param>
</tool_call>
</tool_calls>
</tools>
Example B — Two tools in parallel:
<tool_calls>
<tools>
<tool_call>
<tool_name>` + ex1 + `</tool_name>
<parameters>` + ex1Params + `</parameters>
<param>` + ex1Params + `</param>
</tool_call>
<tool_call>
<tool_name>` + ex2 + `</tool_name>
<parameters>` + ex2Params + `</parameters>
<param>` + ex2Params + `</param>
</tool_call>
</tool_calls>
</tools>
Example C — Tool with nested XML parameters:
<tool_calls>
<tools>
<tool_call>
<tool_name>` + ex3 + `</tool_name>
<parameters>` + ex3Params + `</parameters>
<param>` + ex3Params + `</param>
</tool_call>
</tool_calls>
</tools>
Example D — Tool with long script using CDATA (RELIABLE FOR CODE/SCRIPTS):
<tool_calls>
<tools>
<tool_call>
<tool_name>` + ex2 + `</tool_name>
<parameters>
<param>
<path>` + promptCDATA("script.sh") + `</path>
<content><![CDATA[
#!/bin/bash
@@ -118,9 +116,9 @@ if [ "$1" == "test" ]; then
echo "Success!"
fi
]]></content>
</parameters>
</param>
</tool_call>
</tool_calls>
</tools>
`
}

View File

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

View File

@@ -2,6 +2,7 @@ package toolcall
import (
"encoding/json"
"html"
"strings"
"unicode"
)
@@ -13,7 +14,7 @@ func parseToolCallInput(v any) map[string]any {
case map[string]any:
return x
case string:
raw := strings.TrimSpace(x)
raw := strings.TrimSpace(html.UnescapeString(x))
if raw == "" {
return map[string]any{}
}

View File

@@ -7,120 +7,10 @@ import (
"strings"
)
var toolCallMarkupTagNames = []string{"tool_call", "function_call", "invoke"}
var toolCallMarkupTagPatternByName = map[string]*regexp.Regexp{
"tool_call": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?tool_call\b([^>]*)>(.*?)</(?:[a-z0-9_:-]+:)?tool_call>`),
"function_call": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function_call\b([^>]*)>(.*?)</(?:[a-z0-9_:-]+:)?function_call>`),
"invoke": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)>(.*?)</(?:[a-z0-9_:-]+:)?invoke>`),
}
var toolCallMarkupSelfClosingPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)/>`)
var toolCallMarkupKVPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?([a-z0-9_\-.]+)\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?([a-z0-9_\-.]+)>`)
var toolCallMarkupAttrPattern = regexp.MustCompile(`(?is)(name|function|tool)\s*=\s*"([^"]+)"`)
var anyTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
var toolCallMarkupNameTagNames = []string{"name", "function"}
var toolCallMarkupNamePatternByTag = map[string]*regexp.Regexp{
"name": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?name\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?name>`),
"function": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?function>`),
}
// cdataPattern matches a standalone CDATA section.
var cdataPattern = regexp.MustCompile(`(?is)^<!\[CDATA\[(.*?)]]>$`)
var toolCallMarkupArgsTagNames = []string{"input", "arguments", "argument", "parameters", "parameter", "args", "params"}
var toolCallMarkupArgsPatternByTag = map[string]*regexp.Regexp{
"input": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?input\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?input>`),
"arguments": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?arguments\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?arguments>`),
"argument": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?argument\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?argument>`),
"parameters": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?parameters\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?parameters>`),
"parameter": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?parameter\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?parameter>`),
"args": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?args\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?args>`),
"params": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?params\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?params>`),
}
func parseMarkupToolCalls(text string) []ParsedToolCall {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return nil
}
out := make([]ParsedToolCall, 0)
for _, tagName := range toolCallMarkupTagNames {
pattern := toolCallMarkupTagPatternByName[tagName]
for _, m := range pattern.FindAllStringSubmatch(trimmed, -1) {
if len(m) < 3 {
continue
}
attrs := strings.TrimSpace(m[1])
inner := strings.TrimSpace(m[2])
if parsed := parseMarkupSingleToolCall(attrs, inner); parsed.Name != "" {
out = append(out, parsed)
}
}
}
for _, m := range toolCallMarkupSelfClosingPattern.FindAllStringSubmatch(trimmed, -1) {
if len(m) < 2 {
continue
}
if parsed := parseMarkupSingleToolCall(strings.TrimSpace(m[1]), ""); parsed.Name != "" {
out = append(out, parsed)
}
}
if len(out) == 0 {
return nil
}
return out
}
func parseMarkupSingleToolCall(attrs string, inner string) ParsedToolCall {
// Try parsing inner content as a JSON tool call object.
if raw := strings.TrimSpace(inner); raw != "" && strings.HasPrefix(raw, "{") {
var obj map[string]any
if err := json.Unmarshal([]byte(raw), &obj); err == nil {
name, _ := obj["name"].(string)
if name == "" {
if fn, ok := obj["function"].(map[string]any); ok {
name, _ = fn["name"].(string)
}
}
if name == "" {
if fc, ok := obj["functionCall"].(map[string]any); ok {
name, _ = fc["name"].(string)
}
}
if strings.TrimSpace(name) != "" {
input := parseToolCallInput(obj["input"])
if len(input) == 0 {
if args, ok := obj["arguments"]; ok {
input = parseToolCallInput(args)
}
}
return ParsedToolCall{Name: strings.TrimSpace(name), Input: input}
}
}
}
name := ""
if m := toolCallMarkupAttrPattern.FindStringSubmatch(attrs); len(m) >= 3 {
name = strings.TrimSpace(m[2])
}
if name == "" {
name = findMarkupTagValue(inner, toolCallMarkupNameTagNames, toolCallMarkupNamePatternByTag)
}
if name == "" {
return ParsedToolCall{}
}
input := map[string]any{}
if argsRaw := findMarkupTagValue(inner, toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag); argsRaw != "" {
input = parseMarkupInput(argsRaw)
} else if kv := parseMarkupKVObject(inner); len(kv) > 0 {
input = kv
}
return ParsedToolCall{Name: name, Input: input}
}
func parseMarkupInput(raw string) map[string]any {
return parseStructuredToolCallInput(raw)
}
func parseMarkupKVObject(text string) map[string]any {
matches := toolCallMarkupKVPattern.FindAllStringSubmatch(strings.TrimSpace(text), -1)
@@ -212,23 +102,3 @@ func extractRawTagValue(inner string) string {
// but for KV objects we usually want the value.
return html.UnescapeString(inner)
}
func stripTagText(text string) string {
return strings.TrimSpace(anyTagPattern.ReplaceAllString(text, ""))
}
func findMarkupTagValue(text string, tagNames []string, patternByTag map[string]*regexp.Regexp) string {
for _, tag := range tagNames {
pattern := patternByTag[tag]
if pattern == nil {
continue
}
if m := pattern.FindStringSubmatch(text); len(m) >= 2 {
value := extractRawTagValue(m[1])
if value != "" {
return value
}
}
}
return ""
}

View File

@@ -46,9 +46,6 @@ func parseToolCallsDetailedXMLOnly(text string) ToolCallParseResult {
}
parsed := parseXMLToolCalls(trimmed)
if len(parsed) == 0 {
parsed = parseMarkupToolCalls(trimmed)
}
if len(parsed) == 0 {
return result
}
@@ -77,12 +74,8 @@ func filterToolCallsDetailed(parsed []ParsedToolCall) ([]ParsedToolCall, []strin
func looksLikeToolCallSyntax(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "<tool_calls") ||
return strings.Contains(lower, "<tools") ||
strings.Contains(lower, "<tool_call") ||
strings.Contains(lower, "<function_calls") ||
strings.Contains(lower, "<function_call") ||
strings.Contains(lower, "<invoke") ||
strings.Contains(lower, "<tool_use") ||
strings.Contains(lower, "<attempt_completion") ||
strings.Contains(lower, "<ask_followup_question") ||
strings.Contains(lower, "<new_task") ||

View File

@@ -2,63 +2,37 @@ package toolcall
import (
"encoding/json"
"encoding/xml"
"html"
"regexp"
"strings"
)
var xmlToolCallPattern = regexp.MustCompile(`(?is)<tool_call>\s*(.*?)\s*</tool_call>`)
var functionCallPattern = regexp.MustCompile(`(?is)<function_call>\s*([^<]+?)\s*</function_call>`)
var functionParamPattern = regexp.MustCompile(`(?is)<function\s+parameter\s+name="([^"]+)"\s*>\s*(.*?)\s*</function\s+parameter>`)
var antmlFunctionCallPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?function_call[^>]*(?:name|function)="([^"]+)"[^>]*>\s*(.*?)\s*</(?:[a-z0-9_]+:)?function_call>`)
var antmlArgumentPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?argument\s+name="([^"]+)"\s*>\s*(.*?)\s*</(?:[a-z0-9_]+:)?argument>`)
var invokeCallPattern = regexp.MustCompile(`(?is)<invoke\s+name="([^"]+)"\s*>(.*?)</invoke>`)
var invokeParamPattern = regexp.MustCompile(`(?is)<parameter\s+name="([^"]+)"\s*>\s*(.*?)\s*</parameter>`)
var toolUseFunctionPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function\s+name="([^"]+)"\s*>(.*?)</function>\s*</tool_use>`)
var toolUseNameParametersPattern = regexp.MustCompile(`(?is)<tool_use>\s*<tool_name>\s*([^<]+?)\s*</tool_name>\s*<parameters>\s*(.*?)\s*</parameters>\s*</tool_use>`)
var toolUseFunctionNameParametersPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function_name>\s*([^<]+?)\s*</function_name>\s*<parameters>\s*(.*?)\s*</parameters>\s*</tool_use>`)
var toolUseToolNameBodyPattern = regexp.MustCompile(`(?is)<tool_use>\s*<tool_name>\s*([^<]+?)\s*</tool_name>\s*(.*?)\s*</tool_use>`)
var xmlToolNamePatterns = []*regexp.Regexp{
regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?tool_name\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?tool_name>`),
regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function_name\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?function_name>`),
}
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*$`)
func parseXMLToolCalls(text string) []ParsedToolCall {
matches := xmlToolCallPattern.FindAllString(text, -1)
out := make([]ParsedToolCall, 0, len(matches)+1)
for _, block := range matches {
call, ok := parseSingleXMLToolCall(block)
if !ok {
wrappers := xmlToolsWrapperPattern.FindAllStringSubmatch(text, -1)
if len(wrappers) == 0 {
return nil
}
out := make([]ParsedToolCall, 0, len(wrappers))
for _, wrapper := range wrappers {
if len(wrapper) < 2 {
continue
}
out = append(out, call)
for _, block := range xmlToolCallPattern.FindAllString(wrapper[1], -1) {
call, ok := parseSingleXMLToolCall(block)
if !ok {
continue
}
out = append(out, call)
}
}
if len(out) > 0 {
return out
if len(out) == 0 {
return nil
}
if call, ok := parseFunctionCallTagStyle(text); ok {
return []ParsedToolCall{call}
}
if calls := parseAntmlFunctionCallStyles(text); len(calls) > 0 {
return calls
}
if call, ok := parseInvokeFunctionCallStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseFunctionStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseNameParametersStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseFunctionNameParametersStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseToolNameBodyStyle(text); ok {
return []ParsedToolCall{call}
}
return nil
return out
}
func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) {
@@ -69,15 +43,10 @@ func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) {
if strings.HasPrefix(inner, "{") {
var payload map[string]any
if err := json.Unmarshal([]byte(inner), &payload); err == nil {
name := strings.TrimSpace(asString(payload["tool"]))
if name == "" {
name = strings.TrimSpace(asString(payload["tool_name"]))
}
name := strings.TrimSpace(asString(payload["name"]))
if name != "" {
input := map[string]any{}
if params, ok := payload["params"].(map[string]any); ok {
input = params
} else if params, ok := payload["parameters"].(map[string]any); ok {
if params, ok := payload["input"].(map[string]any); ok {
input = params
}
return ParsedToolCall{Name: name, Input: input}, true
@@ -85,350 +54,15 @@ func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) {
}
}
name := ""
params := extractXMLToolParamsByRegex(inner)
dec := xml.NewDecoder(strings.NewReader(block))
inTool := false
for {
tok, err := dec.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
tag := strings.ToLower(t.Name.Local)
switch tag {
case "tool":
inTool = true
for _, attr := range t.Attr {
if strings.EqualFold(strings.TrimSpace(attr.Name.Local), "name") && strings.TrimSpace(name) == "" {
name = strings.TrimSpace(attr.Value)
}
}
case "parameters":
var node struct {
Inner string `xml:",innerxml"`
}
if err := dec.DecodeElement(&node, &t); err == nil {
inner := strings.TrimSpace(node.Inner)
if inner != "" {
extracted := extractRawTagValue(inner)
if parsed := parseStructuredToolCallInput(extracted); len(parsed) > 0 {
for k, vv := range parsed {
params[k] = vv
}
}
}
}
case "tool_name", "function_name", "name":
var v string
if err := dec.DecodeElement(&v, &t); err == nil && strings.TrimSpace(v) != "" {
name = strings.TrimSpace(v)
}
case "input", "arguments", "argument", "args", "params":
var v string
if err := dec.DecodeElement(&v, &t); err == nil && strings.TrimSpace(v) != "" {
if parsed := parseStructuredToolCallInput(strings.TrimSpace(v)); len(parsed) > 0 {
for k, vv := range parsed {
params[k] = vv
}
}
}
default:
if inTool {
var v string
if err := dec.DecodeElement(&v, &t); err == nil {
params[t.Name.Local] = strings.TrimSpace(html.UnescapeString(v))
}
}
}
case xml.EndElement:
tag := strings.ToLower(t.Name.Local)
if tag == "tool" {
inTool = false
}
}
}
if strings.TrimSpace(name) == "" {
name = strings.TrimSpace(html.UnescapeString(extractXMLToolNameByRegex(stripTopLevelXMLParameters(inner))))
m := xmlCanonicalToolCallBodyPattern.FindStringSubmatch(inner)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(html.UnescapeString(extractRawTagValue(m[1])))
if strings.TrimSpace(name) == "" {
return ParsedToolCall{}, false
}
return ParsedToolCall{Name: strings.TrimSpace(html.UnescapeString(name)), Input: params}, true
}
func stripTopLevelXMLParameters(inner string) string {
out := strings.TrimSpace(inner)
for {
idx := strings.Index(strings.ToLower(out), "<parameters")
if idx < 0 {
return out
}
segment := out[idx:]
segmentLower := strings.ToLower(segment)
openEnd := strings.Index(segmentLower, ">")
if openEnd < 0 {
return out
}
closeIdx := strings.Index(segmentLower, "</parameters>")
if closeIdx < 0 {
return out[:idx]
}
end := idx + closeIdx + len("</parameters>")
out = out[:idx] + out[end:]
}
}
func extractXMLToolNameByRegex(inner string) string {
for _, pattern := range xmlToolNamePatterns {
if m := pattern.FindStringSubmatch(inner); len(m) >= 2 {
if v := strings.TrimSpace(stripTagText(m[1])); v != "" {
return v
}
}
}
return ""
}
func extractXMLToolParamsByRegex(inner string) map[string]any {
raw := findMarkupTagValue(inner, toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag)
if raw == "" {
return map[string]any{}
}
parsed := parseMarkupInput(raw)
if parsed == nil {
return map[string]any{}
}
return parsed
}
func parseFunctionCallTagStyle(text string) (ParsedToolCall, bool) {
m := functionCallPattern.FindStringSubmatch(text)
if len(m) < 2 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(html.UnescapeString(m[1]))
if name == "" {
return ParsedToolCall{}, false
}
input := map[string]any{}
for _, pm := range functionParamPattern.FindAllStringSubmatch(text, -1) {
if len(pm) < 3 {
continue
}
key := strings.TrimSpace(pm[1])
val := extractRawTagValue(pm[2])
if key != "" {
if parsed := parseStructuredToolCallInput(val); len(parsed) > 0 {
if isOnlyRawValue(parsed, val) {
input[key] = val
} else {
input[key] = parsed
}
}
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseAntmlFunctionCallStyles(text string) []ParsedToolCall {
matches := antmlFunctionCallPattern.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
out := make([]ParsedToolCall, 0, len(matches))
for _, m := range matches {
if call, ok := parseSingleAntmlFunctionCallMatch(m); ok {
out = append(out, call)
}
}
if len(out) == 0 {
return nil
}
return out
}
func parseSingleAntmlFunctionCallMatch(m []string) (ParsedToolCall, bool) {
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(html.UnescapeString(m[1]))
if name == "" {
return ParsedToolCall{}, false
}
body := strings.TrimSpace(m[2])
input := map[string]any{}
if strings.HasPrefix(body, "{") {
if err := json.Unmarshal([]byte(body), &input); err == nil {
return ParsedToolCall{Name: name, Input: input}, true
}
}
for _, am := range antmlArgumentPattern.FindAllStringSubmatch(body, -1) {
if len(am) < 3 {
continue
}
k := strings.TrimSpace(am[1])
v := extractRawTagValue(am[2])
if k != "" {
input[k] = v
}
}
if len(input) > 0 {
return ParsedToolCall{Name: name, Input: input}, true
}
if paramsRaw := findMarkupTagValue(body, toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag); paramsRaw != "" {
if parsed := parseMarkupInput(paramsRaw); len(parsed) > 0 {
return ParsedToolCall{Name: name, Input: parsed}, true
}
}
if strings.Contains(body, "<") {
if parsed := parseStructuredToolCallInput(body); len(parsed) > 0 && !isOnlyRawValue(parsed, body) {
return ParsedToolCall{Name: name, Input: parsed}, true
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseInvokeFunctionCallStyle(text string) (ParsedToolCall, bool) {
m := invokeCallPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(html.UnescapeString(m[1]))
if name == "" {
return ParsedToolCall{}, false
}
input := map[string]any{}
for _, pm := range invokeParamPattern.FindAllStringSubmatch(m[2], -1) {
if len(pm) < 3 {
continue
}
k := strings.TrimSpace(pm[1])
v := extractRawTagValue(pm[2])
if k != "" {
if parsed := parseStructuredToolCallInput(v); len(parsed) > 0 {
if isOnlyRawValue(parsed, v) {
input[k] = v
} else {
input[k] = parsed
}
}
}
}
if len(input) == 0 {
if argsRaw := findMarkupTagValue(m[2], toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag); argsRaw != "" {
input = parseMarkupInput(argsRaw)
} else if kv := parseMarkupKVObject(m[2]); len(kv) > 0 {
input = kv
} else if parsed := parseStructuredToolCallInput(m[2]); len(parsed) > 0 && !isOnlyRawValue(parsed, strings.TrimSpace(html.UnescapeString(m[2]))) {
input = parsed
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseFunctionStyle(text string) (ParsedToolCall, bool) {
m := toolUseFunctionPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(html.UnescapeString(m[1]))
if name == "" {
return ParsedToolCall{}, false
}
body := m[2]
input := map[string]any{}
for _, pm := range invokeParamPattern.FindAllStringSubmatch(body, -1) {
if len(pm) < 3 {
continue
}
k := strings.TrimSpace(pm[1])
v := extractRawTagValue(pm[2])
if k != "" {
if parsed := parseStructuredToolCallInput(v); len(parsed) > 0 {
if isOnlyRawValue(parsed, v) {
input[k] = v
} else {
input[k] = parsed
}
}
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseNameParametersStyle(text string) (ParsedToolCall, bool) {
m := toolUseNameParametersPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(html.UnescapeString(m[1]))
if name == "" {
return ParsedToolCall{}, false
}
raw := strings.TrimSpace(m[2])
input := map[string]any{}
if raw != "" {
if parsed := parseStructuredToolCallInput(raw); len(parsed) > 0 {
input = parsed
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseFunctionNameParametersStyle(text string) (ParsedToolCall, bool) {
m := toolUseFunctionNameParametersPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(html.UnescapeString(m[1]))
if name == "" {
return ParsedToolCall{}, false
}
raw := strings.TrimSpace(m[2])
input := map[string]any{}
if raw != "" {
if parsed := parseStructuredToolCallInput(raw); len(parsed) > 0 {
input = parsed
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseToolNameBodyStyle(text string) (ParsedToolCall, bool) {
m := toolUseToolNameBodyPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(html.UnescapeString(m[1]))
if name == "" {
return ParsedToolCall{}, false
}
body := strings.TrimSpace(m[2])
input := map[string]any{}
if body != "" {
if kv := parseXMLChildKV(body); len(kv) > 0 {
input = kv
} else if kv := parseMarkupKVObject(body); len(kv) > 0 {
input = kv
} else if parsed := parseStructuredToolCallInput(body); len(parsed) > 0 {
input = parsed
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseXMLChildKV(body string) map[string]any {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return nil
}
parsed := parseStructuredToolCallInput(trimmed)
if len(parsed) == 0 {
return nil
}
return parsed
return ParsedToolCall{Name: name, Input: parseStructuredToolCallInput(m[2])}, true
}
func asString(v any) string {

View File

@@ -16,8 +16,8 @@ func TestFormatOpenAIToolCalls(t *testing.T) {
}
}
func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) {
text := `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command><description>show cwd</description></parameters></tool_call>`
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>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -30,10 +30,10 @@ func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) {
}
}
func TestParseToolCallsSupportsMultilineCDATAAndRepeatedXMLTags(t *testing.T) {
text := `<tool_call><tool_name>write_file</tool_name><parameters><path>script.sh</path><content><![CDATA[#!/bin/bash
func TestParseToolCallsSupportsStandaloneToolWithMultilineCDATAAndRepeatedXMLTags(t *testing.T) {
text := `<tools><tool_call><tool_name>write_file</tool_name><param><path>script.sh</path><content><![CDATA[#!/bin/bash
echo "hello"
]]></content><item>first</item><item>second</item></parameters></tool_call>`
]]></content><item>first</item><item>second</item></param></tool_call></tools>`
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 TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) {
text := `<tool_call><tool_name>get_weather</tool_name><parameters>{"city":"beijing","unit":"c"}</parameters></tool_call>`
func TestParseToolCallsSupportsCanonicalParamsJSON(t *testing.T) {
text := `<tools><tool_call><tool_name>get_weather</tool_name><param>{"city":"beijing","unit":"c"}</param></tool_call></tools>`
calls := ParseToolCalls(text, []string{"get_weather"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -68,8 +68,8 @@ func TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) {
}
}
func TestParseToolCallsPreservesRawMalformedXMLParameters(t *testing.T) {
text := `<tool_call><tool_name>execute_command</tool_name><parameters>cd /root && git status</parameters></tool_call>`
func TestParseToolCallsPreservesRawMalformedParams(t *testing.T) {
text := `<tools><tool_call><tool_name>execute_command</tool_name><param>cd /root && git status</param></tool_call></tools>`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -86,8 +86,8 @@ func TestParseToolCallsPreservesRawMalformedXMLParameters(t *testing.T) {
}
}
func TestParseToolCallsSupportsXMLParametersJSONWithAmpersandCommand(t *testing.T) {
text := `<tool_calls><tool_call><tool_name>execute_command</tool_name><parameters>{"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}</parameters></tool_call></tool_calls>`
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>`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -101,8 +101,8 @@ func TestParseToolCallsSupportsXMLParametersJSONWithAmpersandCommand(t *testing.
}
}
func TestParseToolCallsDoesNotTreatParameterNameTagAsToolName(t *testing.T) {
text := `<tool_call><tool name="execute_command"><parameters><name>file.txt</name><command>pwd</command></parameters></tool></tool_call>`
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>`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -110,13 +110,13 @@ func TestParseToolCallsDoesNotTreatParameterNameTagAsToolName(t *testing.T) {
if calls[0].Name != "execute_command" {
t.Fatalf("expected tool name execute_command, got %q", calls[0].Name)
}
if calls[0].Input["name"] != "file.txt" {
if calls[0].Input["tool_name"] != "file.txt" {
t.Fatalf("expected parameter name preserved, 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>`
func TestParseToolCallsDetailedMarksToolsSyntax(t *testing.T) {
text := `<tools><tool_call><tool_name>Bash</tool_name><param><command>pwd</command></param></tool_call></tools>`
res := ParseToolCallsDetailed(text, []string{"bash"})
if !res.SawToolCallSyntax {
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
@@ -126,8 +126,8 @@ func TestParseToolCallsDetailedMarksXMLToolCallSyntax(t *testing.T) {
}
}
func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) {
text := `<tool_call>{"tool":"Bash","params":{"command":"pwd","description":"show cwd"}}</tool_call>`
func TestParseToolCallsSupportsInlineJSONToolObject(t *testing.T) {
text := `<tools><tool_call>{"name":"Bash","input":{"command":"pwd","description":"show cwd"}}</tool_call></tools>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
@@ -140,170 +140,35 @@ func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) {
}
}
func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) {
text := `<function_call>Bash</function_call><function parameter name="command">ls -la</function parameter><function parameter name="description">list</function parameter>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "ls -la" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) {
text := `<antml:function_calls><antml:function_call name="Bash">{"command":"pwd","description":"x"}</antml:function_call></antml:function_calls>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) {
text := `<antml:function_calls><antml:function_call id="1" name="Bash"><antml:argument name="command">pwd</antml:argument><antml:argument name="description">x</antml:argument></antml:function_call></antml:function_calls>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) {
text := `<function_calls><invoke name="Bash"><parameter name="command">pwd</parameter><parameter name="description">d</parameter></invoke></function_calls>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) {
text := `<tool_use><function name="search_web"><parameter name="query">test</parameter></function></tool_use>`
calls := ParseToolCalls(text, []string{"search_web"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search_web" {
t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name)
}
if calls[0].Input["query"] != "test" {
t.Fatalf("expected query argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseNameParametersStyle(t *testing.T) {
text := `<tool_use><tool_name>write_file</tool_name><parameters>{"path":"/tmp/a.txt","content":"abc"}</parameters></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["path"] != "/tmp/a.txt" {
t.Fatalf("expected path argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseFunctionNameParametersStyle(t *testing.T) {
text := `<tool_use><function_name>write_file</function_name><parameters>{"path":"/tmp/b.txt","content":"xyz"}</parameters></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["content"] != "xyz" {
t.Fatalf("expected content argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseToolNameBodyStyle(t *testing.T) {
text := `<tool_use><tool_name>write_file</tool_name><path>/tmp/c.txt</path><content>hello</content></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["path"] != "/tmp/c.txt" {
t.Fatalf("expected path argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsNestedToolTagStyle(t *testing.T) {
text := `<tool_call><tool name="Bash"><command>pwd</command><description>show cwd</description></tool></tool_call>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testing.T) {
text := `<antml:function_calls><antml:function_call id="x" function="Bash"><antml:parameters>{"command":"pwd"}</antml:parameters></antml:function_call></antml:function_calls>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) {
text := `<antml:function_calls><antml:function_call id="1" function="Bash"><antml:parameters>{"command":"pwd"}</antml:parameters></antml:function_call><antml:function_call id="2" function="Read"><antml:parameters>{"file_path":"README.md"}</antml:parameters></antml:function_call></antml:function_calls>`
calls := ParseToolCalls(text, []string{"bash", "read"})
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %#v", calls)
}
if calls[0].Name != "Bash" || calls[1].Name != "Read" {
t.Fatalf("expected original names [Bash Read], got %#v", calls)
}
}
func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) {
text := `<tool_call><name>read_file</function><arguments>{"path":"README.md"}</arguments></tool_call>`
text := `<tools><tool_call><tool_name>read_file</function><param>{"path":"README.md"}</param></tool_call></tools>`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 0 {
t.Fatalf("expected mismatched tags to be rejected, got %#v", calls)
}
}
func TestParseToolCallsDoesNotTreatParametersFunctionNameAsToolName(t *testing.T) {
text := `<tool_call><parameters><function_name>data_only</function_name><path>README.md</path></parameters></tool_call>`
func TestParseToolCallsDoesNotTreatNameInsideParamsAsToolName(t *testing.T) {
text := `<tools><tool_call><param><tool_name>data_only</tool_name><path>README.md</path></param></tool_call></tools>`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 0 {
t.Fatalf("expected no tool call when function_name appears only under parameters, got %#v", calls)
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>`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 0 {
t.Fatalf("expected legacy tool_calls root 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>`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 0 {
t.Fatalf("expected legacy parameters tag to be rejected, got %#v", calls)
}
}
@@ -445,7 +310,7 @@ func TestRepairLooseJSONWithNestedObjects(t *testing.T) {
}
func TestParseToolCallsUnescapesHTMLEntityArguments(t *testing.T) {
text := `<tool_call><tool_name>Bash</tool_name><parameters>{"command":"echo a &gt; out.txt"}</parameters></tool_call>`
text := `<tools><tool_call><tool_name>Bash</tool_name><param>{"command":"echo a &gt; out.txt"}</param></tool_call></tools>`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected one call, got %#v", calls)
@@ -457,7 +322,7 @@ func TestParseToolCallsUnescapesHTMLEntityArguments(t *testing.T) {
}
func TestParseToolCallsIgnoresXMLInsideFencedCodeBlock(t *testing.T) {
text := "Here is an example:\n```xml\n<tool_call><tool_name>read_file</tool_name><parameters>{\"path\":\"README.md\"}</parameters></tool_call>\n```\nDo not execute it."
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."
res := ParseToolCallsDetailed(text, []string{"read_file"})
if len(res.Calls) != 0 {
t.Fatalf("expected no parsed calls for fenced example, got %#v", res.Calls)
@@ -465,7 +330,7 @@ func TestParseToolCallsIgnoresXMLInsideFencedCodeBlock(t *testing.T) {
}
func TestParseToolCallsParsesOnlyNonFencedXMLToolCall(t *testing.T) {
text := "```xml\n<tool_call><tool_name>read_file</tool_name><parameters>{\"path\":\"README.md\"}</parameters></tool_call>\n```\n<tool_call><tool_name>search</tool_name><parameters>{\"q\":\"golang\"}</parameters></tool_call>"
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>"
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)
@@ -476,7 +341,7 @@ func TestParseToolCallsParsesOnlyNonFencedXMLToolCall(t *testing.T) {
}
func TestParseToolCallsParsesAfterFourBacktickFence(t *testing.T) {
text := "````markdown\n```xml\n<tool_call><tool_name>read_file</tool_name><parameters>{\"path\":\"README.md\"}</parameters></tool_call>\n```\n````\n<tool_call><tool_name>search</tool_name><parameters>{\"q\":\"outside\"}</parameters></tool_call>"
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>"
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)

View File

@@ -1,13 +1,6 @@
{
"calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true,
"calls": [],
"sawToolCallSyntax": false,
"rejectedByPolicy": false,
"rejectedToolNames": []
}
}

View File

@@ -1,13 +1,6 @@
{
"calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true,
"calls": [],
"sawToolCallSyntax": false,
"rejectedByPolicy": false,
"rejectedToolNames": []
}
}

View File

@@ -1,13 +1,6 @@
{
"calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true,
"calls": [],
"sawToolCallSyntax": false,
"rejectedByPolicy": false,
"rejectedToolNames": []
}
}

View File

@@ -1,14 +1,6 @@
{
"calls": [
{
"name": "get_weather",
"input": {
"city": "beijing",
"unit": "c"
}
}
],
"sawToolCallSyntax": true,
"calls": [],
"sawToolCallSyntax": false,
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -1,5 +1,5 @@
{
"text": "<function_call><function>read_file</function><parameters>{\"path\":\"README.MD\"}</parameters></function_call>",
"text": "<function_call><function>read_file</function><param>{\"path\":\"README.MD\"}</param></function_call>",
"tool_names": [
"read_file"
]

View File

@@ -1,5 +1,5 @@
{
"text": "<tool_call><name>read_file</name><arguments>{\"path\":\"README.MD\"}</arguments></tool_call>",
"text": "<tool><tool_name>read_file</tool_name><arguments>{\"path\":\"README.MD\"}</arguments></tool>",
"tool_names": [
"read_file"
]

View File

@@ -1,5 +1,5 @@
{
"text": "<tool_call><tool_name>get_weather</tool_name><parameters>{\"city\":\"beijing\",\"unit\":\"c\"}</parameters></tool_call>",
"text": "<tool><tool_name>get_weather</tool_name><param>{\"city\":\"beijing\",\"unit\":\"c\"}</param></tool>",
"tool_names": [
"get_weather"
]

View File

@@ -42,7 +42,7 @@ test('extractToolNames keeps only declared tool names (Go parity)', () => {
});
test('parseToolCalls parses XML markup tool call', () => {
const payload = '<tool_call><tool_name>read_file</tool_name><parameters>{"path":"README.MD"}</parameters></tool_call>';
const payload = '<tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.MD"}</param></tool_call></tools>';
const calls = parseToolCalls(payload, ['read_file']);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'read_file');
@@ -61,7 +61,7 @@ test('parseToolCalls ignores tool_call payloads that exist only inside fenced co
const text = [
'I will call a tool now.',
'```xml',
'<tool_call><tool_name>read_file</tool_name><parameters>{"path":"README.md"}</parameters></tool_call>',
'<tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.md"}</param></tool_call></tools>',
'```',
].join('\n');
const calls = parseToolCalls(text, ['read_file']);
@@ -69,7 +69,7 @@ test('parseToolCalls ignores tool_call payloads that exist only inside fenced co
});
test('parseToolCalls keeps unknown schema names when toolNames is provided', () => {
const payload = '<tool_call><tool_name>not_in_schema</tool_name><parameters>{"q":"go"}</parameters></tool_call>';
const payload = '<tools><tool_call><tool_name>not_in_schema</tool_name><param>{"q":"go"}</param></tool_call></tools>';
const calls = parseToolCalls(payload, ['search']);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'not_in_schema');
@@ -77,7 +77,7 @@ test('parseToolCalls keeps unknown schema names when toolNames is provided', ()
test('sieve emits tool_calls for XML tool call payload', () => {
const events = runSieve(
['<tool_call><tool_name>read_file</tool_name><parameters>{"path":"README.MD"}</parameters></tool_call>'],
['<tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.MD"}</param></tool_call></tools>'],
['read_file'],
);
const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
@@ -88,8 +88,8 @@ test('sieve emits tool_calls for XML tool call payload', () => {
test('sieve emits tool_calls when XML tag spans multiple chunks', () => {
const events = runSieve(
[
'<tool_call><tool_name>read_file</tool_name>',
'<parameters>{"path":"README.MD"}</parameters></tool_call>',
'<tools><tool_call><tool_name>read_file</tool_name>',
'<param>{"path":"README.MD"}</param></tool_call></tools>',
],
['read_file'],
);
@@ -103,10 +103,10 @@ test('sieve keeps long XML tool calls buffered until the closing tag arrives', (
const splitAt = longContent.length / 2;
const events = runSieve(
[
'<tool_calls>\n <tool_call>\n <tool_name>write_to_file</tool_name>\n <parameters>\n <content><![CDATA[',
'<tools>\n <tool_call>\n <tool_name>write_to_file</tool_name>\n <param>\n <content><![CDATA[',
longContent.slice(0, splitAt),
longContent.slice(splitAt),
']]></content>\n </parameters>\n </tool_call>\n</tool_calls>',
']]></content>\n </param>\n </tool_call>\n</tools>',
],
['write_to_file'],
);
@@ -147,7 +147,7 @@ test('sieve keeps embedded invalid tool-like json as normal text to avoid stream
});
test('sieve passes malformed executable-looking XML through as text', () => {
const chunk = '<tool_call><parameters>{"path":"README.MD"}</parameters></tool_call>';
const chunk = '<tools><tool_call><param>{"path":"README.MD"}</param></tool_call></tools>';
const events = runSieve([chunk], ['read_file']);
const leakedText = collectText(events);
const hasToolCalls = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
@@ -159,14 +159,14 @@ test('sieve flushes incomplete captured XML tool blocks by falling back to raw t
const events = runSieve(
[
'前置正文G。',
'<tool_calls>\n',
'<tools>\n',
' <tool_call>\n',
' <tool_name>read_file</tool_name>\n',
],
['read_file'],
);
const leakedText = collectText(events);
const expected = ['前置正文G。', '<tool_calls>\n', ' <tool_call>\n', ' <tool_name>read_file</tool_name>\n'].join('');
const expected = ['前置正文G。', '<tools>\n', ' <tool_call>\n', ' <tool_name>read_file</tool_name>\n'].join('');
const hasToolCalls = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
assert.equal(hasToolCalls, false);
assert.equal(leakedText, expected);
@@ -176,7 +176,7 @@ test('sieve captures XML wrapper tags with attributes without leaking wrapper te
const events = runSieve(
[
'前置正文H。',
'<tool_calls id="x"><tool_call><tool_name>read_file</tool_name><parameters>{"path":"README.MD"}</parameters></tool_call></tool_calls>',
'<tools id="x"><tool_call><tool_name>read_file</tool_name><param>{"path":"README.MD"}</param></tool_call></tools>',
'后置正文I。',
],
['read_file'],
@@ -186,8 +186,8 @@ test('sieve captures XML wrapper tags with attributes without leaking wrapper te
assert.equal(hasToolCall, true);
assert.equal(leakedText.includes('前置正文H。'), true);
assert.equal(leakedText.includes('后置正文I。'), true);
assert.equal(leakedText.includes('<tool_calls id=\"x\">'), false);
assert.equal(leakedText.includes('</tool_calls>'), false);
assert.equal(leakedText.includes('<tools id=\"x\">'), false);
assert.equal(leakedText.includes('</tools>'), false);
});
test('sieve keeps plain text intact in tool mode when no tool call appears', () => {
@@ -270,7 +270,7 @@ test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => {
});
test('parseToolCalls rejects mismatched markup tags', () => {
const payload = '<tool_call><name>read_file</function><arguments>{"path":"README.md"}</arguments></tool_call>';
const payload = '<tools><tool_call><tool_name>read_file</function><param>{"path":"README.md"}</param></tool_call></tools>';
const calls = parseToolCalls(payload, ['read_file']);
assert.equal(calls.length, 0);
});