feat(toolcall): prioritize XML for model output and parsing

This commit is contained in:
CJACK.
2026-03-29 10:53:38 +08:00
parent 6e8f3185d5
commit 958f4e39b5
7 changed files with 90 additions and 54 deletions

6
API.md
View File

@@ -286,7 +286,8 @@ data: [DONE]
补充说明:
- **非代码块上下文**下,工具 JSON 即使与普通文本混合,也会按特征识别并产出可执行 tool call前后普通文本仍可透传
- **非代码块上下文**下,工具负载即使与普通文本混合,也会按特征识别并产出可执行 tool call前后普通文本仍可透传
- 解析器以 XML/Markup 为最高优先级,并兼容 JSON、ANTML、text-kv 等格式输入;最终按客户端协议转译为对应 tool call 结构OpenAI/Claude/Gemini
- Markdown fenced code block例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。
---
@@ -346,7 +347,8 @@ data: [DONE]
```
流式场景下若 `tool_choice=required` 违规,会返回 `response.failed` 后结束(不再发送 `response.completed`)。
未在 `tools` 声明中的工具名会被严格拒绝,不会作为有效 tool call 下发。
> 当前版本说明:解析层默认“尽量提取结构化 tool call”未启用基于 `tools` allow-list 的硬拒绝;是否执行仍应由你的工具执行器做白名单校验。
### `GET /v1/responses/{response_id}`

View File

@@ -356,13 +356,17 @@ cp opencode.json.example opencode.json
## Tool Call 适配
当请求中带 `tools` 时DS2API 会做防泄漏处理:
当请求中带 `tools` 时DS2API 会做防泄漏处理与结构化转译
1. 只在**非代码块上下文**启用 toolcall 特征识别(代码块示例不触发)
2. `responses` 流式严格使用官方 item 生命周期事件(`response.output_item.*`、`response.content_part.*`、`response.function_call_arguments.*`
3. 未在 `tools` 声明中的工具名会被严格拒绝,不会下发为有效 tool call
1. 只在**非代码块上下文**启用执行型 toolcall 识别(代码块示例默认不触发)
2. 解析层以 XML/Markup 为最高优先级,同时兼容 JSON / ANTML / invoke / text-kv并统一归一到内部工具调用结构
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. 仅在通过策略校验后才会发出有效工具调用事件,避免错误工具名进入客户端执行链
5. 客户端请求哪种协议就按该协议返回工具调用OpenAI/Claude/Gemini 各自原生结构);模型侧优先约束输出规范 XML再由兼容层转译
> 说明:当前版本在 parser 层仍以“尽量解析成功”为优先,未启用基于 allow-list 的工具名硬拒绝。
>
> 想评估“把工具调用封装成 XML 再输入模型”的方案,可参考:`docs/toolcall-semantics.md`。
## 本地开发抓包工具

View File

@@ -1,41 +1,68 @@
# Tool call parsing semantics (Go canonical spec)
# Tool call parsing semanticsGo/Node 统一语义)
This document defines the cross-runtime contract for `ParseToolCallsDetailed` / `parseToolCallsDetailed`.
本文档描述当前代码中 `ParseToolCallsDetailed` / `parseToolCallsDetailed` 的**实际行为**,用于对齐 Go 与 Node Runtime。
## Output contract
## 1) 输出结构(当前实现)
- `calls`: accepted tool calls with normalized tool names.
- `sawToolCallSyntax`: true when tool-call-like syntax is detected (`tool_calls`, `<tool_call>`, `<function_call>`, `<invoke>`) or a valid call is parsed.
- `rejectedByPolicy`: true when parser extracted call syntax but all calls are rejected by allow-list policy.
- `rejectedToolNames`: de-duplicated rejected tool names in first-seen order.
- `calls`:解析得到的工具调用列表(`name` + `input`)。
- `sawToolCallSyntax`:检测到工具调用语法特征时为 `true`(例如 `tool_calls``<tool_call>``<function_call>``<invoke>``function.name:`)。
- `rejectedByPolicy`:当前实现固定为 `false`(预留字段,尚未启用 allow-list 拒绝)。
- `rejectedToolNames`:当前实现固定为空数组(预留字段)。
## Parse pipeline
> 说明:`filterToolCallsDetailed` 当前仅做结构清洗,不做工具名策略拒绝。
1. Strip fenced code blocks for non-standalone parsing.
2. Build candidates from:
- full text,
- fenced JSON snippets,
- extracted JSON objects around `tool_calls`,
- first `{` to last `}` object slice.
3. Parse each candidate in order:
- JSON payload parser (`tool_calls`, list, single call object),
- XML/Markup parser (`<tool_call>`, `<function_call>`, `<invoke>`; supports attributes + nested fields),
- Text KV fallback parser (`function.name: <name>` ... `function.arguments: {json}`).
4. Stop at first candidate that yields at least one call.
## 2) 解析管线
## Name normalization policy
1. **示例保护**:若判定为 fenced code block 示例上下文,则跳过执行型解析。
2. **候选片段构建**:从完整文本中构建候选(原文、围绕 `tool_calls` 的 JSON 片段、首尾大括号切片等)。
3. **按序尝试解析(命中即停)**
- XML 解析(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / `antml:function_call` 等);
- JSON 解析(`{"tool_calls": [...]}`、列表、单对象);
- Markup 解析;
- Text-KV 回退(如 `function.name:` + `function.arguments:`)。
4. **兜底**:候选全部失败后,再对全文做 XML / Text-KV 回退。
When matching parsed names against configured tools:
## 3) XML 能力边界(当前)
1. exact match,
2. case-insensitive match,
3. namespace tail match (`a.b.c` => `c`),
4. loose alnum match (remove non `[a-z0-9]`, compare).
当前已支持输入端的“多 XML/标记风格”解析,包括但不限于:
## Standalone mode
- `<tool_call><tool_name>...</tool_name><parameters>...</parameters></tool_call>`
- `<function_call>tool</function_call><function parameter name="x">...</function parameter>`
- `<invoke name="tool"><parameter name="x">...</parameter></invoke>`
- `antml:function_call` / `antml:argument` / `antml:parameters`
- `tool_use` 家族标签
Standalone mode (`ParseStandaloneToolCallsDetailed`) parses the whole input directly (no candidate slicing), while still applying:
但**输出端仍统一转换为 OpenAI 兼容 JSON 事件/对象**`message.tool_calls``delta.tool_calls``response.function_call_arguments.*`)。
- example-context guard,
- JSON then markup fallback,
- the same allow-list normalization policy.
## 4) 关于“是否可以封装成 XML 再喂给模型”
结论:**可以做,而且建议把 XML 作为模型侧第一优先格式**,同时保持“输入兼容层 + 输出按客户端协议渲染”。
推荐架构:
1. **Prompt 约束层**(默认开启):强约束模型优先输出规范 XML tool block例如 `<tool_calls><tool_call>...</tool_call></tool_calls>`)。
2. **解析兼容层**(已具备基础):继续在 parser 中同时接受 JSON/XML/ANTML/invoke/text-kv。
3. **协议归一层**(必须):无论模型输出什么格式,统一落到内部 `ParsedToolCall`
4. **对外渲染层**根据客户端请求协议渲染OpenAI/Claude/Gemini 各自格式)。
这样可以同时获得:
- 减少模型端 JSON 转义/引号错误;
- 不破坏现有 SDK/客户端生态;
- 逐步灰度(按模型、按租户、按请求开关)。
## 5) 落地建议(低风险迭代)
- 新增配置项:`toolcall.prefer_xml_output`(默认 `false`)。
-`true` 场景在系统提示词里加入 XML 模板;保留 JSON 模板作为回退。
- 增加观测指标:
- `toolcall_parse_source`json/xml/markup/textkv
- `toolcall_parse_success_rate`
- `toolcall_malformed_rate`
- `toolcall_repair_rate`
- 先在 `responses` 链路灰度,再扩展 `chat.completions`
## 6) 兼容性提醒
- 上游模型若输出混合文本 + XML仍可能出现“半结构化”噪声需要依赖现有 sieve 增量消费策略。
- XML 不等于安全:仍需做 tool 名、参数 schema、执行权限的服务端校验。

View File

@@ -53,7 +53,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
if len(toolSchemas) == 0 {
return messages, names
}
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON object format:\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON object above. Do NOT include any extra text.\n2) Do NOT wrap tool-call JSON in markdown/code fences (for example, do not use triple backticks).\n3) After receiving a tool result, you MUST use it to produce the final answer.\n4) Only call another tool when the previous result is missing required data or returned an error.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY XML using this canonical format:\n<tool_calls>\n <tool_call>\n <tool_name>tool_name</tool_name>\n <parameters>{\"param\":\"value\"}</parameters>\n </tool_call>\n</tool_calls>\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n<tool_calls>\n <tool_call>\n <tool_name>get_weather</tool_name>\n <parameters>{\"city\":\"Beijing\"}</parameters>\n </tool_call>\n <tool_call>\n <tool_name>get_weather</tool_name>\n <parameters>{\"city\":\"Shanghai\"}</parameters>\n </tool_call>\n <tool_call>\n <tool_name>update_todo</tool_name>\n <parameters>{\"todos\":[{\"content\":\"Buy milk\"},{\"content\":\"Write report\"}]}</parameters>\n </tool_call>\n</tool_calls>\n\nIMPORTANT:\n1) If calling tools, output ONLY XML in the canonical format above. Do NOT include any extra text.\n2) Do NOT wrap tool-call XML in markdown/code fences (for example, do not use triple backticks).\n3) `<parameters>` MUST be strict JSON object text. Use double quotes for all JSON keys/strings.\n4) If calling multiple tools, emit multiple `<tool_call>` blocks under one `<tool_calls>` root.\n5) After receiving a tool result, you MUST use it to produce the final answer.\n6) Only call another tool when the previous result is missing required data or returned an error."
if policy.Mode == util.ToolChoiceRequired {
toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list."
}

View File

@@ -77,10 +77,13 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
if !strings.Contains(finalPrompt, "Only call another tool when the previous result is missing required data or returned an error.") {
t.Fatalf("vercel prepare finalPrompt missing retry guard instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "Do NOT wrap tool-call JSON in markdown/code fences") {
t.Fatalf("vercel prepare finalPrompt missing no-fence instruction: %q", finalPrompt)
if !strings.Contains(finalPrompt, "output ONLY XML using this canonical format") {
t.Fatalf("vercel prepare finalPrompt missing xml format instruction: %q", finalPrompt)
}
if strings.Contains(finalPrompt, "```json") {
t.Fatalf("vercel prepare finalPrompt should not require fenced json tool calls: %q", finalPrompt)
if !strings.Contains(finalPrompt, "Do NOT wrap tool-call XML in markdown/code fences") {
t.Fatalf("vercel prepare finalPrompt missing no-fence xml instruction: %q", finalPrompt)
}
if strings.Contains(finalPrompt, "```xml") || strings.Contains(finalPrompt, "```json") {
t.Fatalf("vercel prepare finalPrompt should not require fenced tool calls: %q", finalPrompt)
}
}

View File

@@ -54,9 +54,9 @@ function parseToolCallsDetailed(text, toolNames) {
const candidates = buildToolCallCandidates(normalized);
let parsed = [];
for (const c of candidates) {
parsed = parseToolCallsPayload(c);
parsed = parseMarkupToolCalls(c);
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c);
parsed = parseToolCallsPayload(c);
}
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);
@@ -101,9 +101,9 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
const candidates = buildToolCallCandidates(trimmed);
let parsed = [];
for (const c of candidates) {
parsed = parseToolCallsPayload(c);
parsed = parseMarkupToolCalls(c);
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c);
parsed = parseToolCallsPayload(c);
}
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);

View File

@@ -34,13 +34,13 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
candidates := buildToolCallCandidates(text)
var parsed []ParsedToolCall
for _, candidate := range candidates {
tc := parseToolCallsPayload(candidate)
if len(tc) == 0 {
tc = parseXMLToolCalls(candidate)
}
tc := parseXMLToolCalls(candidate)
if len(tc) == 0 {
tc = parseMarkupToolCalls(candidate)
}
if len(tc) == 0 {
tc = parseToolCallsPayload(candidate)
}
if len(tc) == 0 {
tc = parseTextKVToolCalls(candidate)
}
@@ -88,13 +88,13 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
if candidate == "" {
continue
}
parsed = parseToolCallsPayload(candidate)
if len(parsed) == 0 {
parsed = parseXMLToolCalls(candidate)
}
parsed = parseXMLToolCalls(candidate)
if len(parsed) == 0 {
parsed = parseMarkupToolCalls(candidate)
}
if len(parsed) == 0 {
parsed = parseToolCallsPayload(candidate)
}
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(candidate)
}