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

@@ -37,7 +37,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl
- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`.
- Adapter responsibilities are streamlined to: **request normalization → DeepSeek invocation → protocol-shaped rendering**, reducing legacy split-logic paths.
- Tool-calling semantics are aligned between Go and Node runtime: parsing is now centered on XML/Markup-family tool syntax (`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml variants), plus stream-time anti-leak filtering.
- Tool-calling semantics are aligned between Go and Node runtime: the only executable model-output syntax is the canonical XML tool block `<tool_calls>` `<invoke name="...">` `<parameter name="...">`, plus stream-time anti-leak filtering.
- `Admin API` separates static config from runtime policy: `/admin/config*` for configuration state, `/admin/settings*` for runtime behavior.
---
@@ -330,7 +330,7 @@ When `tools` is present, DS2API performs anti-leak handling:
Additional notes:
- The parser currently follows XML/Markup-family tool payloads (`<tool_call>`, `<function_call>`, `<invoke>`, `tool_use`, antml variants). Standalone JSON `tool_calls` payloads are not treated as executable tool calls by default.
- The parser currently treats only canonical XML tool blocks (`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`) as executable tool calls. Legacy `<tools>`, `<tool_call>`, `<tool_name>`, `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text.
- `tool_calls` shown inside fenced markdown code blocks (for example, ```json ... ```) are treated as examples, not executable calls.
---

4
API.md
View File

@@ -37,7 +37,7 @@
- OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。
- 适配器层职责收敛为:**请求归一化 → DeepSeek 调用 → 协议形态渲染**,减少历史版本中“同能力多处实现”的分叉。
- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:当前以 XML/Markup 家族解析为主(含 `<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml 变体),并在流式场景执行防泄漏筛分。
- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:当前唯一可执行的模型输出语法是 canonical XML 工具块 `<tool_calls>` `<invoke name="...">` `<parameter name="...">`,并在流式场景执行防泄漏筛分。
- `Admin API` 将配置与运行时策略分开:`/admin/config*` 管静态配置,`/admin/settings*` 管运行时行为。
---
@@ -333,7 +333,7 @@ data: [DONE]
补充说明:
- **非代码块上下文**下,工具负载即使与普通文本混合,也会按特征识别并产出可执行 tool call前后普通文本仍可透传
- 解析器当前走 XML/Markup 家族(包含 `<tool_call>``<function_call>``<invoke>``tool_use`、antml 风格纯 JSON `tool_calls` 片段默认不会直接作为可执行调用解析
- 解析器当前只把 canonical XML 工具块(`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`)作为可执行调用解析;旧式 `<tools>``<tool_call>``<tool_name>``<param>``<function_call>``tool_use`、antml 风格纯 JSON `tool_calls` 片段默认都会按普通文本处理
- Markdown fenced code block例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。
---

View File

@@ -142,7 +142,7 @@ flowchart LR
- `ANTHROPIC_BASE_URL` 推荐直接指向 DS2API 根地址(例如 `http://127.0.0.1:5001`Claude Code 会请求 `/v1/messages?beta=true`。
- `ANTHROPIC_API_KEY` 需要与 `config.json` 中 `keys` 一致;建议同时保留常规 key 与 `sk-ant-*` 形态 key兼容不同客户端校验习惯。
- 若系统设置了代理,建议对 DS2API 地址配置 `NO_PROXY=127.0.0.1,localhost,<你的主机IP>`,避免本地回环请求被代理拦截。
- 如遇“工具调用输出成文本、未执行”问题,请优先检查模型输出是否为受支持的 XML/Markup 工具块(例如 `<tool_call>` / `<function_call>` / `<invoke>` / `tool_use`),而不是纯 JSON `tool_calls` 片段。
- 如遇“工具调用输出成文本、未执行”问题,请优先检查模型输出是否为当前唯一受支持的 XML 工具块:`<tool_calls><invoke name="..."><parameter name="...">...`,而不是旧式 `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`、`<function_call>`、`tool_use`纯 JSON `tool_calls` 片段。
### Gemini 接口
@@ -409,7 +409,7 @@ Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 `
当请求中带 `tools` 时DS2API 会做防泄漏处理与结构化转译:
1. 只在**非代码块上下文**启用执行型 toolcall 识别(代码块示例默认不触发)
2. 解析层当前以 XML/Markup 家族为准(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml 变体纯 JSON `tool_calls` 片段默认不作为可执行调用解析
2. 解析层当前只把 canonical XML 工具块视为可执行调用:`<tool_calls>` `<invoke name="...">` → `<parameter name="...">`;旧式 `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`、`<function_call>`、`tool_use` / antml 变体纯 JSON `tool_calls` 片段都会按普通文本处理
3. `responses` 流式严格使用官方 item 生命周期事件(`response.output_item.*`、`response.content_part.*`、`response.function_call_arguments.*`
4. `responses` 支持并执行 `tool_choice``auto`/`none`/`required`/强制函数);`required` 违规时非流式返回 `422`,流式返回 `response.failed`
5. 客户端请求哪种协议就按该协议返回工具调用OpenAI/Claude/Gemini 各自原生结构);模型侧优先约束输出规范 XML再由兼容层转译

View File

@@ -138,7 +138,7 @@ Besides the current primary aliases above, `/anthropic/v1/models` also returns C
- Set `ANTHROPIC_BASE_URL` to the DS2API root URL (for example `http://127.0.0.1:5001`). Claude Code sends requests to `/v1/messages?beta=true`.
- `ANTHROPIC_API_KEY` must match an entry in `keys` from `config.json`. Keeping both a regular key and an `sk-ant-*` style key improves client compatibility.
- If your environment has proxy variables, set `NO_PROXY=127.0.0.1,localhost,<your_host_ip>` for DS2API to avoid proxy interception of local traffic.
- If tool calls are rendered as plain text and not executed, first verify the model output uses supported XML/Markup tool blocks (`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use`) rather than standalone JSON `tool_calls`.
- If tool calls are rendered as plain text and not executed, first verify the model output uses the only supported XML block: `<tool_calls><invoke name="..."><parameter name="...">...`, not legacy `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`, `<function_call>`, `tool_use`, or standalone JSON `tool_calls`.
### Gemini Endpoint
@@ -383,7 +383,7 @@ Queue limit = DS2API_ACCOUNT_MAX_QUEUE (default = recommended concurrency)
When `tools` is present in the request, DS2API performs anti-leak handling:
1. Toolcall feature matching is enabled only in **non-code-block context** (fenced examples are ignored)
2. The parser currently targets XML/Markup-family tool syntax (`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml variants); standalone JSON `tool_calls` payloads are not treated as executable calls by default
2. The parser now treats only the canonical XML wrapper as executable tool-calling syntax: `<tool_calls>` `<invoke name="...">``<parameter name="...">`; legacy `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text
3. `responses` streaming strictly uses official item lifecycle events (`response.output_item.*`, `response.content_part.*`, `response.function_call_arguments.*`)
4. `responses` supports and enforces `tool_choice` (`auto`/`none`/`required`/forced function); `required` violations return `422` for non-stream and `response.failed` for stream
5. The output protocol follows the client request (OpenAI / Claude / Gemini native shapes); model-side prompting can prefer XML, and the compatibility layer handles the protocol-specific translation

View File

@@ -116,7 +116,7 @@ flowchart LR
- `internal/translatorcliproxy`: structure translation between Claude/Gemini and OpenAI.
- `internal/deepseek`: upstream request/session/PoW/SSE handling.
- `internal/stream` + `internal/sse`: stream parsing and incremental assembly.
- `internal/toolcall`: XML/Markup-family tool-call parsing + anti-leak sieve (`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml variants).
- `internal/toolcall`: canonical XML tool-call parsing + anti-leak sieve (the only executable format is `<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`).
- `internal/admin`: config/accounts/vercel sync/version/dev-capture endpoints.
- `internal/config`: config loading/validation + runtime settings hot-reload.
- `internal/account`: managed account pool, inflight slots, waiting queue.

View File

@@ -116,7 +116,7 @@ flowchart LR
- `internal/translatorcliproxy`Claude/Gemini 与 OpenAI 结构互转。
- `internal/deepseek`上游请求、会话、PoW、SSE 消费。
- `internal/stream` + `internal/sse`:流式解析与增量处理。
- `internal/toolcall`以 XML/Markup 家族为核心的工具调用解析与防泄漏筛分(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml 变体)。
- `internal/toolcall`canonical XML 工具调用解析与防泄漏筛分(唯一可执行格式:`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`)。
- `internal/admin`配置管理、账号管理、Vercel 同步、版本检查、开发抓包。
- `internal/config`:配置加载、校验、运行时 settings 热更新。
- `internal/account`:托管账号池、并发槽位、等待队列。

View File

@@ -96,6 +96,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
- `prompt` 才是对话上下文主载体。
- `ref_file_ids` 只承载文件引用,不承载普通文本消息。
- `tools` 不会作为“原生工具 schema”直接下发给下游而是被改写进 `prompt`
- 客户端显式传入的 thinking / reasoning 开关会被归一到下游 `thinking_enabled`;关闭时即使上游返回 `response/thinking_content`,兼容层也不会把它当作可见正文输出。
## 5. prompt 是怎么拼出来的
@@ -178,16 +179,15 @@ assistant 的 reasoning 会变成一个显式标签块:
assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON而会转成 prompt 可见的 XML
```xml
<tools>
<tool_call>
<tool_name>read_file</tool_name>
<param>
<path><![CDATA[src/main.go]]></path>
</param>
</tool_call>
</tools>
<tool_calls>
<invoke name="read_file">
<parameter name="path"><![CDATA[src/main.go]]></parameter>
</invoke>
</tool_calls>
```
这也是当前项目里唯一受支持的 canonical tool-calling 形态;其他形态都会作为普通文本保留,不会作为可执行调用语法。
这件事很重要,因为它决定了:
- 历史工具调用在 prompt 中是“可见文本历史”
@@ -242,15 +242,17 @@ OpenAI 文件相关实现:
1. 旧历史消息被切出去。
2. 旧历史会被重新序列化成一个文本文件。
3. 文件名固定是 `IGNORE`
4. 文件上传后,其 `file_id` 会排在 `ref_file_ids` 最前面
5. live prompt 只保留:
3. 真正上传的文件名固定是 `HISTORY.txt`
4. 文件内容内部会使用 `IGNORE` 这层包装名来闭合 DeepSeek 官网原生文件标记
5. 该文件上传后,其 `file_id` 会排在 `ref_file_ids` 最前面。
6. live prompt 只保留:
- system / developer
- 最新 user turn 起的上下文
历史文件内容不是普通自由文本,而是用同一套角色标记再次序列化出的 transcript
```text
[uploaded filename]: HISTORY.txt
[file content end]
<begin▁of▁sentence><User>...<Assistant>...<Tool>...

View File

@@ -1,74 +1,67 @@
# Tool call parsing semanticsGo/Node 统一语义)
本文档描述当前代码中工具调用解析链路的**实际行为**`internal/toolcall``internal/js/helpers/stream-tool-sieve` 为准
本文档描述当前代码中的**实际行为**`internal/toolcall``internal/js/helpers/stream-tool-sieve` 为准。
文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [测试指南](./TESTING.md)
## 1) 当前输出结构
## 1) 当前唯一可执行格式
当前版本只把下面这类 canonical XML 视为可执行工具调用:
```xml
<tool_calls>
<invoke name="read_file">
<parameter name="path"><![CDATA[README.MD]]></parameter>
</invoke>
</tool_calls>
```
约束:
- 必须有 `<tool_calls>...</tool_calls>` wrapper
- 每个调用必须在 `<invoke name="...">...</invoke>`
- 工具名必须放在 `invoke``name` 属性
- 参数必须使用 `<parameter name="...">...</parameter>`
## 2) 非 canonical 内容
任何不满足上述 canonical XML 形态的内容,都会保留为普通文本,不会执行。
## 3) 流式与防泄漏行为
在流式链路中Go / Node 一致):
- 只有从 `<tool_calls` 开始的 canonical wrapper 才会进入结构化捕获
- 已识别成功的工具调用不会再次回流到普通文本
- 不符合新格式的块不会执行,并继续按原样文本透传
- fenced code block 中的 XML 示例始终按普通文本处理
## 4) 输出结构
`ParseToolCallsDetailed` / `parseToolCallsDetailed` 返回:
- `calls`:解析出的工具调用列表(`name` + `input`
- `sawToolCallSyntax`:检测到工具调用语法特征时`true`
- `rejectedByPolicy`:当前实现固定为 `false`(预留字段)。
- `rejectedToolNames`:当前实现固定为空数组(预留字段)。
- `calls`:解析出的工具调用列表(`name` + `input`
- `sawToolCallSyntax`只有检测到 `<tool_calls` 时才会`true`
- `rejectedByPolicy`:当前固定为 `false`
- `rejectedToolNames`:当前固定为空数组
> 当前 `filterToolCallsDetailed` 仅做结构清洗,不做 allow-list 工具名硬拒绝。
## 5) 落地建议
## 2) 解析范围(重点)
1. Prompt 里只示范 canonical XML 语法。
2. 上游客户端需要直接输出 canonical XMLDS2API 不会把其他形态改写成工具调用。
3. 不要依赖 parser 做安全控制;执行器侧仍应做工具名和参数校验。
当前版本的可执行解析以 **XML/Markup 家族**为主:
- `<tool_call>...</tool_call>`
- `<function_call>...</function_call>`
- `<invoke ...>...</invoke>`(含自闭合)
- `<tool_use>...</tool_use>`
- antml 变体(如 `antml:function_call` / `antml:argument`
并支持在这些标记块内部解析:
- JSON 参数字符串
- 标签参数(`<parameter name="...">...`
- key/value 风格子标签
## 3) 不应再假设的行为
以下说法在当前实现中已不成立:
1. “纯 JSON `tool_calls` 片段会被直接当作可执行工具调用解析”。
2. “存在 `toolcall.mode` / `toolcall.early_emit_confidence` 等可配置开关可以改变解析策略”。
当前策略在代码中固定为:
- 特征匹配开启feature-match on
- 高置信度早发开启early emit on
- policy 拒绝字段保留但未启用
## 4) 流式与防泄漏语义
在流式链路中OpenAI / Claude / Gemini 统一内核):
- 工具调用片段会被优先提取为结构化增量输出;
- 已识别的工具调用原始片段不会作为普通文本再次回流;
- fenced code block 中的示例内容按文本处理,不作为可执行工具调用。
## 5) 落地建议(按当前实现)
1. Prompt 里优先约束模型输出 XML/Markup 工具块。
2. 执行器侧继续做工具名白名单与参数 schema 校验(不要依赖 parser 代替安全策略)。
3. 需要兼容历史“纯 JSON tool_calls”模型输出时请在上游模板层把输出规范化为 XML/Markup 风格再进入 DS2API。
## 6) 回归验证建议
## 6) 回归验证
可直接运行:
```bash
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/
go test -v -run 'TestParseToolCalls|TestProcessToolSieve' ./internal/toolcall ./internal/adapter/openai
node --test tests/node/stream-tool-sieve.test.js
```
重点覆盖:
- `<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml 变体
- 参数 JSON 修复与解析
- 流式增量下的工具调用提取与文本防泄漏
- canonical `<tool_calls>` wrapper 正常解析
- 非 canonical 内容按普通文本透传
- 代码块示例不执行

View File

@@ -52,6 +52,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
}
}
translatedReq := translatorcliproxy.ToOpenAI(sdktranslator.FormatClaude, translateModel, raw, stream)
translatedReq = applyExplicitThinkingOverrideToOpenAIRequest(translatedReq, req)
isVercelPrepare := strings.TrimSpace(r.URL.Query().Get("__stream_prepare")) == "1"
isVercelRelease := strings.TrimSpace(r.URL.Query().Get("__stream_release")) == "1"
@@ -123,6 +124,27 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
return true
}
func applyExplicitThinkingOverrideToOpenAIRequest(translated []byte, original map[string]any) []byte {
enabled, ok := util.ResolveThinkingOverride(original)
if !ok {
return translated
}
req := map[string]any{}
if err := json.Unmarshal(translated, &req); err != nil {
return translated
}
typ := "disabled"
if enabled {
typ = "enabled"
}
req["thinking"] = map[string]any{"type": typ}
out, err := json.Marshal(req)
if err != nil {
return translated
}
return out
}
func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string) {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {

View File

@@ -251,14 +251,14 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
payload string
wantToolUse bool
}{
{name: "canonical_tools_wrapper", payload: `<tools><tool_call><tool_name>Bash</tool_name><param><command>pwd</command></param></tool_call></tools>`, wantToolUse: true},
{name: "invoke_parameter_wrapper", payload: `<tool_calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></tool_calls>`, 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},
{name: "legacy_function_calls_wrapper", payload: `<function_calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></function_calls>`, wantToolUse: false},
}
for _, tc := range tests {
@@ -291,7 +291,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) {
h := &Handler{}
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>"
payload := "I'll call a tool now.\\n<tool_calls><invoke name=\\\"write_file\\\"><parameter name=\\\"path\\\">/tmp/a.txt</parameter><parameter name=\\\"content\\\">abc</parameter></invoke></tool_calls>"
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, "<tools>") || !containsStr(content, "<tool_name>search_web</tool_name>") {
if !containsStr(content, "<tool_calls>") || !containsStr(content, `<invoke name="search_web">`) {
t.Fatalf("expected assistant content to include XML tool call history, got %q", content)
}
if !containsStr(content, "<param>\n <query><![CDATA[latest]]></query>\n </param>") {
if !containsStr(content, `<parameter name="query"><![CDATA[latest]]></parameter>`) {
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, "<tools>") {
if !containsStr(prompt, "<tool_calls>") {
t.Fatalf("expected XML tool_calls format in prompt")
}
if !containsStr(prompt, "TOOL CALL FORMAT") {

View File

@@ -106,6 +106,26 @@ func TestClaudeProxyViaOpenAIUsesGlobalAliasMapping(t *testing.T) {
}
}
func TestClaudeProxyViaOpenAIPreservesThinkingOverride(t *testing.T) {
openAI := &openAIProxyCaptureStub{}
h := &Handler{
Store: claudeProxyStoreStub{aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}},
OpenAI: openAI,
}
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled"},"stream":false}`))
rec := httptest.NewRecorder()
h.Messages(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
thinking, _ := openAI.seenReq["thinking"].(map[string]any)
if thinking["type"] != "disabled" {
t.Fatalf("expected translated OpenAI request to preserve disabled thinking, got %#v", openAI.seenReq)
}
}
func TestClaudeProxyTranslatesInlineImageToOpenAIDataURL(t *testing.T) {
openAI := &openAIProxyCaptureStub{}
h := &Handler{OpenAI: openAI}

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<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: {"p":"response/content","v":"前置文本\n<tool_calls>\n <invoke name=\"read_file\">\n <parameter name=\"path\">README.MD</parameter>\n </invoke>\n</tool_calls>"}`,
`data: {"p":"response/content","v":"中间文本\n<tool_calls>\n <invoke name=\"search\">\n <parameter name=\"q\">golang</parameter>\n </invoke>\n</tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()

View File

@@ -12,9 +12,10 @@ import (
)
const (
historySplitFilename = "IGNORE"
historySplitContentType = "text/plain; charset=utf-8"
historySplitPurpose = "assistants"
historySplitFilename = "HISTORY.txt"
historySplitInjectedFilename = "IGNORE"
historySplitContentType = "text/plain; charset=utf-8"
historySplitPurpose = "assistants"
)
func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq util.StandardRequest) (util.StandardRequest, error) {
@@ -114,7 +115,7 @@ func buildOpenAIHistoryTranscript(messages []any) string {
if transcript == "" {
return ""
}
return fmt.Sprintf("[file content end]\n\n%s\n\n[file name]: %s\n[file content begin]\n", transcript, historySplitFilename)
return fmt.Sprintf("[file content end]\n\n%s\n\n[file name]: %s\n[file content begin]\n", transcript, historySplitInjectedFilename)
}
func prependUniqueRefFileID(existing []string, fileID string) []string {

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, "<tools>") {
if !strings.Contains(transcript, "<tool_calls>") {
t.Fatalf("expected tool calls preserved, got %q", transcript)
}
if !strings.HasSuffix(transcript, "\n[file name]: IGNORE\n[file content begin]\n") {
@@ -180,7 +180,7 @@ func TestApplyHistorySplitCarriesHistoryText(t *testing.T) {
}
}
func TestChatCompletionsHistorySplitUploadsIgnoreFileAndKeepsLatestPrompt(t *testing.T) {
func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &Handler{
Store: mockOpenAIConfig{
@@ -210,7 +210,7 @@ func TestChatCompletionsHistorySplitUploadsIgnoreFileAndKeepsLatestPrompt(t *tes
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
upload := ds.uploadCalls[0]
if upload.Filename != "IGNORE" {
if upload.Filename != "HISTORY.txt" {
t.Fatalf("unexpected upload filename: %q", upload.Filename)
}
if upload.Purpose != "assistants" {

View File

@@ -38,10 +38,10 @@ 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, "<tools>") {
if !strings.Contains(assistantContent, "<tool_calls>") {
t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent)
}
if !strings.Contains(assistantContent, "<tool_name>get_weather</tool_name>") {
if !strings.Contains(assistantContent, `<invoke name="get_weather">`) {
t.Fatalf("expected tool name in preserved history, got %q", assistantContent)
}
if !strings.Contains(normalized[3]["content"].(string), `"temp":18`) {
@@ -49,7 +49,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
}
prompt := util.MessagesPrepare(normalized)
if !strings.Contains(prompt, "<tools>") {
if !strings.Contains(prompt, "<tool_calls>") {
t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt)
}
}
@@ -177,10 +177,10 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
t.Fatalf("expected assistant tool_call-only message preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if strings.Count(content, "<tool_call>") != 2 {
if strings.Count(content, "<invoke name=") != 2 {
t.Fatalf("expected two preserved tool call blocks, got %q", content)
}
if !strings.Contains(content, "<tool_name>search_web</tool_name>") || !strings.Contains(content, "<tool_name>eval_javascript</tool_name>") {
if !strings.Contains(content, `<invoke name="search_web">`) || !strings.Contains(content, `<invoke name="eval_javascript">`) {
t.Fatalf("expected both tool names in preserved history, got %q", content)
}
}
@@ -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, "<tools>") {
if !strings.Contains(content, "<tool_calls>") {
t.Fatalf("expected assistant tool history in normalized content, got %q", content)
}
}

View File

@@ -47,10 +47,10 @@ 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, "<tools>") {
if !strings.Contains(finalPrompt, "<tool_calls>") {
t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "<tool_name>get_weather</tool_name>") {
if !strings.Contains(finalPrompt, `<invoke name="get_weather">`) {
t.Fatalf("handler finalPrompt should include tool name history: %q", finalPrompt)
}
}
@@ -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 <tools>...</tools> XML block at the end of your response.") {
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <tool_calls>...</tool_calls> 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

@@ -122,8 +122,8 @@ func TestHandleResponsesStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t
return "data: " + string(b) + "\n"
}
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>") +
streamBody := sseLine("前置文本\n<tool_calls>\n <invoke name=\"read_file\">\n <parameter name=\"path\">README.MD</parameter>\n </invoke>\n</tool_calls>") +
sseLine("中间文本\n<tool_calls>\n <invoke name=\"search\">\n <parameter name=\"q\">golang</parameter>\n </invoke>\n</tool_calls>") +
"data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,

View File

@@ -136,6 +136,22 @@ func TestNormalizeOpenAIResponsesRequestThinkingExtraBodyFallback(t *testing.T)
}
}
func TestNormalizeOpenAIResponsesRequestReasoningDisablesThinking(t *testing.T) {
store := newEmptyStoreForNormalizeTest(t)
req := map[string]any{
"model": "gpt-4o",
"input": "ping",
"reasoning": map[string]any{"effort": "none"},
}
n, err := normalizeOpenAIResponsesRequest(store, req, "")
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
if n.Thinking {
t.Fatalf("expected reasoning.effort=none to disable thinking")
}
}
func TestNormalizeOpenAIResponsesRequestToolChoiceRequired(t *testing.T) {
store := newEmptyStoreForNormalizeTest(t)
req := map[string]any{

View File

@@ -9,42 +9,27 @@ import (
// --- XML tool call support for the streaming sieve ---
//nolint:unused // kept as explicit tag inventory for future XML sieve refinements.
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{"<tools", "<tool_call",
// Agent-style XML tags
"<attempt_completion", "<ask_followup_question", "<new_task", "<result"}
var xmlToolCallClosingTags = []string{"</tool_calls>"}
var xmlToolCallOpeningTags = []string{"<tool_calls"}
// 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 }{
{"<tools", "</tools>"},
{"<tool_call", "</tool_call>"},
// 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.
{"<attempt_completion", "</attempt_completion>"},
{"<ask_followup_question", "</ask_followup_question>"},
{"<new_task", "</new_task>"},
{"<tool_calls", "</tool_calls>"},
}
// xmlToolCallBlockPattern matches a complete XML tool call block (wrapper or standalone).
// xmlToolCallBlockPattern matches a complete canonical XML tool call block.
//
//nolint:unused // reserved for future fast-path XML block detection.
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>)`)
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(<tool_calls\b[^>]*>\s*(?:.*?)\s*</tool_calls>)`)
// xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart.
var xmlToolTagsToDetect = []string{"<tools>", "<tools\n", "<tools ", "<tool_call>", "<tool_call\n", "<tool_call ",
// Agent-style tags
"<attempt_completion>", "<ask_followup_question>", "<new_task>"}
var xmlToolTagsToDetect = []string{"<tool_calls>", "<tool_calls\n", "<tool_calls "}
// consumeXMLToolCapture tries to extract complete XML tool call blocks from captured text.
func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) {
lower := strings.ToLower(captured)
// Find the FIRST matching open/close pair, preferring wrapper tags.
// Tag pairs are ordered longest-first (e.g. <tool_calls before <tool_call)
// so wrapper tags are checked before inner tags.
// Find the FIRST matching open/close pair for the canonical wrapper.
for _, pair := range xmlToolCallTagPairs {
openIdx := strings.Index(lower, pair.open)
if openIdx < 0 {
@@ -54,8 +39,7 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string,
closeIdx := strings.LastIndex(lower, pair.close)
if closeIdx < openIdx {
// Opening tag is present but its specific closing tag hasn't arrived.
// Return not-ready so we keep buffering — do NOT fall through to
// try inner pairs (e.g. <tool_call inside <tool_calls).
// Return not-ready so we keep buffering until the canonical wrapper closes.
return "", nil, "", false
}
closeEnd := closeIdx + len(pair.close)
@@ -88,8 +72,8 @@ func hasOpenXMLToolTag(captured string) bool {
return false
}
// findPartialXMLToolTagStart checks if the string ends with a partial XML tool tag
// (e.g., "<tool_ca" or "<inv") and returns the position of the '<'.
// findPartialXMLToolTagStart checks if the string ends with a partial canonical
// XML wrapper tag (e.g., "<too") and returns the position of the '<'.
func findPartialXMLToolTagStart(s string) int {
lastLT := strings.LastIndex(s, "<")
if lastLT < 0 {

View File

@@ -9,12 +9,11 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) {
var state toolStreamSieveState
// Simulate a model producing XML tool call output chunk by chunk.
chunks := []string{
"<tools>\n",
" <tool_call>\n",
" <tool_name>read_file</tool_name>\n",
` <param>{"path":"README.MD"}</param>` + "\n",
" </tool_call>\n",
"</tools>",
"<tool_calls>\n",
` <invoke name="read_file">` + "\n",
` <parameter name="path">README.MD</parameter>` + "\n",
" </invoke>\n",
"</tool_calls>",
}
var events []toolStreamEvent
for _, c := range chunks {
@@ -31,7 +30,7 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) {
toolCalls += len(evt.ToolCalls)
}
if strings.Contains(textContent, "<tool_call") {
if strings.Contains(textContent, "<invoke ") {
t.Fatalf("XML tool call content leaked to text: %q", textContent)
}
if strings.Contains(textContent, "read_file") {
@@ -48,10 +47,10 @@ func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) {
payload := strings.Repeat("x", 4096)
splitAt := len(payload) / 2
chunks := []string{
"<tools>\n <tool_call>\n <tool_name>" + toolName + "</tool_name>\n <param>\n <content><![CDATA[",
"<tool_calls>\n <invoke name=\"" + toolName + "\">\n <parameter name=\"content\"><![CDATA[",
payload[:splitAt],
payload[splitAt:],
"]]></content>\n </param>\n </tool_call>\n</tools>",
"]]></parameter>\n </invoke>\n</tool_calls>",
}
var events []toolStreamEvent
@@ -90,8 +89,8 @@ func TestProcessToolSieveXMLWithLeadingText(t *testing.T) {
// Model outputs some prose then an XML tool call.
chunks := []string{
"Let me check the file.\n",
"<tools>\n <tool_call>\n <tool_name>read_file</tool_name>\n",
` <param>{"path":"go.mod"}</param>` + "\n </tool_call>\n</tools>",
"<tool_calls>\n <invoke name=\"read_file\">\n",
` <parameter name="path">go.mod</parameter>` + "\n </invoke>\n</tool_calls>",
}
var events []toolStreamEvent
for _, c := range chunks {
@@ -113,7 +112,7 @@ func TestProcessToolSieveXMLWithLeadingText(t *testing.T) {
t.Fatalf("expected leading text to be emitted, got %q", textContent)
}
// The XML itself should NOT leak.
if strings.Contains(textContent, "<tool_call") {
if strings.Contains(textContent, "<invoke ") {
t.Fatalf("XML tool call content leaked to text: %q", textContent)
}
if toolCalls == 0 {
@@ -143,7 +142,7 @@ func TestProcessToolSievePassesThroughNonToolXMLBlock(t *testing.T) {
func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) {
var state toolStreamSieveState
chunk := `<tool><title>plain xml</title></tool><tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.MD"}</param></tool_call></tools>`
chunk := `<tool><title>plain xml</title></tool><tool_calls><invoke name="read_file"><parameter name="path">README.MD</parameter></invoke></tool_calls>`
events := processToolSieveChunk(&state, chunk, []string{"read_file"})
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
@@ -156,8 +155,8 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) {
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(), `<tools><tool_call>`) {
t.Fatalf("expected canonical tool XML to be intercepted, got %q", textContent.String())
if strings.Contains(textContent.String(), `<tool_calls><invoke`) {
t.Fatalf("expected invoke 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 +165,7 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) {
func TestProcessToolSievePassesThroughMalformedExecutableXMLBlock(t *testing.T) {
var state toolStreamSieveState
chunk := `<tools><tool_call><param>{"path":"README.md"}</param></tool_call></tools>`
chunk := `<tool_calls><invoke name="read_file"><param>{"path":"README.md"}</param></invoke></tool_calls>`
events := processToolSieveChunk(&state, chunk, []string{"read_file"})
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
@@ -189,17 +188,17 @@ func TestProcessToolSievePassesThroughFencedXMLToolCallExamples(t *testing.T) {
var state toolStreamSieveState
input := strings.Join([]string{
"Before first example.\n```",
"xml\n<tools><tool_call><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n",
"xml\n<tool_calls><invoke name=\"read_file\"><parameter name=\"path\">README.md</parameter></invoke></tool_calls>\n```\n",
"Between examples.\n```xml\n",
"<tools><tool_call><tool_name>search</tool_name><param>{\"q\":\"golang\"}</param></tool_call></tools>\n",
"<tool_calls><invoke name=\"search\"><parameter name=\"q\">golang</parameter></invoke></tool_calls>\n",
"```\nAfter examples.",
}, "")
chunks := []string{
"Before first example.\n```",
"xml\n<tools><tool_call><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n",
"xml\n<tool_calls><invoke name=\"read_file\"><parameter name=\"path\">README.md</parameter></invoke></tool_calls>\n```\n",
"Between examples.\n```xml\n",
"<tools><tool_call><tool_name>search</tool_name><param>{\"q\":\"golang\"}</param></tool_call></tools>\n",
"<tool_calls><invoke name=\"search\"><parameter name=\"q\">golang</parameter></invoke></tool_calls>\n",
"```\nAfter examples.",
}
@@ -230,13 +229,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><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n",
"lls><invoke name=\"read_file\"><parameter name=\"path\">README.md</parameter></invoke></tool_calls>\n```\n",
"Done.",
}, "")
chunks := []string{
"Example:\n```xml\n<tool_ca",
"ll><tool_name>read_file</tool_name><param>{\"path\":\"README.md\"}</param></tool_call></tools>\n```\n",
"lls><invoke name=\"read_file\"><parameter name=\"path\">README.md</parameter></invoke></tool_calls>\n```\n",
"Done.",
}
@@ -266,15 +265,15 @@ func TestProcessToolSieveKeepsPartialXMLTagInsideFencedExample(t *testing.T) {
func TestProcessToolSievePartialXMLTagHeldBack(t *testing.T) {
var state toolStreamSieveState
// Chunk ends with a partial XML tool tag.
events := processToolSieveChunk(&state, "Hello <tool_ca", []string{"read_file"})
events := processToolSieveChunk(&state, "Hello <too", []string{"read_file"})
var textContent string
for _, evt := range events {
textContent += evt.Content
}
// "Hello " should be emitted, but "<tool_ca" should be held back.
if strings.Contains(textContent, "<tool_ca") {
// "Hello " should be emitted, but "<too" should be held back.
if strings.Contains(textContent, "<too") {
t.Fatalf("partial XML tag should not be emitted, got %q", textContent)
}
if !strings.Contains(textContent, "Hello") {
@@ -288,9 +287,9 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) {
input string
want int
}{
{"tools_tag", "some text <tools>\n", 10},
{"tool_call_tag", "prefix <tool_call>\n", 7},
{"xml_inside_code_fence", "```xml\n<tools><tool_call><tool_name>read_file</tool_name></tool_call></tools>\n```", -1},
{"tool_calls_tag", "some text <tool_calls>\n", 10},
{"bare_tool_call_text", "prefix <tool_call>\n", -1},
{"xml_inside_code_fence", "```xml\n<tool_calls><invoke name=\"read_file\"></invoke></tool_calls>\n```", -1},
{"no_xml", "just plain text", -1},
{"gemini_json_no_detect", `some text {"functionCall":{"name":"search"}}`, -1},
}
@@ -310,10 +309,10 @@ func TestFindPartialXMLToolTagStart(t *testing.T) {
input string
want int
}{
{"partial_tools", "Hello <too", 6},
{"partial_tool_call", "Hello <tool_ca", 6},
{"partial_tool_calls", "Hello <tool_ca", 6},
{"bare_tool_call_not_held", "Hello <tool_name", -1},
{"partial_lt_only", "Text <", 5},
{"complete_tag", "Text <tools>done", -1},
{"complete_tag", "Text <tool_calls>done", -1},
{"no_lt", "plain text", -1},
{"closed_lt", "a < b > c", -1},
}
@@ -328,10 +327,10 @@ func TestFindPartialXMLToolTagStart(t *testing.T) {
}
func TestHasOpenXMLToolTag(t *testing.T) {
if !hasOpenXMLToolTag("<tools>\n<tool_call>\n<tool_name>foo</tool_name>") {
if !hasOpenXMLToolTag("<tool_calls>\n<invoke name=\"foo\">") {
t.Fatal("should detect open XML tool tag without closing tag")
}
if hasOpenXMLToolTag("<tools>\n<tool_call>\n<tool_name>foo</tool_name></tool_call>\n</tools>") {
if hasOpenXMLToolTag("<tool_calls>\n<invoke name=\"foo\"></invoke>\n</tool_calls>") {
t.Fatal("should return false when closing tag is present")
}
if hasOpenXMLToolTag("plain text without any XML") {
@@ -340,44 +339,29 @@ func TestHasOpenXMLToolTag(t *testing.T) {
}
// Test the EXACT scenario the user reports: token-by-token streaming where
// <tools> tag arrives in small pieces.
// <tool_calls> 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",
"s",
"_ca",
"lls",
">\n",
" <",
"tool",
"_call",
">\n",
" <",
"tool",
"_name",
">",
" <in",
"voke",
` name="`,
"read",
"_file",
`">` + "\n",
" <para",
`meter name="path">`,
"README.MD",
"</parameter>\n",
" </invoke>\n",
"</",
"tool",
"_name",
">\n",
" <",
"param",
">",
`{"path"`,
`: "README.MD"`,
`}`,
"</",
"param",
">\n",
" </",
"tool",
"_call",
">\n",
"</",
"tools",
"tool_calls",
">",
}
var events []toolStreamEvent
@@ -395,10 +379,10 @@ func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) {
toolCalls += len(evt.ToolCalls)
}
if strings.Contains(textContent, "<tool_call") {
if strings.Contains(textContent, "<invoke ") {
t.Fatalf("XML tool call content leaked to text in token-by-token mode: %q", textContent)
}
if strings.Contains(textContent, "tools>") {
if strings.Contains(textContent, "tool_calls>") {
t.Fatalf("closing tag fragment leaked to text: %q", textContent)
}
if strings.Contains(textContent, "read_file") {
@@ -414,9 +398,8 @@ func TestFlushToolSieveIncompleteXMLFallsBackToText(t *testing.T) {
var state toolStreamSieveState
// XML block starts but stream ends before completion.
chunks := []string{
"<tools>\n",
" <tool_call>\n",
" <tool_name>read_file</tool_name>\n",
"<tool_calls>\n",
" <invoke name=\"read_file\">\n",
}
var events []toolStreamEvent
for _, c := range chunks {
@@ -437,19 +420,19 @@ func TestFlushToolSieveIncompleteXMLFallsBackToText(t *testing.T) {
}
}
// Test that the opening tag "<tools>\n " is NOT emitted as text content.
// Test that the opening tag "<tool_calls>\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, "<tools>\n ", []string{"read_file"})
evts1 := processToolSieveChunk(&state, "<tool_calls>\n ", []string{"read_file"})
for _, evt := range evts1 {
if strings.Contains(evt.Content, "<tools>") {
if strings.Contains(evt.Content, "<tool_calls>") {
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 <param>{\"path\":\"README.MD\"}</param>\n </tool_call>\n</tools>", []string{"read_file"})
evts2 := processToolSieveChunk(&state, "<invoke name=\"read_file\">\n <parameter name=\"path\">README.MD</parameter>\n </invoke>\n</tool_calls>", []string{"read_file"})
evts2 = append(evts2, flushToolSieve(&state, []string{"read_file"})...)
var textContent string
@@ -462,7 +445,7 @@ func TestOpeningXMLTagNotLeakedAsContent(t *testing.T) {
toolCalls += len(evt.ToolCalls)
}
if strings.Contains(textContent, "<tool_call") {
if strings.Contains(textContent, "<invoke ") {
t.Fatalf("XML content leaked: %q", textContent)
}
if toolCalls == 0 {
@@ -501,3 +484,24 @@ func TestProcessToolSieveFallsBackToRawAttemptCompletion(t *testing.T) {
t.Fatalf("expected agent XML to fall back to raw text, got %q", textContent)
}
}
func TestProcessToolSievePassesThroughBareToolCallAsText(t *testing.T) {
var state toolStreamSieveState
chunk := `<invoke name="read_file"><parameter name="path">README.md</parameter></invoke>`
events := processToolSieveChunk(&state, chunk, []string{"read_file"})
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
var textContent strings.Builder
toolCalls := 0
for _, evt := range events {
textContent.WriteString(evt.Content)
toolCalls += len(evt.ToolCalls)
}
if toolCalls != 0 {
t.Fatalf("expected bare invoke to remain text, got %d events=%#v", toolCalls, events)
}
if textContent.String() != chunk {
t.Fatalf("expected bare invoke to pass through unchanged, got %q", textContent.String())
}
}

View File

@@ -8,7 +8,7 @@ const {
stripFencedCodeBlocks,
} = require('./parse_payload');
const TOOL_MARKUP_PREFIXES = ['<tools', '<tool_call'];
const TOOL_MARKUP_PREFIXES = ['<tool_calls'];
function extractToolNames(tools) {
if (!Array.isArray(tools) || tools.length === 0) {

View File

@@ -1,10 +1,11 @@
'use strict';
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 TOOLS_WRAPPER_PATTERN = /<tool_calls\b[^>]*>([\s\S]*?)<\/tool_calls>/gi;
const TOOL_CALL_MARKUP_BLOCK_PATTERN = /<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?invoke>/gi;
const PARAMETER_BLOCK_PATTERN = /<(?:[a-z0-9_:-]+:)?parameter\b([^>]*)>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameter>/gi;
const TOOL_CALL_MARKUP_KV_PATTERN = /<(?:[a-z0-9_:-]+:)?([a-z0-9_.-]+)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi;
const CDATA_PATTERN = /^<!\[CDATA\[([\s\S]*?)]]>$/i;
const XML_ATTR_PATTERN = /\b([a-z0-9_:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gi;
const {
toStringSafe,
@@ -27,7 +28,7 @@ function parseMarkupToolCalls(text) {
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());
const parsed = parseMarkupSingleToolCall(block);
if (parsed) {
out.push(parsed);
}
@@ -36,33 +37,43 @@ function parseMarkupToolCalls(text) {
return out;
}
function parseMarkupSingleToolCall(inner) {
// Try inline JSON parse for the inner content.
function parseMarkupSingleToolCall(block) {
const attrs = parseTagAttributes(block[1]);
const name = toStringSafe(attrs.name).trim();
if (!name) {
return null;
}
const inner = toStringSafe(block[2]).trim();
if (inner) {
try {
const decoded = JSON.parse(inner);
if (decoded && typeof decoded === 'object' && !Array.isArray(decoded) && decoded.name) {
if (decoded && typeof decoded === 'object' && !Array.isArray(decoded)) {
return {
name: toStringSafe(decoded.name),
input: decoded.input && typeof decoded.input === 'object' && !Array.isArray(decoded.input) ? decoded.input : {},
name,
input: decoded.input && typeof decoded.input === 'object' && !Array.isArray(decoded.input)
? decoded.input
: decoded.parameters && typeof decoded.parameters === 'object' && !Array.isArray(decoded.parameters)
? decoded.parameters
: {},
};
}
} catch (_err) {
// Not JSON, continue with markup parsing.
}
}
const match = inner.match(TOOL_CALL_CANONICAL_BODY_PATTERN);
if (!match || match.length < 3) {
const input = {};
for (const match of inner.matchAll(PARAMETER_BLOCK_PATTERN)) {
const parameterAttrs = parseTagAttributes(match[1]);
const paramName = toStringSafe(parameterAttrs.name).trim();
if (!paramName) {
continue;
}
appendMarkupValue(input, paramName, parseMarkupValue(match[2]));
}
if (Object.keys(input).length === 0 && inner.trim() !== '') {
return null;
}
const name = extractRawTagValue(match[1]).trim();
if (!name) {
return null;
}
const input = parseMarkupInput(match[2]);
return { name, input };
}
@@ -124,11 +135,14 @@ function parseMarkupValue(raw) {
}
}
try {
return JSON.parse(s);
} catch (_err) {
return s;
if (s.startsWith('{') || s.startsWith('[')) {
try {
return JSON.parse(s);
} catch (_err) {
return s;
}
}
return s;
}
function extractRawTagValue(inner) {
@@ -158,6 +172,22 @@ function unescapeHtml(safe) {
.replace(/&#x27;/g, "'");
}
function parseTagAttributes(raw) {
const source = toStringSafe(raw);
const out = {};
if (!source) {
return out;
}
for (const match of source.matchAll(XML_ATTR_PATTERN)) {
const key = toStringSafe(match[1]).trim().toLowerCase();
if (!key) {
continue;
}
out[key] = match[3] || match[4] || '';
}
return out;
}
function parseToolCallInput(v) {
if (v == null) {
return {};

View File

@@ -1,17 +1,16 @@
'use strict';
const { parseToolCalls } = require('./parse');
// Tag pairs ordered longest-first: wrapper tags checked before inner tags.
// XML wrapper tag pair used by the streaming sieve.
const XML_TOOL_TAG_PAIRS = [
{ open: '<tools', close: '</tools>' },
{ open: '<tool_call', close: '</tool_call>' },
{ open: '<tool_calls', close: '</tool_calls>' },
];
const XML_TOOL_OPENING_TAGS = XML_TOOL_TAG_PAIRS.map(p => p.open);
function consumeXMLToolCapture(captured, toolNames, trimWrappingJSONFence) {
const lower = captured.toLowerCase();
// Find the FIRST matching open/close pair, preferring wrapper tags.
// Find the FIRST matching open/close pair for the canonical wrapper.
for (const pair of XML_TOOL_TAG_PAIRS) {
const openIdx = lower.indexOf(pair.open);
if (openIdx < 0) {
@@ -21,7 +20,7 @@ function consumeXMLToolCapture(captured, toolNames, trimWrappingJSONFence) {
const closeIdx = lower.lastIndexOf(pair.close);
if (closeIdx < openIdx) {
// Opening tag present but specific closing tag hasn't arrived.
// Return not-ready — do NOT fall through to inner pairs.
// Return not-ready so buffering continues until the wrapper closes.
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const closeEnd = closeIdx + pair.close.length;

View File

@@ -1,15 +1,15 @@
'use strict';
const XML_TOOL_SEGMENT_TAGS = [
'<tools>', '<tools\n', '<tools ', '<tool_call>', '<tool_call\n', '<tool_call ',
'<tool_calls>', '<tool_calls\n', '<tool_calls ',
];
const XML_TOOL_OPENING_TAGS = [
'<tools', '<tool_call',
'<tool_calls',
];
const XML_TOOL_CLOSING_TAGS = [
'</tools>', '</tool_call>',
'</tool_calls>',
];
module.exports = {

View File

@@ -16,8 +16,8 @@ var promptXMLTextEscaper = strings.NewReplacer(
var promptXMLNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.:-]*$`)
// FormatToolCallsForPrompt renders a tool_calls slice into the canonical
// prompt-visible history block used across adapters.
// FormatToolCallsForPrompt renders a tool_calls slice into the prompt-visible
// invoke/parameter history block used across adapters.
func FormatToolCallsForPrompt(raw any) string {
calls, ok := raw.([]any)
if !ok || len(calls) == 0 {
@@ -38,7 +38,7 @@ func FormatToolCallsForPrompt(raw any) string {
if len(blocks) == 0 {
return ""
}
return "<tools>\n" + strings.Join(blocks, "\n") + "\n</tools>"
return "<tool_calls>\n" + strings.Join(blocks, "\n") + "\n</tool_calls>"
}
// StringifyToolCallArguments normalizes tool arguments into a compact string
@@ -93,28 +93,99 @@ func formatToolCallForPrompt(call map[string]any) string {
}
parameters := formatToolCallParametersForPrompt(argsRaw)
if parameters == "" {
return ` <invoke name="` + escapeXMLAttribute(name) + `"></invoke>`
}
return " <tool_call>\n" +
" <tool_name>" + escapeXMLText(name) + "</tool_name>\n" +
return " <invoke name=\"" + escapeXMLAttribute(name) + "\">\n" +
parameters + "\n" +
" </tool_call>"
" </invoke>"
}
func formatToolCallParametersForPrompt(raw any) string {
value := normalizePromptToolCallValue(raw)
body, ok := renderPromptToolXMLBody(value, " ")
if ok {
if strings.TrimSpace(body) == "" {
return " <param></param>"
}
return " <param>\n" + body + "\n </param>"
body, ok := renderPromptToolParameters(value, " ")
if ok && strings.TrimSpace(body) != "" {
return body
}
fallback := StringifyToolCallArguments(raw)
if strings.TrimSpace(fallback) == "" {
fallback = "{}"
return ""
}
return " <parameter name=\"content\">" + renderPromptXMLText(fallback) + "</parameter>"
}
func renderPromptToolParameters(value any, indent string) (string, bool) {
switch v := value.(type) {
case nil:
return "", true
case map[string]any:
if len(v) == 0 {
return "", true
}
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
rendered, ok := renderPromptParameterNode(key, v[key], indent)
if !ok {
return "", false
}
lines = append(lines, rendered)
}
return strings.Join(lines, "\n"), true
case []any:
lines := make([]string, 0, len(v))
for _, item := range v {
rendered, ok := renderPromptParameterNode("item", item, indent)
if !ok {
return "", false
}
lines = append(lines, rendered)
}
return strings.Join(lines, "\n"), true
case string:
return indent + `<parameter name="content">` + renderPromptXMLText(v) + `</parameter>`, true
default:
return indent + `<parameter name="value">` + renderPromptXMLText(fmt.Sprint(v)) + `</parameter>`, true
}
}
func renderPromptParameterNode(name string, value any, indent string) (string, bool) {
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return "", false
}
switch v := value.(type) {
case nil:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
case map[string]any:
body, ok := renderPromptToolXMLBody(v, indent+" ")
if !ok {
return "", false
}
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
}
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</parameter>`, true
case []any:
body, ok := renderPromptToolXMLArray(v, indent+" ")
if !ok {
return "", false
}
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
}
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</parameter>`, true
case string:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(v) + `</parameter>`, true
default:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(fmt.Sprint(v)) + `</parameter>`, true
}
return " <param><content>" + renderPromptXMLText(fallback) + "</content></param>"
}
func normalizePromptToolCallValue(raw any) any {
@@ -246,6 +317,18 @@ func isValidPromptXMLName(name string) bool {
return promptXMLNamePattern.MatchString(strings.TrimSpace(name))
}
func escapeXMLAttribute(text string) string {
if text == "" {
return ""
}
return strings.NewReplacer(
"&", "&amp;",
`"`, "&quot;",
"<", "&lt;",
">", "&gt;",
).Replace(text)
}
func normalizeToolArgumentString(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {

View File

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

View File

@@ -56,6 +56,21 @@ func TestCollectStreamThinkingAndText(t *testing.T) {
}
}
func TestCollectStreamDropsThinkingWhenDisabled(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/thinking_content\",\"v\":\"Thinking...\"}\n" +
"data: {\"p\":\"response/content\",\"v\":\"Answer\"}\n" +
"data: [DONE]\n",
)
result := CollectStream(resp, false, true)
if result.Thinking != "" {
t.Fatalf("expected disabled thinking to be dropped, got %q", result.Thinking)
}
if result.Text != "Answer" {
t.Fatalf("expected only visible answer, got %q", result.Text)
}
}
func TestCollectStreamOnlyThinking(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/thinking_content\",\"v\":\"Only thinking\"}\n" +

View File

@@ -99,6 +99,10 @@ func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, current
if transitioned {
newType = "text"
}
if !thinkingEnabled {
parts = dropThinkingParts(parts)
newType = "text"
}
return parts, false, newType
}
@@ -172,6 +176,9 @@ func updateTypeFromNestedResponse(path string, v any, newType *string) {
func resolvePartType(path string, thinkingEnabled bool, newType string) string {
switch {
case path == "response/thinking_content":
if !thinkingEnabled {
return "thinking"
}
if newType == "text" {
return "text"
}
@@ -187,6 +194,20 @@ func resolvePartType(path string, thinkingEnabled bool, newType string) string {
}
}
func dropThinkingParts(parts []ContentPart) []ContentPart {
if len(parts) == 0 {
return parts
}
out := parts[:0]
for _, p := range parts {
if p.Type == "thinking" {
continue
}
out = append(out, p)
}
return out
}
func appendChunkValueContent(v any, partType string, newType *string, parts *[]ContentPart, path string) bool {
switch val := v.(type) {
case string:

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)

View File

@@ -3,27 +3,48 @@ package util
import "strings"
func ResolveThinkingEnabled(req map[string]any, defaultEnabled bool) bool {
if enabled, ok := parseThinkingSetting(req["thinking"]); ok {
return enabled
}
if extraBody, ok := req["extra_body"].(map[string]any); ok {
if enabled, ok := parseThinkingSetting(extraBody["thinking"]); ok {
return enabled
}
}
if enabled, ok := parseReasoningEffort(req["reasoning_effort"]); ok {
if enabled, ok := ResolveThinkingOverride(req); ok {
return enabled
}
return defaultEnabled
}
func ResolveThinkingOverride(req map[string]any) (bool, bool) {
if req == nil {
return false, false
}
if enabled, ok := parseThinkingSetting(req["thinking"]); ok {
return enabled, true
}
if enabled, ok := parseReasoningSetting(req["reasoning"]); ok {
return enabled, true
}
if extraBody, ok := req["extra_body"].(map[string]any); ok {
if enabled, ok := parseThinkingSetting(extraBody["thinking"]); ok {
return enabled, true
}
if enabled, ok := parseReasoningSetting(extraBody["reasoning"]); ok {
return enabled, true
}
if enabled, ok := parseReasoningEffort(extraBody["reasoning_effort"]); ok {
return enabled, true
}
}
if enabled, ok := parseReasoningEffort(req["reasoning_effort"]); ok {
return enabled, true
}
return false, false
}
func parseThinkingSetting(raw any) (bool, bool) {
switch v := raw.(type) {
case bool:
return v, true
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "enabled":
case "enabled", "enable", "on", "true":
return true, true
case "disabled":
case "disabled", "disable", "off", "false", "none":
return false, true
default:
return false, false
@@ -36,10 +57,28 @@ func parseThinkingSetting(raw any) (bool, bool) {
return false, false
}
func parseReasoningSetting(raw any) (bool, bool) {
switch v := raw.(type) {
case bool:
return v, true
case string:
return parseReasoningEffort(v)
case map[string]any:
for _, key := range []string{"effort", "type", "enabled"} {
if enabled, ok := parseReasoningSetting(v[key]); ok {
return enabled, true
}
}
}
return false, false
}
func parseReasoningEffort(raw any) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(toString(raw))) {
case "low", "medium", "high", "xhigh":
case "minimal", "low", "medium", "high", "xhigh":
return true, true
case "none", "disabled", "disable", "off", "false":
return false, true
default:
return false, false
}

View File

@@ -27,13 +27,24 @@ func TestResolveThinkingEnabledUsesExtraBodyFallback(t *testing.T) {
}
func TestResolveThinkingEnabledMapsReasoningEffortToEnabled(t *testing.T) {
for _, effort := range []string{"low", "medium", "high", "xhigh"} {
for _, effort := range []string{"minimal", "low", "medium", "high", "xhigh"} {
if got := ResolveThinkingEnabled(map[string]any{"reasoning_effort": effort}, false); !got {
t.Fatalf("expected reasoning_effort=%s to enable thinking", effort)
}
}
}
func TestResolveThinkingEnabledMapsReasoningObject(t *testing.T) {
req := map[string]any{"reasoning": map[string]any{"effort": "none"}}
if got := ResolveThinkingEnabled(req, true); got {
t.Fatalf("expected reasoning.effort=none to disable thinking")
}
req = map[string]any{"reasoning": map[string]any{"effort": "medium"}}
if got := ResolveThinkingEnabled(req, false); !got {
t.Fatalf("expected reasoning.effort=medium to enable thinking")
}
}
func TestResolveThinkingEnabledDefaultsWhenUnset(t *testing.T) {
if !ResolveThinkingEnabled(nil, true) {
t.Fatal("expected default thinking=true when unset")

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
{
"calls": [],
"sawToolCallSyntax": false,
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -1,6 +0,0 @@
{
"calls": [],
"sawToolCallSyntax": false,
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -1,6 +0,0 @@
{
"calls": [],
"sawToolCallSyntax": false,
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -1,6 +0,0 @@
{
"calls": [],
"sawToolCallSyntax": false,
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -0,0 +1,6 @@
{
"text": "<tool_calls><invoke name=\"get_weather\"><parameter name=\"city\"><![CDATA[beijing]]></parameter><parameter name=\"unit\"><![CDATA[c]]></parameter></invoke></tool_calls>",
"tool_names": [
"get_weather"
]
}

View File

@@ -0,0 +1,6 @@
{
"text": "<tool_calls><invoke name=\"read_file\"><parameter name=\"path\">README.MD</parameter></invoke></tool_calls>",
"tool_names": [
"read_file"
]
}

View File

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

View File

@@ -1,6 +0,0 @@
{
"text": "<invoke name=\"read_file\"><argument>{\"path\":\"README.MD\"}</argument></invoke>",
"tool_names": [
"read_file"
]
}

View File

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

View File

@@ -1,6 +0,0 @@
{
"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 = '<tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.MD"}</param></tool_call></tools>';
const payload = '<tool_calls><invoke name="read_file"><parameter name="path">README.MD</parameter></invoke></tool_calls>';
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',
'<tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.md"}</param></tool_call></tools>',
'<tool_calls><invoke name="read_file"><parameter name="path">README.md</parameter></invoke></tool_calls>',
'```',
].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 = '<tools><tool_call><tool_name>not_in_schema</tool_name><param>{"q":"go"}</param></tool_call></tools>';
const payload = '<tool_calls><invoke name="not_in_schema"><parameter name="q">go</parameter></invoke></tool_calls>';
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(
['<tools><tool_call><tool_name>read_file</tool_name><param>{"path":"README.MD"}</param></tool_call></tools>'],
['<tool_calls><invoke name="read_file"><parameter name="path">README.MD</parameter></invoke></tool_calls>'],
['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(
[
'<tools><tool_call><tool_name>read_file</tool_name>',
'<param>{"path":"README.MD"}</param></tool_call></tools>',
'<tool_calls><invoke name="read_file">',
'<parameter name="path">README.MD</parameter></invoke></tool_calls>',
],
['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(
[
'<tools>\n <tool_call>\n <tool_name>write_to_file</tool_name>\n <param>\n <content><![CDATA[',
'<tool_calls>\n <invoke name="write_to_file">\n <parameter name="content"><![CDATA[',
longContent.slice(0, splitAt),
longContent.slice(splitAt),
']]></content>\n </param>\n </tool_call>\n</tools>',
']]></parameter>\n </invoke>\n</tool_calls>',
],
['write_to_file'],
);
@@ -147,7 +147,16 @@ 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 = '<tools><tool_call><param>{"path":"README.MD"}</param></tool_call></tools>';
const chunk = '<tool_calls><invoke name="read_file"><param>{"path":"README.MD"}</param></invoke></tool_calls>';
const events = runSieve([chunk], ['read_file']);
const leakedText = collectText(events);
const hasToolCalls = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
assert.equal(hasToolCalls, false);
assert.equal(leakedText, chunk);
});
test('sieve keeps bare tool_call XML as plain text without wrapper', () => {
const chunk = '<invoke name="read_file"><parameter name="path">README.MD</parameter></invoke>';
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 +168,13 @@ test('sieve flushes incomplete captured XML tool blocks by falling back to raw t
const events = runSieve(
[
'前置正文G。',
'<tools>\n',
' <tool_call>\n',
' <tool_name>read_file</tool_name>\n',
'<tool_calls>\n',
' <invoke name="read_file">\n',
],
['read_file'],
);
const leakedText = collectText(events);
const expected = ['前置正文G。', '<tools>\n', ' <tool_call>\n', ' <tool_name>read_file</tool_name>\n'].join('');
const expected = ['前置正文G。', '<tool_calls>\n', ' <invoke name="read_file">\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 +184,7 @@ test('sieve captures XML wrapper tags with attributes without leaking wrapper te
const events = runSieve(
[
'前置正文H。',
'<tools id="x"><tool_call><tool_name>read_file</tool_name><param>{"path":"README.MD"}</param></tool_call></tools>',
'<tool_calls id="x"><invoke name="read_file"><parameter name="path">README.MD</parameter></invoke></tool_calls>',
'后置正文I。',
],
['read_file'],
@@ -186,8 +194,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('<tools id=\"x\">'), false);
assert.equal(leakedText.includes('</tools>'), false);
assert.equal(leakedText.includes('<tool_calls id=\"x\">'), false);
assert.equal(leakedText.includes('</tool_calls>'), false);
});
test('sieve keeps plain text intact in tool mode when no tool call appears', () => {
@@ -270,7 +278,7 @@ test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => {
});
test('parseToolCalls rejects mismatched markup tags', () => {
const payload = '<tools><tool_call><tool_name>read_file</function><param>{"path":"README.md"}</param></tool_call></tools>';
const payload = '<tool_calls><invoke name="read_file"><parameter name="path">README.md</function></invoke></tool_calls>';
const calls = parseToolCalls(payload, ['read_file']);
assert.equal(calls.length, 0);
});

File diff suppressed because one or more lines are too long