diff --git a/API.en.md b/API.en.md index 4660188..bf8c42d 100644 --- a/API.en.md +++ b/API.en.md @@ -40,7 +40,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: models should output the fullwidth-separator DSML shell `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; DS2API also accepts the halfwidth DSML wrapper `<|DSML|tool_calls>`, DSML wrapper aliases such as ``, `<|tool_calls>`, `<|tool_calls>`, common DSML separator drift such as `<|DSML tool_calls>`, collapsed DSML local names such as ``, control-separator drift such as `` / raw STX `\x02`, CJK angle bracket, fullwidth-bang / ideographic-comma separator drift, PascalCase local-name drift, and trailing attribute separator drift such as `...〈/DSM|parameter〉`, `<!DSML!invoke name=“Bash”>`, `<、DSML、tool_calls>`, ``, or ``, arbitrary protocol prefixes such as ``, and legacy canonical XML `` → `` → ``. The scanner normalizes fixed local names (`tool_calls` / `invoke` / `parameter`) with non-structural separators before or after them back to XML before parsing, and also tolerates CDATA opener drift such as `<![CDATA[` / `<、[CDATA[`; only wrapped tool blocks or the narrow missing-opening-wrapper repair path enter the tool path, while bare `` does not count as supported syntax. JSON literal parameter bodies are preserved as structured values, explicit empty or whitespace-only parameters are preserved as empty strings, malformed complete wrappers are released as plain text, and loose CDATA is narrowly repaired at final parse/flush when it can preserve a complete outer tool call. +- Tool-calling semantics are aligned between Go and Node runtime: models should output the halfwidth-pipe DSML shell `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; DS2API also accepts DSML wrapper aliases such as `` and `<|tool_calls>`, common DSML separator drift such as `<|DSML tool_calls>`, collapsed DSML local names such as ``, control-separator drift such as `` / raw STX `\x02`, CJK angle bracket, fullwidth-bang / ideographic-comma separator drift, PascalCase local-name drift, and trailing attribute separator drift such as `...〈/DSM|parameter〉`, `<!DSML!invoke name=“Bash”>`, `<、DSML、tool_calls>`, ``, or ``, arbitrary protocol prefixes such as ``, and legacy canonical XML `` → `` → ``. The scanner normalizes fixed local names (`tool_calls` / `invoke` / `parameter`) with non-structural separators before or after them back to XML before parsing, and also tolerates CDATA opener drift such as `<![CDATA[` / `<、[CDATA[`; only wrapped tool blocks or the narrow missing-opening-wrapper repair path enter the tool path, while bare `` does not count as supported syntax. JSON literal parameter bodies are preserved as structured values, explicit empty or whitespace-only parameters are preserved as empty strings, malformed complete wrappers are released as plain text, and loose CDATA is narrowly repaired at final parse/flush when it can preserve a complete outer tool call. - `Admin API` separates static config from runtime policy: `/admin/config*` for configuration state, `/admin/settings*` for runtime behavior. - When upstream returns a thinking-only response with no visible text, the Go main path for both streaming and non-streaming completions retries once in the same DeepSeek session: it appends the prompt suffix `"Previous reply had no visible output. Please regenerate the visible final answer or tool call now."` and sets `parent_message_id`. If that same-account retry would still end as `429 upstream_empty_output`, managed-account mode switches to the next available account, creates a fresh session, and retries the original payload once before returning 429. - Citation/reference marker boundary: streaming output hides upstream `[citation:N]` / `[reference:N]` placeholders by default; non-stream output converts DeepSeek search reference markers into Markdown links. @@ -355,7 +355,7 @@ When `tools` is present, DS2API performs anti-leak handling: Additional notes: -- The parser treats the recommended DSML shell tool blocks (`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`), halfwidth DSML shell blocks (`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`), DSML wrapper aliases (``, `<|tool_calls>`, `<|tool_calls>`), common DSML separator drift (`<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`), collapsed DSML local names (`` / `` / ``), control-separator drift (`` / raw STX `\x02`), CJK angle bracket, fullwidth-bang / ideographic-comma separator drift, PascalCase local-name drift, and trailing attribute separator drift (`...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `` / ``), arbitrary protocol prefixes (``), and legacy canonical XML tool blocks (`` / `` / ``) as executable tool calls. These shells normalize non-structural separators back to XML first, while internal parsing remains XML-based; CDATA opener drift such as `<![CDATA[` / `<、[CDATA[` is also normalized for parameter bodies. Legacy ``, ``, ``, ``, ``, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text; complete but malformed wrappers are also released as plain text. +- The parser treats the recommended halfwidth-pipe DSML shell tool blocks (`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`), DSML wrapper aliases (``, `<|tool_calls>`), common DSML separator drift (`<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`), collapsed DSML local names (`` / `` / ``), control-separator drift (`` / raw STX `\x02`), CJK angle bracket, fullwidth-bang / ideographic-comma separator drift, PascalCase local-name drift, and trailing attribute separator drift (`...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `` / ``), arbitrary protocol prefixes (``), and legacy canonical XML tool blocks (`` / `` / ``) as executable tool calls. These shells normalize non-structural separators back to XML first, while internal parsing remains XML-based; CDATA opener drift such as `<![CDATA[` / `<、[CDATA[` is also normalized for parameter bodies. Legacy ``, ``, ``, ``, ``, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text; complete but malformed wrappers are also released as plain text. - The parser no longer drops tool calls solely because parameter values are empty; explicit empty strings or whitespace-only parameters become empty strings in structured `tool_calls`. Prompting still tells the model not to emit blank parameters, and missing/empty argument rejection belongs in the tool executor or client schema validation. - If the final visible response text is empty but the reasoning stream contains an executable tool call, Chat / Responses emits a standard OpenAI `tool_calls` / `function_call` output during finalization. If thinking/reasoning was not enabled by the client, that reasoning text is used only for detection and is not exposed as visible text or `reasoning_content`. - `tool_calls` shown inside fenced markdown code blocks (for example, ```json ... ```) are treated as examples, not executable calls. diff --git a/API.md b/API.md index 9809eca..63b9a6d 100644 --- a/API.md +++ b/API.md @@ -40,7 +40,7 @@ - OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。 - 适配器层职责收敛为:**请求归一化 → DeepSeek 调用 → 协议形态渲染**,减少历史版本中“同能力多处实现”的分叉。 -- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:推荐模型输出全角分隔符 DSML 外壳 `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`;兼容层也接受半角 DSML wrapper `<|DSML|tool_calls>`、DSML wrapper 别名 ``、`<|tool_calls>`、`<|tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo(如 ``)、控制分隔符漂移(如 `` / 原始 STX `\x02`)、CJK 尖括号、全角感叹号、顿号、PascalCase 本地名、弯引号属性值与属性尾部分隔符漂移(如 `...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `` / ``)、任意协议前缀壳(如 ``),以及旧式 canonical XML `` → `` → ``。实现上采用结构扫描:只要固定本地标签名是 `tool_calls` / `invoke` / `parameter`,标签名前或标签名后的非结构性分隔符会在解析入口归一化;CDATA 开头也会容错 `<![CDATA[` / `<、[CDATA[` 这类分隔符漂移;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 会进入工具路径,裸 `` 不计为已支持语法;流式场景继续执行防泄漏筛分。若参数体本身是合法 JSON 字面量(如 `123`、`true`、`null`、数组或对象),会按结构化值输出,不再一律当作字符串;显式空字符串和纯空白参数会结构化保留为空字符串,是否拒绝缺参由工具执行侧决定;完整但 malformed 的 wrapper 会作为普通文本释放,不会吞掉或伪造成工具调用;若 CDATA 偶发漏闭合,则会在最终 parse / flush 恢复阶段做窄修复,尽量保住已完整包裹的外层工具调用。 +- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:推荐模型输出半角管道符 DSML 外壳 `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`;兼容层也接受 DSML wrapper 别名 ``、`<|tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo(如 ``)、控制分隔符漂移(如 `` / 原始 STX `\x02`)、CJK 尖括号、全角感叹号、顿号、PascalCase 本地名、弯引号属性值与属性尾部分隔符漂移(如 `...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `` / ``)、任意协议前缀壳(如 ``),以及旧式 canonical XML `` → `` → ``。实现上采用结构扫描:只要固定本地标签名是 `tool_calls` / `invoke` / `parameter`,标签名前或标签名后的非结构性分隔符会在解析入口归一化;CDATA 开头也会容错 `<![CDATA[` / `<、[CDATA[` 这类分隔符漂移;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 会进入工具路径,裸 `` 不计为已支持语法;流式场景继续执行防泄漏筛分。若参数体本身是合法 JSON 字面量(如 `123`、`true`、`null`、数组或对象),会按结构化值输出,不再一律当作字符串;显式空字符串和纯空白参数会结构化保留为空字符串,是否拒绝缺参由工具执行侧决定;完整但 malformed 的 wrapper 会作为普通文本释放,不会吞掉或伪造成工具调用;若 CDATA 偶发漏闭合,则会在最终 parse / flush 恢复阶段做窄修复,尽量保住已完整包裹的外层工具调用。 - `Admin API` 将配置与运行时策略分开:`/admin/config*` 管静态配置,`/admin/settings*` 管运行时行为。 - 当上游返回 thinking-only 响应(模型输出了推理链但无可见文本)时,Go 主路径的流式与非流式补全都会先自动重试一次:以多轮对话 follow-up 方式追加 prompt 后缀 `"Previous reply had no visible output. Please regenerate the visible final answer or tool call now."` 并设置 `parent_message_id` 在同一 DeepSeek session 内让模型重新输出;同账号重试最大 1 次。若同账号重试后仍即将返回 `429 upstream_empty_output`,托管账号模式会在返回 429 前自动切换到下一个可用账号,新建 session,用原始 payload 再 fresh retry 一次。 - 引用标记处理边界:流式输出默认隐藏 `[citation:N]` / `[reference:N]` 这类上游内部占位符;非流式输出默认把 DeepSeek 搜索引用标记转换为 Markdown 引用链接。 @@ -357,7 +357,7 @@ data: [DONE] 补充说明: - **非代码块上下文**下,工具负载即使与普通文本混合,也会按特征识别并产出可执行 tool call(前后普通文本仍可透传)。 -- 解析器当前把推荐 DSML 外壳(`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`)、半角 DSML 外壳(`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`)、DSML wrapper 别名(``、`<|tool_calls>`、`<|tool_calls>`)、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`)、`DSML` 与工具标签名黏连的常见 typo(如 `` / `` / ``)、控制分隔符漂移(如 `` / 原始 STX `\x02`)、CJK 尖括号、全角感叹号、顿号、PascalCase 本地名、弯引号属性值与属性尾部分隔符漂移(如 `...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `` / ``)、任意协议前缀壳(如 ``)和旧式 canonical XML 工具块(`` / `` / ``)作为可执行调用解析;这些非结构性分隔符壳会先归一化回 XML,内部仍以 XML 解析语义为准,CDATA 开头也会容错 `<![CDATA[` / `<、[CDATA[`。旧式 ``、``、``、``、``、`tool_use`、antml 风格与纯 JSON `tool_calls` 片段默认都会按普通文本处理;完整但 malformed 的 wrapper 同样会作为普通文本释放。 +- 解析器当前把推荐半角管道符 DSML 外壳(`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`)、DSML wrapper 别名(``、`<|tool_calls>`)、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`)、`DSML` 与工具标签名黏连的常见 typo(如 `` / `` / ``)、控制分隔符漂移(如 `` / 原始 STX `\x02`)、CJK 尖括号、全角感叹号、顿号、PascalCase 本地名、弯引号属性值与属性尾部分隔符漂移(如 `...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `` / ``)、任意协议前缀壳(如 ``)和旧式 canonical XML 工具块(`` / `` / ``)作为可执行调用解析;这些非结构性分隔符壳会先归一化回 XML,内部仍以 XML 解析语义为准,CDATA 开头也会容错 `<![CDATA[` / `<、[CDATA[`。旧式 ``、``、``、``、``、`tool_use`、antml 风格与纯 JSON `tool_calls` 片段默认都会按普通文本处理;完整但 malformed 的 wrapper 同样会作为普通文本释放。 - 解析层不会因为参数值为空而丢弃工具调用;显式空字符串或纯空白参数会按空字符串进入结构化 `tool_calls`。Prompt 会要求模型不要主动输出空参数,缺参/空命令的拒绝应由工具执行侧或客户端 schema 校验负责。 - 当最终可见正文为空但思维链里包含可执行工具调用时,Chat / Responses 会在收尾阶段补发标准 OpenAI `tool_calls` / `function_call` 输出;如果客户端未开启 thinking / reasoning,该思维链只用于检测,不会作为可见正文或 `reasoning_content` 暴露。 - Markdown fenced code block(例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。 diff --git a/README.MD b/README.MD index ae5eafc..c32c09c 100644 --- a/README.MD +++ b/README.MD @@ -196,7 +196,7 @@ OpenAI `/v1/*` 仍是推荐的规范路径;同时支持 `/models`、`/chat/com - `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>`,避免本地回环请求被代理拦截。 -- 如遇“工具调用输出成文本、未执行”问题,请优先检查模型输出是否为推荐的全角分隔符 DSML 工具块:`<|DSML|tool_calls><|DSML|invoke name="..."><|DSML|parameter name="...">...`。兼容层也接受半角 DSML 与旧式 canonical XML:`...`;旧式 `` / `` / `` / ``、``、`tool_use` 或纯 JSON `tool_calls` 片段不会执行,会作为普通文本处理。 +- 如遇“工具调用输出成文本、未执行”问题,请优先检查模型输出是否为推荐的半角管道符 DSML 工具块:`<|DSML|tool_calls><|DSML|invoke name="..."><|DSML|parameter name="...">...`。兼容层也接受旧式 canonical XML:`...`;旧式 `` / `` / `` / ``、``、`tool_use` 或纯 JSON `tool_calls` 片段不会执行,会作为普通文本处理。 ### Gemini 接口 @@ -373,7 +373,7 @@ Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 ` 当请求中带 `tools` 时,DS2API 会做防泄漏处理与结构化转译: 1. 只在**非代码块上下文**启用执行型 toolcall 识别(代码块示例默认不触发) -2. 解析层当前把全角分隔符 DSML 外壳视为推荐可执行调用:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`;兼容半角 DSML、旧式 canonical XML `` → `` → ``,以及若干 DSML 前缀/分隔符漂移。DSML 只是外壳别名,内部仍以 XML 解析语义为准;旧式 `` / `` / `` / ``、``、`tool_use` / antml 变体与纯 JSON `tool_calls` 片段都会按普通文本处理,完整但 malformed 的 wrapper 也会作为普通文本释放 +2. 解析层当前把半角管道符 DSML 外壳视为推荐可执行调用:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`;兼容旧式 canonical XML `` → `` → ``,以及若干 DSML 前缀/分隔符漂移。DSML 只是外壳别名,内部仍以 XML 解析语义为准;旧式 `` / `` / `` / ``、``、`tool_use` / antml 变体与纯 JSON `tool_calls` 片段都会按普通文本处理,完整但 malformed 的 wrapper 也会作为普通文本释放 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,再由兼容层转译 diff --git a/README.en.md b/README.en.md index 81ef313..afb4c7d 100644 --- a/README.en.md +++ b/README.en.md @@ -185,7 +185,7 @@ Besides the primary aliases above, `/anthropic/v1/models` also returns Claude 4. - 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,` 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 the recommended fullwidth-separator DSML block: `<|DSML|tool_calls><|DSML|invoke name="..."><|DSML|parameter name="...">...`. DS2API also accepts halfwidth DSML and legacy canonical XML: `...`; legacy `` / `` / `` / ``, ``, `tool_use`, or standalone JSON `tool_calls` are not executed and stay plain text. +- If tool calls are rendered as plain text and not executed, first verify the model output uses the recommended halfwidth-pipe DSML block: `<|DSML|tool_calls><|DSML|invoke name="..."><|DSML|parameter name="...">...`. DS2API also accepts legacy canonical XML: `...`; legacy `` / `` / `` / ``, ``, `tool_use`, or standalone JSON `tool_calls` are not executed and stay plain text. ### Gemini Endpoint @@ -359,7 +359,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 treats the fullwidth-separator DSML shell as the recommended executable tool-calling syntax: `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; it also accepts halfwidth DSML, legacy canonical XML `` → `` → ``, plus common DSML prefix/separator drift. DSML is a shell alias and internal parsing remains XML-based; legacy `` / `` / `` / ``, ``, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text, and complete but malformed wrappers are released as plain text too +2. The parser treats the halfwidth-pipe DSML shell as the recommended executable tool-calling syntax: `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; it also accepts legacy canonical XML `` → `` → ``, plus common DSML prefix/separator drift. DSML is a shell alias and internal parsing remains XML-based; legacy `` / `` / `` / ``, ``, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text, and complete but malformed wrappers are released as plain text too 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 diff --git a/VERSION b/VERSION index a84947d..6016e8a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.0 +4.6.0 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index d0f23de..d2050bd 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -4,7 +4,7 @@ 本指南基于当前 Go 代码库,详细说明各种部署方式。 -本页导航:[文档总索引](./README.md)|[架构说明](./ARCHITECTURE.md)|[接口文档](../API.md)|[测试指南](./TESTING.md) +本页导航:[文档总索引](./README.md)|[架构说明](./ARCHITECTURE.md)|[接口文档](../API.md)|[测试指南](./TESTING.md) --- diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 6fcc1ab..1555b62 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -89,7 +89,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` "chat_session_id": "session-id", "model_type": "default", "parent_message_id": null, - "prompt": "<|begin▁of▁sentence|>...", + "prompt": "<|begin▁of▁sentence|>...", "ref_file_ids": [ "file-history", "file-systemprompt", @@ -135,14 +135,14 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认 最终 prompt 使用 DeepSeek 风格角色标记: -- `<|begin▁of▁sentence|>` -- `<|System|>` -- `<|User|>` -- `<|Assistant|>` -- `<|Tool|>` -- `<|end▁of▁instructions|>` -- `<|end▁of▁sentence|>` -- `<|end▁of▁toolresults|>` +- `<|begin▁of▁sentence|>` +- `<|System|>` +- `<|User|>` +- `<|Assistant|>` +- `<|Tool|>` +- `<|end▁of▁instructions|>` +- `<|end▁of▁sentence|>` +- `<|end▁of▁toolresults|>` 实现位置: [internal/prompt/messages.go](../internal/prompt/messages.go) @@ -165,10 +165,10 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认 1. 把每个 tool 的名称、描述、参数 schema 序列化成文本。 2. 拼成 `You have access to these tools:` 大段说明。 3. 再附上统一的 DSML tool call 外壳格式约束。 -4. 把这整段内容并入 system prompt。 +4. 普通直传请求会把“工具描述 + 格式约束”一起并入 system prompt;如果 `current_input_file` 触发,则工具描述/schema 会单独上传成 `DS2API_TOOLS.txt`,live prompt 只保留工具格式约束、工具选择策略和对 `DS2API_TOOLS.txt` 的引用。 -工具调用正例现在优先示范全角分隔符 DSML 风格:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`。 -兼容层仍接受旧式纯 `` wrapper,并会容错若干 DSML 标签变体,包括短横线形式 `` / `` / ``、下划线形式 `` / `` / ``,以及其他前缀分隔形态如 `` / `` / ``;标签壳扫描还会把全角 ASCII 漂移归一化,例如 `<dSML|tool_calls>` 与全角 `>` 结束符,也会容错 CJK 尖括号、全角感叹号或顿号分隔符、弯引号属性值、PascalCase 本地名和属性尾部分隔符漂移,例如 `...〈/DSM|parameter〉`、`<!DSML!invoke name=“Bash”>`、`<、DSML、tool_calls>`、``、``。更一般地,Go / Node tag 扫描以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,标签名前或标签名后的非结构性协议分隔符都会在解析入口剥离,例如 ``、`` 这类控制符或非 ASCII 分隔符漂移也会归一化回现有 XML 标签后继续走同一套 parser;结构性字符如 `<` / `>` / `/` / `=` / 引号、空白和 ASCII 字母数字不会被当作这类分隔符。进入现有 DSML rewrite / XML parse 之前,Go / Node 还会先对“已经识别成工具标签壳的 candidate span”做一次窄 canonicalization:只折叠 wrapper / `invoke` / `parameter` / `name` / `CDATA` / `DSML` 及其壳层分隔符里的 confusable 字符,清理零宽 / BOM / 控制类干扰,并把引号、空白、dash / underscore 变体等统一回可解析的工具语法。这个阶段不会广义改写普通正文、参数内容、CDATA 里的示例文本或其他非工具 XML。CDATA 开头也使用同一类扫描式容错,`` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`。 +兼容层仍接受旧式纯 `` wrapper,并会容错若干 DSML 标签变体,包括短横线形式 `` / `` / ``、下划线形式 `` / `` / ``,以及其他前缀分隔形态如 `` / `` / ``;标签壳扫描还会把全角 ASCII 漂移归一化,例如 `<dSML|tool_calls>` 与全角 `>` 结束符,也会容错 CJK 尖括号、全角感叹号或顿号分隔符、弯引号属性值、PascalCase 本地名和属性尾部分隔符漂移,例如 `...〈/DSM|parameter〉`、`<!DSML!invoke name=“Bash”>`、`<、DSML、tool_calls>`、``、``。更一般地,Go / Node tag 扫描以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,标签名前或标签名后的非结构性协议分隔符都会在解析入口剥离,例如 ``、`` 这类控制符或非 ASCII 分隔符漂移也会归一化回现有 XML 标签后继续走同一套 parser;结构性字符如 `<` / `>` / `/` / `=` / 引号、空白和 ASCII 字母数字不会被当作这类分隔符。进入现有 DSML rewrite / XML parse 之前,Go / Node 还会先对“已经识别成工具标签壳的 candidate span”做一次窄 canonicalization:只折叠 wrapper / `invoke` / `parameter` / `name` / `CDATA` / `DSML` 及其壳层分隔符里的 confusable 字符,清理零宽 / BOM / 控制类干扰,并把引号、空白、dash / underscore 变体等统一回可解析的工具语法。这个阶段不会广义改写普通正文、参数内容、CDATA 里的示例文本或其他非工具 XML。CDATA 开头也使用同一类扫描式容错,`...` 子节点表示;当某个参数体只包含 item 子节点时,Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。除此之外,解析器还会回收一些更松散的列表写法,例如 JSON array 字面量或逗号分隔的 JSON 项序列,只要它们足够明确;但 `` 仍然是首选形态。若模型把完整结构化 XML fragment 误包进 CDATA,兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过,如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `urgent` 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array;只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 `item` 列表,才会触发结构化恢复。对 `command` / `content` 等长文本参数,CDATA 内部的 Markdown fenced DSML / XML 示例会作为原文保护;示例里的 `]]>` 或 `` 不会截断外层工具调用,解析器会继续等待围栏外真正的参数 / wrapper 结束标签。 Go 侧读取 DeepSeek SSE 时不再依赖 `bufio.Scanner` 的固定 2MiB 单行上限;当写文件类工具把很长的 `content` 放在单个 `data:` 行里返回时,非流式收集、流式解析和 auto-continue 透传都会保留完整行,再进入同一套工具解析与序列化流程。 在 assistant 最终回包阶段,如果某个 tool 参数在声明 schema 中明确是 `string`,兼容层会在把解析后的 `tool_calls` / `function_call` 重新序列化成 OpenAI / Responses / Claude 可见参数前,递归把该路径上的 number / bool / object / array 统一转成字符串;其中 object / array 会压成紧凑 JSON 字符串。这个保护只对 schema 明确声明为 string 的路径生效,不会改写本来就是 `number` / `boolean` / `object` / `array` 的参数。这样可以兼容 DeepSeek 输出了结构化片段、但上游客户端工具 schema 又严格要求字符串参数的场景(例如 `content`、`prompt`、`path`、`taskId` 等)。 @@ -215,11 +215,11 @@ assistant 的 reasoning 会变成一个显式标签块: assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON,而会转成 prompt 可见的 DSML 外壳: ```xml -<|DSML|tool_calls> - <|DSML|invoke name="read_file"> - <|DSML|parameter name="path"> - - +<|DSML|tool_calls> + <|DSML|invoke name="read_file"> + <|DSML|parameter name="path"> + + ``` 如果客户端历史里没有结构化 `tool_calls` 字段、却把一个可独立解析的 assistant 工具块放进了普通 `content`,兼容层会在写入后续 prompt 前先按工具调用解析它,再重渲染为规范 DSML 历史外壳。这样可以避免一次 malformed 工具块未被结构化保存后,作为普通 assistant 文本回灌,继续污染后续模型的 few-shot 工具格式。 @@ -237,7 +237,7 @@ assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON,而会转成 ### 7.3 tool result 保留方式 -tool / function role 的结果会作为 `<|Tool|>...<|end▁of▁toolresults|>` 进入 prompt。 +tool / function role 的结果会作为 `<|Tool|>...<|end▁of▁toolresults|>` 进入 prompt。 如果 tool content 为空,当前会补成字符串 `"null"`,避免整个 tool turn 丢失。 @@ -278,7 +278,7 @@ OpenAI 的文件上传现在不再是“只传文件本体”的通用路径, 兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 配置字段已移除,读取旧配置时会忽略它且不会再写回。 -- `current_input_file` 默认开启;它在统一 completion runtime 入口全局生效,用于把“完整上下文”合并进 `DS2API_HISTORY.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,runtime 会上传一个文件名为 `DS2API_HISTORY.txt` 的上下文文件。文件内容会先经过各协议入口的标准化,再序列化成按轮次编号的 `DS2API_HISTORY.txt` 风格 transcript,带有 `# DS2API_HISTORY.txt` 标题和 `=== N. ROLE ===` 分段;live prompt 中则会给出一个 continuation 语气的 user 消息,引导模型从 `DS2API_HISTORY.txt` 的最新状态继续推进,并直接回答最新请求,避免把任务拉回起点。 +- `current_input_file` 默认开启;它在统一 completion runtime 入口全局生效,用于把“完整上下文”合并进 `DS2API_HISTORY.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,runtime 会上传一个文件名为 `DS2API_HISTORY.txt` 的上下文文件。文件内容会先经过各协议入口的标准化,再序列化成按轮次编号的 `DS2API_HISTORY.txt` 风格 transcript,带有 `# DS2API_HISTORY.txt` 标题和 `=== N. ROLE ===` 分段;如果当前请求声明了可用工具,还会把工具名称、描述和参数 schema 单独上传成 `DS2API_TOOLS.txt`,带有 `# DS2API_TOOLS.txt` 标题。live prompt 中则会给出一个 continuation 语气的 user 消息,引导模型从 `DS2API_HISTORY.txt` 的最新状态继续推进,并在有工具文件时明确可用工具 schema 位于 `DS2API_TOOLS.txt`;system prompt 仍保留统一 DSML 工具格式约束和本轮工具选择策略,避免把任务拉回起点。 - 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。 - 即使触发 `current_input_file` 后 live prompt 被缩短,对客户端回包里的上下文 token 统计,仍会沿用**拆分前的完整 prompt 语义**做计数,而不是按缩短后的占位 prompt 计算;否则会把真实上下文显著算小。 @@ -291,7 +291,7 @@ OpenAI 的文件上传现在不再是“只传文件本体”的通用路径, - 全局 completion runtime 应用点: [internal/completionruntime/nonstream.go](../internal/completionruntime/nonstream.go) -当前输入转文件启用并触发时,上传文件的真实文件名是 `DS2API_HISTORY.txt`,文件内容是完整 `messages` 上下文;它会使用 OpenAI-compatible 的消息/transcript 序列化规则和 DeepSeek 角色标记,再按轮次编号成 `DS2API_HISTORY.txt` 风格的 transcript(不再注入文件边界标签): +当前输入转文件启用并触发时,上传的历史文件真实文件名是 `DS2API_HISTORY.txt`,文件内容是完整 `messages` 上下文;它会使用 OpenAI-compatible 的消息/transcript 序列化规则和 DeepSeek 角色标记,再按轮次编号成 `DS2API_HISTORY.txt` 风格的 transcript(不再注入文件边界标签): ```text [uploaded filename]: DS2API_HISTORY.txt @@ -311,7 +311,21 @@ Prior conversation history and tool progress. ... ``` -开启后,请求的 live prompt 不再直接内联完整上下文,而是保留一个 user role 的短提示,提示模型基于已提供上下文直接回答最新请求;上传后的 `file_id` 会进入 `ref_file_ids`。 +如果当前请求带有工具,runtime 同时上传 `DS2API_TOOLS.txt`: + +```text +[uploaded filename]: DS2API_TOOLS.txt +# DS2API_TOOLS.txt +Available tool descriptions and parameter schemas for this request. + +You have access to these tools: + +Tool: ... +Description: ... +Parameters: ... +``` + +开启后,请求的 live prompt 不再直接内联完整上下文,也不再内联大段工具 schema;它保留一个 user role 的短提示,提示模型基于已提供上下文直接回答最新请求,并在有工具时引用 `DS2API_TOOLS.txt`。上传后的 `DS2API_HISTORY.txt` file_id 会排在 `ref_file_ids` 最前;如果存在 `DS2API_TOOLS.txt`,它的 file_id 紧随其后;客户端已有的其他 file_id 保持在后面。上下文 token 统计会包含上传的历史文件、工具文件和 live prompt。 ## 10. 各协议入口的差异 @@ -321,7 +335,7 @@ Prior conversation history and tool progress. - `developer` 会映射到 `system` - Responses `instructions` 会 prepend 为 system message -- `tools` 会注入 system prompt +- 普通直传时 `tools` 会注入 system prompt;`current_input_file` 触发时工具描述/schema 会拆成 `DS2API_TOOLS.txt`,system prompt 只保留格式/策略规则 - `attachments` / `input_file` / inline 文件会进入 `ref_file_ids` - current input file 在统一 completion runtime 入口全局生效 @@ -331,7 +345,7 @@ Prior conversation history and tool progress. - top-level `system` 优先作为系统提示 - `tool_use` / `tool_result` 会被转换成统一的 assistant/tool 历史语义 -- `tools` 同样会被并进 system prompt +- 普通直传时 `tools` 同样会被并进 system prompt;`current_input_file` 触发时会沿用统一的 `DS2API_TOOLS.txt` 拆分上传路径 - 常规执行通过 `internal/httpapi/claude/handler_messages.go` 转到 OpenAI chat 路径,模型 alias 会先解析成 DeepSeek 原生模型 - 当前代码里没有像 OpenAI 那样完整的 `ref_file_ids` 附件链路 @@ -341,7 +355,7 @@ Prior conversation history and tool progress. - `systemInstruction`、`contents.parts`、`functionCall`、`functionResponse` 会先归一 - tools 会转成 OpenAI 风格 function schema -- prompt 构建复用 OpenAI 的 `promptcompat.BuildOpenAIPromptForAdapter` +- prompt 构建复用 OpenAI 的 `promptcompat.BuildOpenAIPromptForAdapter`,`current_input_file` 触发时也会使用统一的 `DS2API_TOOLS.txt` 拆分上传路径 - 未识别的非文本 part 会被安全序列化进 prompt,并对二进制/疑似 base64 内容做省略或截断处理 也就是说,Gemini 在“最终 prompt 语义”上,尽量和 OpenAI 保持一致。 @@ -360,9 +374,10 @@ Prior conversation history and tool progress. ```json { - "prompt": "<|begin▁of▁sentence|><|System|>原 system / developer\n\nYou have access to these tools: ...<|end▁of▁instructions|><|User|>Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly.<|Assistant|>", + "prompt": "<|begin▁of▁sentence|><|System|>原 system / developer\n\nTOOL CALL FORMAT — FOLLOW EXACTLY: ...<|end▁of▁instructions|><|User|>Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly. Available tool descriptions and parameter schemas are attached in DS2API_TOOLS.txt; use only those tools and follow the tool-call format rules in this prompt.<|Assistant|>", "ref_file_ids": [ - "file-current-input-ignore", + "file-ds2api-history", + "file-ds2api-tools", "file-systemprompt", "file-other-attachment" ], diff --git a/docs/toolcall-semantics.md b/docs/toolcall-semantics.md index 598eb47..7988d5a 100644 --- a/docs/toolcall-semantics.md +++ b/docs/toolcall-semantics.md @@ -6,14 +6,14 @@ ## 1) 当前可执行格式 -当前版本推荐模型输出全角分隔符 DSML 外壳: +当前版本推荐模型输出半角管道符 DSML 外壳: ```xml -<|DSML|tool_calls> - <|DSML|invoke name="read_file"> - <|DSML|parameter name="path"> - - +<|DSML|tool_calls> + <|DSML|invoke name="read_file"> + <|DSML|parameter name="path"> + + ``` 兼容层仍接受旧式 canonical XML: @@ -30,19 +30,19 @@ 约束: -- 必须有 `<|DSML|tool_calls>...` 或 `...` wrapper -- 每个调用必须在 `<|DSML|invoke name="...">...` 或 `...` 内 +- 必须有 `<|DSML|tool_calls>...` 或 `...` wrapper +- 每个调用必须在 `<|DSML|invoke name="...">...` 或 `...` 内 - 工具名必须放在 `invoke` 的 `name` 属性 -- 参数必须使用 `<|DSML|parameter name="...">...` 或 `...` +- 参数必须使用 `<|DSML|parameter name="...">...` 或 `...` - 同一个工具块内不要混用 DSML 标签和旧 XML 工具标签;混搭会被视为非法工具块 兼容修复: - 如果模型漏掉 opening wrapper,但后面仍输出了一个或多个 invoke 并以 closing wrapper 收尾,Go 解析链路会在解析前补回缺失的 opening wrapper。 - 在进入现有 DSML rewrite / XML parse 之前,Go / Node 都会先做一次非常窄的 candidate-span canonicalization:只处理已经被 scanner 识别为工具标签壳的 wrapper / `invoke` / `parameter` / `name` / `CDATA` / `DSML` 及其结构分隔符;这里会移除零宽 / BOM / 控制类干扰字符,并把 `<`、`>`、`/`、`|`、`=`、引号、Unicode 空白、常见 dash / underscore 变体这类工具语法外壳符号折回 ASCII 语义。 -- Go / Node 解析层不再枚举每一种 DSML typo。它以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,把标签名前的任意协议前缀壳视为可容忍噪声,并继续兼容管道符 `|` / `|`、全角感叹号 `!`、顿号 `、`、空白、重复 leading `<`、可视控制符 `␂`、原始 STX `\x02`、非 ASCII 分隔符、CJK 尖括号 `〈` / `〉`、弯引号属性值、PascalCase 本地名等漂移。例如 ``、`<<|DSML|tool_calls>`、`<|DSML tool_calls>`、``、``、`<`、``、``、`...〈/DSM|tool_calls〉`、`<!DSML!tool_calls>...<!/DSML!tool_calls>`、`<、DSML、tool_calls>...<、/DSML、tool_calls>` 都会归一化;相似但非固定标签名(如 `tool_calls_extra` / `ToolCallsExtra`)仍按普通文本处理。 +- Go / Node 解析层不再枚举每一种 DSML typo。它以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,把标签名前的任意协议前缀壳视为可容忍噪声,并继续兼容半角管道符、全角感叹号 `!`、顿号 `、`、空白、重复 leading `<`、可视控制符 `␂`、原始 STX `\x02`、非 ASCII 分隔符、CJK 尖括号 `〈` / `〉`、弯引号属性值、PascalCase 本地名等漂移。例如 ``、`<<|DSML|tool_calls>`、`<|DSML tool_calls>`、``、``、`<`、``、``、`...〈/DSM|tool_calls〉`、`<!DSML!tool_calls>...<!/DSML!tool_calls>`、`<、DSML、tool_calls>...<、/DSML、tool_calls>` 都会归一化;相似但非固定标签名(如 `tool_calls_extra` / `ToolCallsExtra`)仍按普通文本处理。 - 这个 candidate-span canonicalization 不会对普通 prose、参数正文、CDATA 内容或嵌套的非工具 XML 做广义 Unicode 归一化。也就是说,参数里的示例 ``、普通聊天文本里的 confusable 单词、或其他非工具壳 XML 片段都保持原样;只有真正落在工具标签壳上的 whitelist 关键字和结构符号会被折叠。 -- 如果模型在固定工具标签名后多输出一个非结构性分隔符,例如 `<|DSML|tool_calls|` / `<|DSML|invoke|` / `<|DSML|parameter|` / ``,或在带属性标签的结束符前多输出一个尾部分隔符(如 ``),兼容层会把这个尾部分隔符当作异常标签终止符并补齐或归一化;如果后面已经有 `>` / `〉`,也会消费这个多余分隔符后再归一化。结构性字符如 `<` / `>` / `/` / `=` / 引号、空白和 ASCII 字母数字不会被当作这类分隔符。 +- 如果模型在固定工具标签名后多输出一个非结构性分隔符,例如 `<|DSML|tool_calls|` / `<|DSML|invoke|` / `<|DSML|parameter|` / ``,或在带属性标签的结束符前多输出一个尾部分隔符(如 ``),兼容层会把这个尾部分隔符当作异常标签终止符并补齐或归一化;如果后面已经有 `>` / `〉`,也会消费这个多余分隔符后再归一化。结构性字符如 `<` / `>` / `/` / `=` / 引号、空白和 ASCII 字母数字不会被当作这类分隔符。 - “缺失 opening wrapper”的修复只会在 wrapper-confidence 足够高时触发:scanner 必须已经识别出白名单工具壳结构(wrapper / invoke / parameter / `name=` 等),且剩余失败看起来只是壳层结构问题。相似但不在白名单内的 near-miss 标签名,或缺少足够 wrapper 证据的 malformed 片段,仍会按普通文本透传。 - 这是一个针对常见模型失误的窄修复,不改变推荐输出格式;prompt 仍要求模型直接输出完整 DSML 外壳。 - 裸 `` / `` 不会被当成“已支持的工具语法”;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 才会进入工具调用路径。 @@ -57,7 +57,7 @@ 在流式链路中(Go / Node 一致): -- DSML `<|DSML|tool_calls>` wrapper、短横线形式(如 `` / `` / ``)、基于固定本地标签名的 DSML 噪声容错形态、尾部非结构性分隔符形态(如 `<|DSML|tool_calls|` / ``)和 canonical `` wrapper 都会进入结构化捕获 +- DSML `<|DSML|tool_calls>` wrapper、短横线形式(如 `` / `` / ``)、基于固定本地标签名的 DSML 噪声容错形态、尾部非结构性分隔符形态(如 `<|DSML|tool_calls|` / ``)和 canonical `` wrapper 都会进入结构化捕获 - 如果流里直接从 invoke 开始,但后面补上了 closing wrapper,Go 流式筛分也会按缺失 opening wrapper 的修复路径尝试恢复 - 已识别成功的工具调用不会再次回流到普通文本 - 不符合新格式的块不会执行,并继续按原样文本透传 @@ -105,9 +105,9 @@ go test -v -run 'TestParseToolCalls|TestProcessToolSieve' ./internal/toolcall ./ 重点覆盖: -- DSML `<|DSML|tool_calls>` wrapper 正常解析 +- DSML `<|DSML|tool_calls>` wrapper 正常解析 - legacy canonical `` wrapper 正常解析 -- 固定本地标签名的 DSML 噪声容错形态(如 ``、`<<|DSML|tool_calls>`、`<|DSML tool_calls>`、``、``、`<`、`...〈/DSM|tool_calls〉`、`<!DSML!tool_calls>...<!/DSML!tool_calls>`)正常解析 +- 固定本地标签名的 DSML 噪声容错形态(如 ``、`<<|DSML|tool_calls>`、`<|DSML tool_calls>`、``、``、`<`、`...〈/DSM|tool_calls〉`、`<!DSML!tool_calls>...<!/DSML!tool_calls>`)正常解析 - 混搭标签(DSML wrapper + canonical inner)归一化后正常解析 - 波浪线围栏 `~~~` 内的示例不执行 - 嵌套围栏(4 反引号嵌套 3 反引号)内的示例不执行 diff --git a/internal/httpapi/claude/current_input_file_test.go b/internal/httpapi/claude/current_input_file_test.go index fa6b34b..d49646e 100644 --- a/internal/httpapi/claude/current_input_file_test.go +++ b/internal/httpapi/claude/current_input_file_test.go @@ -93,7 +93,11 @@ func (d *claudeCurrentInputDS) GetPow(context.Context, *auth.RequestAuth, int) ( func (d *claudeCurrentInputDS) UploadFile(_ context.Context, _ *auth.RequestAuth, req dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { d.uploads = append(d.uploads, req) - return &dsclient.UploadFileResult{ID: "file-claude-history"}, nil + id := "file-claude-history" + if len(d.uploads) > 1 { + id = "file-claude-tools" + } + return &dsclient.UploadFileResult{ID: id}, nil } func (d *claudeCurrentInputDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) { @@ -156,3 +160,47 @@ func TestClaudeDirectAppliesCurrentInputFile(t *testing.T) { t.Fatalf("expected persisted message to match upstream continuation prompt, got %#v", full.Messages) } } + +func TestClaudeCurrentInputFileUploadsToolsSeparately(t *testing.T) { + ds := &claudeCurrentInputDS{} + h := &Handler{ + Store: mockClaudeConfig{aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}}, + Auth: claudeCurrentInputAuth{}, + DS: ds, + } + reqBody := `{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hello from claude"}],"tools":[{"name":"search","description":"Search docs","input_schema":{"type":"object"}}],"max_tokens":1024}` + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.Messages(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploads) != 2 { + t.Fatalf("expected history and tools uploads, got %d", len(ds.uploads)) + } + if ds.uploads[0].Filename != "DS2API_HISTORY.txt" || ds.uploads[1].Filename != "DS2API_TOOLS.txt" { + t.Fatalf("unexpected upload filenames: %#v", ds.uploads) + } + historyText := string(ds.uploads[0].Data) + if strings.Contains(historyText, "You have access to these tools") || strings.Contains(historyText, "Description: Search docs") { + t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText) + } + toolsText := string(ds.uploads[1].Data) + if !strings.Contains(toolsText, "# DS2API_TOOLS.txt") || !strings.Contains(toolsText, "Tool: search") || !strings.Contains(toolsText, "Description: Search docs") { + t.Fatalf("expected tools transcript to include tool schema, got %q", toolsText) + } + refIDs, _ := ds.payload["ref_file_ids"].([]any) + if len(refIDs) < 2 || refIDs[0] != "file-claude-history" || refIDs[1] != "file-claude-tools" { + t.Fatalf("expected history and tools ref ids first, got %#v", ds.payload["ref_file_ids"]) + } + prompt, _ := ds.payload["prompt"].(string) + if !strings.Contains(prompt, "DS2API_TOOLS.txt") || !strings.Contains(prompt, "TOOL CALL FORMAT") { + t.Fatalf("expected live prompt to reference tools file and retain format instructions, got %q", prompt) + } + if strings.Contains(prompt, "Description: Search docs") { + t.Fatalf("live prompt should not inline tool descriptions, got %q", prompt) + } +} diff --git a/internal/httpapi/claude/handler_util_test.go b/internal/httpapi/claude/handler_util_test.go index a624b01..7d229fb 100644 --- a/internal/httpapi/claude/handler_util_test.go +++ b/internal/httpapi/claude/handler_util_test.go @@ -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, "<|DSML|tool_calls>") || !containsStr(content, `<|DSML|invoke name="search_web">`) { + if !containsStr(content, "<|DSML|tool_calls>") || !containsStr(content, `<|DSML|invoke name="search_web">`) { t.Fatalf("expected assistant content to include DSML tool call history, got %q", content) } - if !containsStr(content, `<|DSML|parameter name="query">`) { + if !containsStr(content, `<|DSML|parameter name="query">`) { t.Fatalf("expected assistant content to include serialized parameters, got %q", content) } } @@ -133,7 +133,7 @@ func TestNormalizeClaudeMessagesPreservesThinkingOnToolUseHistory(t *testing.T) if !containsStr(prompt, "[reasoning_content]\nneed live search before answering\n[/reasoning_content]") { t.Fatalf("expected thinking in prompt history, got %q", prompt) } - if !containsStr(prompt, `<|DSML|invoke name="search_web">`) { + if !containsStr(prompt, `<|DSML|invoke name="search_web">`) { t.Fatalf("expected tool call in prompt history, got %q", prompt) } } @@ -329,7 +329,7 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) { if !containsStr(prompt, "Search the web") { t.Fatalf("expected description in prompt") } - if !containsStr(prompt, "<|DSML|tool_calls>") { + if !containsStr(prompt, "<|DSML|tool_calls>") { t.Fatalf("expected DSML tool_calls format in prompt") } if !containsStr(prompt, "TOOL CALL FORMAT") { diff --git a/internal/httpapi/claude/standard_request.go b/internal/httpapi/claude/standard_request.go index 49d9bff..4998eb9 100644 --- a/internal/httpapi/claude/standard_request.go +++ b/internal/httpapi/claude/standard_request.go @@ -52,7 +52,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma RequestedModel: strings.TrimSpace(model), ResolvedModel: dsModel, ResponseModel: strings.TrimSpace(model), - Messages: payload["messages"].([]any), + Messages: normalizedMessages, PromptTokenText: finalPrompt, ToolsRaw: toolsRequested, FinalPrompt: finalPrompt, diff --git a/internal/httpapi/gemini/convert_messages_test.go b/internal/httpapi/gemini/convert_messages_test.go index a429325..6f0890f 100644 --- a/internal/httpapi/gemini/convert_messages_test.go +++ b/internal/httpapi/gemini/convert_messages_test.go @@ -89,7 +89,7 @@ func TestGeminiMessagesFromRequestPreservesThoughtOnFunctionCallHistory(t *testi if !strings.Contains(prompt, "[reasoning_content]\nneed current state before answering\n[/reasoning_content]") { t.Fatalf("expected thought in prompt history, got %q", prompt) } - if !strings.Contains(prompt, `<|DSML|invoke name="search_web">`) { + if !strings.Contains(prompt, `<|DSML|invoke name="search_web">`) { t.Fatalf("expected tool call in prompt history, got %q", prompt) } } diff --git a/internal/httpapi/gemini/handler_test.go b/internal/httpapi/gemini/handler_test.go index 90a1fe9..d420dcb 100644 --- a/internal/httpapi/gemini/handler_test.go +++ b/internal/httpapi/gemini/handler_test.go @@ -67,7 +67,11 @@ func (m *testGeminiDS) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (st //nolint:unused // reserved test double for native Gemini DS-call path coverage. func (m *testGeminiDS) UploadFile(_ context.Context, _ *auth.RequestAuth, req dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) { m.uploadCalls = append(m.uploadCalls, req) - return &dsclient.UploadFileResult{ID: "file-gemini-history"}, nil + id := "file-gemini-history" + if len(m.uploadCalls) > 1 { + id = "file-gemini-tools" + } + return &dsclient.UploadFileResult{ID: id}, nil } //nolint:unused // reserved test double for native Gemini DS-call path coverage. diff --git a/internal/httpapi/openai/chat/test_helpers_test.go b/internal/httpapi/openai/chat/test_helpers_test.go index d8284cd..8a8baa9 100644 --- a/internal/httpapi/openai/chat/test_helpers_test.go +++ b/internal/httpapi/openai/chat/test_helpers_test.go @@ -2,6 +2,7 @@ package chat import ( "context" + "fmt" "io" "net/http" "strings" @@ -148,8 +149,12 @@ func (m *inlineUploadDSStub) UploadFile(ctx context.Context, _ *auth.RequestAuth if m.uploadErr != nil { return nil, m.uploadErr } + id := "file-inline-1" + if len(m.uploadCalls) > 1 { + id = "file-inline-" + fmt.Sprint(len(m.uploadCalls)) + } return &dsclient.UploadFileResult{ - ID: "file-inline-1", + ID: id, Filename: req.Filename, Bytes: int64(len(req.Data)), Status: "uploaded", diff --git a/internal/httpapi/openai/chat/vercel_prepare_test.go b/internal/httpapi/openai/chat/vercel_prepare_test.go index 38fccc2..e7f3892 100644 --- a/internal/httpapi/openai/chat/vercel_prepare_test.go +++ b/internal/httpapi/openai/chat/vercel_prepare_test.go @@ -141,6 +141,71 @@ func TestHandleVercelStreamPrepareAppliesCurrentInputFile(t *testing.T) { } } +func TestHandleVercelStreamPrepareUsesHalfwidthDSMLToolPrompt(t *testing.T) { + t.Setenv("VERCEL", "1") + t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret") + + h := &Handler{ + Store: mockOpenAIConfig{}, + Auth: streamStatusAuthStub{}, + DS: &inlineUploadDSStub{}, + } + + reqBody, _ := json.Marshal(map[string]any{ + "model": "deepseek-v4-flash", + "messages": []any{ + map[string]any{"role": "user", "content": "search docs"}, + }, + "tools": []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + "description": "search docs", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + "required": []any{"query"}, + }, + }, + }, + }, + "stream": true, + }) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions?__stream_prepare=1", strings.NewReader(string(reqBody))) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Ds2-Internal-Token", "stream-secret") + rec := httptest.NewRecorder() + + h.handleVercelStreamPrepare(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode failed: %v", err) + } + finalPrompt, _ := body["final_prompt"].(string) + payload, _ := body["payload"].(map[string]any) + payloadPrompt, _ := payload["prompt"].(string) + for label, promptText := range map[string]string{"final_prompt": finalPrompt, "payload.prompt": payloadPrompt} { + if !strings.Contains(promptText, "<|DSML|tool_calls>") || !strings.Contains(promptText, "Tag punctuation alphabet: ASCII < > / = \" plus the halfwidth pipe |.") { + t.Fatalf("expected %s to contain halfwidth DSML tool instructions, got %q", label, promptText) + } + if strings.Contains(promptText, "\uff5c") || strings.Contains(promptText, "full"+"width vertical bar") { + t.Fatalf("expected %s not to contain legacy pipe guidance, got %q", label, promptText) + } + } + toolNames, _ := body["tool_names"].([]any) + if len(toolNames) != 1 || toolNames[0] != "search" { + t.Fatalf("expected prepared tool names to align with request tools, got %#v", body["tool_names"]) + } +} + func TestHandleVercelStreamPrepareMapsCurrentInputFileManagedAuthFailureTo401(t *testing.T) { t.Setenv("VERCEL", "1") t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret") diff --git a/internal/httpapi/openai/deps_injection_test.go b/internal/httpapi/openai/deps_injection_test.go index 3082dab..b3bdc1d 100644 --- a/internal/httpapi/openai/deps_injection_test.go +++ b/internal/httpapi/openai/deps_injection_test.go @@ -103,7 +103,7 @@ func TestNormalizeOpenAIResponsesRequestAlwaysAcceptsWideInput(t *testing.T) { if out.Surface != "openai_responses" { t.Fatalf("unexpected surface: %q", out.Surface) } - if !strings.Contains(out.FinalPrompt, "<|User|>hi") { + if !strings.Contains(out.FinalPrompt, "<|User|>hi") { t.Fatalf("unexpected final prompt: %q", out.FinalPrompt) } } diff --git a/internal/httpapi/openai/file_inline_upload_test.go b/internal/httpapi/openai/file_inline_upload_test.go index abaf704..88978e2 100644 --- a/internal/httpapi/openai/file_inline_upload_test.go +++ b/internal/httpapi/openai/file_inline_upload_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -41,8 +42,12 @@ func (m *inlineUploadDSStub) UploadFile(ctx context.Context, _ *auth.RequestAuth if m.uploadErr != nil { return nil, m.uploadErr } + id := "file-inline-1" + if len(m.uploadCalls) > 1 { + id = "file-inline-" + fmt.Sprint(len(m.uploadCalls)) + } return &dsclient.UploadFileResult{ - ID: "file-inline-1", + ID: id, Filename: req.Filename, Bytes: int64(len(req.Data)), Status: "uploaded", diff --git a/internal/httpapi/openai/history/current_input_file.go b/internal/httpapi/openai/history/current_input_file.go index 9f5f8ee..b69cb82 100644 --- a/internal/httpapi/openai/history/current_input_file.go +++ b/internal/httpapi/openai/history/current_input_file.go @@ -15,6 +15,7 @@ import ( const ( currentInputFilename = promptcompat.CurrentInputContextFilename + currentToolsFilename = promptcompat.CurrentToolsContextFilename currentInputContentType = "text/plain; charset=utf-8" currentInputPurpose = "assistants" ) @@ -50,6 +51,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, if strings.TrimSpace(fileText) == "" { return stdReq, errors.New("current user input file produced empty transcript") } + toolsText, _ := promptcompat.BuildOpenAIToolsContextTranscript(stdReq.ToolsRaw, stdReq.ToolChoice) modelType := "default" if resolvedType, ok := config.GetModelType(stdReq.ResolvedModel); ok { modelType = resolvedType @@ -69,21 +71,44 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, return stdReq, errors.New("upload current user input file returned empty file id") } + toolFileID := "" + if strings.TrimSpace(toolsText) != "" { + result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{ + Filename: currentToolsFilename, + ContentType: currentInputContentType, + Purpose: currentInputPurpose, + ModelType: modelType, + Data: []byte(toolsText), + }, 3) + if err != nil { + return stdReq, fmt.Errorf("upload current tools file: %w", err) + } + toolFileID = strings.TrimSpace(result.ID) + if toolFileID == "" { + return stdReq, errors.New("upload current tools file returned empty file id") + } + } + messages := []any{ map[string]any{ "role": "user", - "content": currentInputFilePrompt(), + "content": currentInputFilePrompt(toolFileID != ""), }, } stdReq.Messages = messages stdReq.HistoryText = fileText stdReq.CurrentInputFileApplied = true - stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID) - stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking) + stdReq.RefFileIDs = prependUniqueRefFileIDs(stdReq.RefFileIDs, fileID, toolFileID) + stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPromptWithToolInstructionsOnly(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking) // Token accounting must reflect the actual downstream context: - // the uploaded DS2API_HISTORY.txt file content + the continuation live prompt. - stdReq.PromptTokenText = fileText + "\n" + stdReq.FinalPrompt + // uploaded context files + the continuation live prompt. + tokenParts := []string{fileText} + if strings.TrimSpace(toolsText) != "" { + tokenParts = append(tokenParts, toolsText) + } + tokenParts = append(tokenParts, stdReq.FinalPrompt) + stdReq.PromptTokenText = strings.Join(tokenParts, "\n") return stdReq, nil } @@ -106,23 +131,40 @@ func latestUserInputForFile(messages []any) (int, string) { return -1, "" } -func currentInputFilePrompt() string { - return "Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly." +func currentInputFilePrompt(hasToolsFile bool) string { + prompt := "Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly." + if hasToolsFile { + prompt += " Available tool descriptions and parameter schemas are attached in DS2API_TOOLS.txt; use only those tools and follow the tool-call format rules in this prompt." + } + return prompt } -func prependUniqueRefFileID(existing []string, fileID string) []string { - fileID = strings.TrimSpace(fileID) - if fileID == "" { - return existing - } - out := make([]string, 0, len(existing)+1) - out = append(out, fileID) - for _, id := range existing { - trimmed := strings.TrimSpace(id) - if trimmed == "" || strings.EqualFold(trimmed, fileID) { +func prependUniqueRefFileIDs(existing []string, fileIDs ...string) []string { + out := make([]string, 0, len(existing)+len(fileIDs)) + seen := map[string]struct{}{} + for _, fileID := range fileIDs { + trimmed := strings.TrimSpace(fileID) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, ok := seen[key]; ok { continue } out = append(out, trimmed) + seen[key] = struct{}{} + } + for _, id := range existing { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if _, ok := seen[key]; ok { + continue + } + out = append(out, trimmed) + seen[key] = struct{}{} } return out } diff --git a/internal/httpapi/openai/history_split_test.go b/internal/httpapi/openai/history_split_test.go index 97100f4..90e8d53 100644 --- a/internal/httpapi/openai/history_split_test.go +++ b/internal/httpapi/openai/history_split_test.go @@ -84,7 +84,7 @@ func TestBuildOpenAICurrentInputContextTranscriptUsesNumberedHistorySections(t * "latest user turn", "[reasoning_content]", "hidden reasoning", - "<|DSML|tool_calls>", + "<|DSML|tool_calls>", } { if !strings.Contains(transcript, want) { t.Fatalf("expected transcript to contain %q, got %q", want, transcript) @@ -380,6 +380,79 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) { } } +func TestApplyCurrentInputFileUploadsToolsContextSeparately(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &openAITestSurface{ + Store: mockOpenAIConfig{ + currentInputEnabled: true, + currentInputMin: 0, + }, + DS: ds, + } + req := map[string]any{ + "model": "deepseek-v4-flash", + "messages": historySplitTestMessages(), + "tools": []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + "description": "search docs", + "parameters": map[string]any{ + "type": "object", + }, + }, + }, + }, + } + stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "") + if err != nil { + t.Fatalf("normalize failed: %v", err) + } + + out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq) + if err != nil { + t.Fatalf("apply current input file failed: %v", err) + } + if len(ds.uploadCalls) != 2 { + t.Fatalf("expected history and tools uploads, got %d", len(ds.uploadCalls)) + } + if ds.uploadCalls[0].Filename != "DS2API_HISTORY.txt" { + t.Fatalf("expected first upload to be DS2API_HISTORY.txt, got %q", ds.uploadCalls[0].Filename) + } + if ds.uploadCalls[1].Filename != "DS2API_TOOLS.txt" { + t.Fatalf("expected second upload to be DS2API_TOOLS.txt, got %q", ds.uploadCalls[1].Filename) + } + historyText := string(ds.uploadCalls[0].Data) + if strings.Contains(historyText, "You have access to these tools") || strings.Contains(historyText, "Description: search docs") { + t.Fatalf("history transcript should not embed tool descriptions, got %q", historyText) + } + toolsText := string(ds.uploadCalls[1].Data) + for _, want := range []string{"# DS2API_TOOLS.txt", "Tool: search", "Description: search docs", `Parameters: {"type":"object"}`} { + if !strings.Contains(toolsText, want) { + t.Fatalf("expected tools transcript to contain %q, got %q", want, toolsText) + } + } + if strings.Contains(toolsText, "TOOL CALL FORMAT") { + t.Fatalf("tools transcript should not duplicate tool format instructions, got %q", toolsText) + } + if !strings.Contains(out.FinalPrompt, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") || !strings.Contains(out.FinalPrompt, "DS2API_TOOLS.txt") { + t.Fatalf("expected live prompt to reference both context files, got %q", out.FinalPrompt) + } + if !strings.Contains(out.FinalPrompt, "TOOL CALL FORMAT") || !strings.Contains(out.FinalPrompt, "Remember: The ONLY valid way to use tools") { + t.Fatalf("expected live prompt to retain tool format instructions, got %q", out.FinalPrompt) + } + if strings.Contains(out.FinalPrompt, "You have access to these tools") || strings.Contains(out.FinalPrompt, "Description: search docs") || strings.Contains(out.FinalPrompt, "Parameters:") { + t.Fatalf("expected live prompt to omit tool descriptions after tools upload, got %q", out.FinalPrompt) + } + if len(out.RefFileIDs) < 2 || out.RefFileIDs[0] != "file-inline-1" || out.RefFileIDs[1] != "file-inline-2" { + t.Fatalf("expected history and tools file ids first, got %#v", out.RefFileIDs) + } + if !strings.Contains(out.PromptTokenText, "# DS2API_HISTORY.txt") || !strings.Contains(out.PromptTokenText, "# DS2API_TOOLS.txt") || !strings.Contains(out.PromptTokenText, "Description: search docs") { + t.Fatalf("expected prompt token text to include uploaded history and tools content, got %q", out.PromptTokenText) + } +} + func TestApplyCurrentInputFileCarriesHistoryText(t *testing.T) { ds := &inlineUploadDSStub{} h := &openAITestSurface{ diff --git a/internal/httpapi/openai/leaked_output_sanitize_test.go b/internal/httpapi/openai/leaked_output_sanitize_test.go index 3b2884b..4076b14 100644 --- a/internal/httpapi/openai/leaked_output_sanitize_test.go +++ b/internal/httpapi/openai/leaked_output_sanitize_test.go @@ -19,7 +19,7 @@ func TestSanitizeLeakedOutputRemovesLeakedWireToolCallAndResult(t *testing.T) { } func TestSanitizeLeakedOutputRemovesStandaloneMetaMarkers(t *testing.T) { - raw := "A<| end_of_sentence |><| Assistant |>B<| end_of_thinking |>C<|end▁of▁thinking|>D<|end▁of▁sentence|>E<| end_of_toolresults |>F<|end▁of▁instructions|>G" + raw := "A<| end_of_sentence |><| Assistant |>B<| end_of_thinking |>C<|end▁of▁thinking|>D<|end▁of▁sentence|>E<| end_of_toolresults |>F<|end▁of▁instructions|>G" got := sanitizeLeakedOutput(raw) if got != "ABCDEFG" { t.Fatalf("unexpected sanitize result for meta markers: %q", got) @@ -27,7 +27,7 @@ func TestSanitizeLeakedOutputRemovesStandaloneMetaMarkers(t *testing.T) { } func TestSanitizeLeakedOutputRemovesThinkAndBosMarkers(t *testing.T) { - raw := "ABC<|begin▁of▁sentence|>D<| begin_of_sentence |>E<|begin_of_sentence|>F" + raw := "ABC<|begin▁of▁sentence|>D<| begin_of_sentence |>E<|begin_of_sentence|>F" got := sanitizeLeakedOutput(raw) if got != "ABCDEF" { t.Fatalf("unexpected sanitize result for think/BOS markers: %q", got) @@ -35,7 +35,7 @@ func TestSanitizeLeakedOutputRemovesThinkAndBosMarkers(t *testing.T) { } func TestSanitizeLeakedOutputRemovesThoughtMarkers(t *testing.T) { - raw := "A<|▁of▁thought|>B<| of_thought |>C<| begin_of_thought |>D<| end_of_thought |>E" + raw := "A<|▁of▁thought|>B<| of_thought |>C<| begin_of_thought |>D<| end_of_thought |>E" got := sanitizeLeakedOutput(raw) if got != "ABCDE" { t.Fatalf("unexpected sanitize result for leaked thought markers: %q", got) @@ -51,7 +51,7 @@ func TestSanitizeLeakedOutputRemovesDanglingThinkBlock(t *testing.T) { } func TestSanitizeLeakedOutputRemovesCompleteDSMLToolCallWrapper(t *testing.T) { - raw := "前置文本\n<|DSML|tool_calls>\n<|DSML|invoke name=\"Bash\">\n<|DSML|parameter name=\"command\">\n\n\n后置文本" + raw := "前置文本\n<|DSML|tool_calls>\n<|DSML|invoke name=\"Bash\">\n<|DSML|parameter name=\"command\">\n\n\n后置文本" got := sanitizeLeakedOutput(raw) if got != "前置文本\n\n后置文本" { t.Fatalf("unexpected sanitize result for leaked dsml wrapper: %q", got) diff --git a/internal/httpapi/openai/shared/leaked_output_sanitize.go b/internal/httpapi/openai/shared/leaked_output_sanitize.go index b45a3ac..ae317f6 100644 --- a/internal/httpapi/openai/shared/leaked_output_sanitize.go +++ b/internal/httpapi/openai/shared/leaked_output_sanitize.go @@ -14,20 +14,20 @@ var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s* var leakedThinkTagPattern = regexp.MustCompile(`(?is)`) // leakedBOSMarkerPattern matches DeepSeek BOS markers in BOTH forms: -// - ASCII underscore: <|begin_of_sentence|> -// - U+2581 variant: <|begin▁of▁sentence|> -var leakedBOSMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*begin[_▁]of[_▁]sentence\s*[|\|]>`) +// - ASCII underscore: <|begin_of_sentence|> +// - U+2581 variant: <|begin▁of▁sentence|> +var leakedBOSMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*begin[_▁]of[_▁]sentence\s*[|\|]>`) // leakedThoughtMarkerPattern matches leaked thought control markers in both // explicit and compact forms: // - ASCII underscore: <| of_thought |>, <| begin_of_thought |> -// - U+2581 variant: <|▁of▁thought|>, <|begin▁of▁thought|> -var leakedThoughtMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:begin[_▁])?[_▁]*of[_▁]thought\s*[|\|]>`) +// - U+2581 variant: <|▁of▁thought|>, <|begin▁of▁thought|> +var leakedThoughtMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:begin[_▁])?[_▁]*of[_▁]thought\s*[|\|]>`) // leakedMetaMarkerPattern matches the remaining DeepSeek special tokens in BOTH forms: -// - ASCII underscore: <|end_of_sentence|>, <|end_of_toolresults|>, <|end_of_instructions|> -// - U+2581 variant: <|end▁of▁sentence|>, <|end▁of▁toolresults|>, <|end▁of▁instructions|> -var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]thought|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[|\|]>`) +// - ASCII underscore: <|end_of_sentence|>, <|end_of_toolresults|>, <|end_of_instructions|> +// - U+2581 variant: <|end▁of▁sentence|>, <|end▁of▁toolresults|>, <|end▁of▁instructions|> +var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]thought|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[|\|]>`) // leakedAgentXMLBlockPatterns catch agent-style XML blocks that leak through // when the sieve fails to capture them. These are applied only to complete diff --git a/internal/js/chat-stream/sse_parse_impl.js b/internal/js/chat-stream/sse_parse_impl.js index 4d9a121..735c615 100644 --- a/internal/js/chat-stream/sse_parse_impl.js +++ b/internal/js/chat-stream/sse_parse_impl.js @@ -7,9 +7,9 @@ const { SKIP_EXACT_PATHS, } = require('../shared/deepseek-constants'); -const LEAKED_BOS_MARKER_PATTERN = /<[||]\s*begin[_▁]of[_▁]sentence\s*[||]>/gi; -const LEAKED_THOUGHT_MARKER_PATTERN = /<[||]\s*(?:begin[_▁])?[_▁]*of[_▁]thought\s*[||]>/gi; -const LEAKED_META_MARKER_PATTERN = /<[||]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]thought|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[||]>/gi; +const LEAKED_BOS_MARKER_PATTERN = /<[||]\s*begin[_▁]of[_▁]sentence\s*[||]>/gi; +const LEAKED_THOUGHT_MARKER_PATTERN = /<[||]\s*(?:begin[_▁])?[_▁]*of[_▁]thought\s*[||]>/gi; +const LEAKED_META_MARKER_PATTERN = /<[||]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]thought|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[||]>/gi; diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index 37c4df6..e9fc02f 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -1356,7 +1356,7 @@ function consumeToolMarkupPipe(raw, idx) { if (pos >= raw.length) { return { next: idx, ok: false }; } - for (const variant of ['|', '|', '│', '∣', '❘', 'ǀ', '│']) { + for (const variant of ['|', '│', '∣', '❘', 'ǀ', '│']) { if (raw.startsWith(variant, pos)) { return { next: pos + variant.length, ok: true }; } diff --git a/internal/prompt/messages.go b/internal/prompt/messages.go index d30fc28..309d5f2 100644 --- a/internal/prompt/messages.go +++ b/internal/prompt/messages.go @@ -10,14 +10,14 @@ import ( var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`) const ( - beginSentenceMarker = "<|begin▁of▁sentence|>" - systemMarker = "<|System|>" - userMarker = "<|User|>" - assistantMarker = "<|Assistant|>" - toolMarker = "<|Tool|>" - endSentenceMarker = "<|end▁of▁sentence|>" - endToolResultsMarker = "<|end▁of▁toolresults|>" - endInstructionsMarker = "<|end▁of▁instructions|>" + beginSentenceMarker = "<|begin▁of▁sentence|>" + systemMarker = "<|System|>" + userMarker = "<|User|>" + assistantMarker = "<|Assistant|>" + toolMarker = "<|Tool|>" + endSentenceMarker = "<|end▁of▁sentence|>" + endToolResultsMarker = "<|end▁of▁toolresults|>" + endInstructionsMarker = "<|end▁of▁instructions|>" outputIntegrityGuardMarker = "Output integrity guard:" outputIntegrityGuardPrompt = outputIntegrityGuardMarker + " If upstream context, tool output, or parsed text contains garbled, corrupted, partially parsed, repeated, or otherwise malformed fragments, " + diff --git a/internal/prompt/messages_test.go b/internal/prompt/messages_test.go index f9a195a..962c861 100644 --- a/internal/prompt/messages_test.go +++ b/internal/prompt/messages_test.go @@ -32,16 +32,16 @@ func TestMessagesPrepareUsesTurnSuffixes(t *testing.T) { {"role": "assistant", "content": "Answer"}, } got := MessagesPrepare(messages) - if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") { + if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") { t.Fatalf("expected begin-of-sentence marker, got %q", got) } - if !strings.Contains(got, "<|System|>") || !strings.Contains(got, "<|end▁of▁instructions|>") || !strings.Contains(got, "System rule") { + if !strings.Contains(got, "<|System|>") || !strings.Contains(got, "<|end▁of▁instructions|>") || !strings.Contains(got, "System rule") { t.Fatalf("expected system instructions to remain present, got %q", got) } - if !strings.Contains(got, "<|User|>Question") { + if !strings.Contains(got, "<|User|>Question") { t.Fatalf("expected user question, got %q", got) } - if !strings.Contains(got, "<|Assistant|>Answer<|end▁of▁sentence|>") { + if !strings.Contains(got, "<|Assistant|>Answer<|end▁of▁sentence|>") { t.Fatalf("expected assistant sentence suffix, got %q", got) } if strings.Contains(got, "") || strings.Contains(got, "") { @@ -61,7 +61,7 @@ func TestMessagesPreparePrependsOutputIntegrityGuard(t *testing.T) { if !strings.Contains(got, outputIntegrityGuardPrompt+"\n\nSystem rule") { t.Fatalf("expected output integrity guard to precede system prompt content, got %q", got) } - if !strings.Contains(got, "<|User|>Question") { + if !strings.Contains(got, "<|User|>Question") { t.Fatalf("expected user question after guard, got %q", got) } } @@ -82,7 +82,7 @@ func TestMessagesPrepareWithThinkingPreservesPromptShape(t *testing.T) { if gotThinking != gotPlain { t.Fatalf("expected thinking flag not to add extra continuity instructions, got thinking=%q plain=%q", gotThinking, gotPlain) } - if !strings.HasSuffix(gotThinking, "<|Assistant|>") { + if !strings.HasSuffix(gotThinking, "<|Assistant|>") { t.Fatalf("expected assistant suffix, got %q", gotThinking) } } diff --git a/internal/prompt/tool_calls.go b/internal/prompt/tool_calls.go index e191dcf..050a2a9 100644 --- a/internal/prompt/tool_calls.go +++ b/internal/prompt/tool_calls.go @@ -17,12 +17,12 @@ var promptXMLTextEscaper = strings.NewReplacer( var promptXMLNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.:-]*$`) const ( - promptDSMLToolCallsOpen = "<|DSML|tool_calls>" - promptDSMLToolCallsClose = "" - promptDSMLInvokeOpen = "<|DSML|invoke" - promptDSMLInvokeClose = "" - promptDSMLParameterOpen = "<|DSML|parameter" - promptDSMLParameterClose = "" + promptDSMLToolCallsOpen = "<|DSML|tool_calls>" + promptDSMLToolCallsClose = "" + promptDSMLInvokeOpen = "<|DSML|invoke" + promptDSMLInvokeClose = "" + promptDSMLParameterOpen = "<|DSML|parameter" + promptDSMLParameterClose = "" ) // FormatToolCallsForPrompt renders a tool_calls slice into the prompt-visible diff --git a/internal/prompt/tool_calls_test.go b/internal/prompt/tool_calls_test.go index eef0a4a..8a5a369 100644 --- a/internal/prompt/tool_calls_test.go +++ b/internal/prompt/tool_calls_test.go @@ -22,7 +22,7 @@ func TestFormatToolCallsForPromptDSML(t *testing.T) { if got == "" { t.Fatal("expected non-empty formatted tool calls") } - if got != "<|DSML|tool_calls>\n <|DSML|invoke name=\"search_web\">\n <|DSML|parameter name=\"query\">\n \n" { + if got != "<|DSML|tool_calls>\n <|DSML|invoke name=\"search_web\">\n <|DSML|parameter name=\"query\">\n \n" { t.Fatalf("unexpected formatted tool call DSML: %q", got) } } @@ -34,7 +34,7 @@ func TestFormatToolCallsForPromptEscapesXMLEntities(t *testing.T) { "arguments": `{"q":"a < b && c > d"}`, }, }) - want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"search<&>\">\n <|DSML|parameter name=\"q\"> d]]>\n \n" + want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"search<&>\">\n <|DSML|parameter name=\"q\"> d]]>\n \n" if got != want { t.Fatalf("unexpected escaped tool call XML: %q", got) } @@ -50,7 +50,7 @@ func TestFormatToolCallsForPromptUsesCDATAForMultilineContent(t *testing.T) { }, }, }) - want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"write_file\">\n <|DSML|parameter name=\"content\">\n <|DSML|parameter name=\"path\">\n \n" + want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"write_file\">\n <|DSML|parameter name=\"content\">\n <|DSML|parameter name=\"path\">\n \n" if got != want { t.Fatalf("unexpected multiline cdata tool call XML: %q", got) } diff --git a/internal/promptcompat/message_normalize_test.go b/internal/promptcompat/message_normalize_test.go index e117209..cd37f55 100644 --- a/internal/promptcompat/message_normalize_test.go +++ b/internal/promptcompat/message_normalize_test.go @@ -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, "<|DSML|tool_calls>") { + if !strings.Contains(assistantContent, "<|DSML|tool_calls>") { t.Fatalf("assistant tool history should be preserved in DSML form, got %q", assistantContent) } - if !strings.Contains(assistantContent, `<|DSML|invoke name="get_weather">`) { + if !strings.Contains(assistantContent, `<|DSML|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, "<|DSML|tool_calls>") { + if !strings.Contains(prompt, "<|DSML|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, "<|DSML|invoke name=") != 2 { + if strings.Count(content, "<|DSML|invoke name=") != 2 { t.Fatalf("expected two preserved tool call blocks, got %q", content) } - if !strings.Contains(content, `<|DSML|invoke name="search_web">`) || !strings.Contains(content, `<|DSML|invoke name="eval_javascript">`) { + if !strings.Contains(content, `<|DSML|invoke name="search_web">`) || !strings.Contains(content, `<|DSML|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, "<|DSML|tool_calls>") { + if !strings.Contains(content, "<|DSML|tool_calls>") { t.Fatalf("expected assistant tool history in normalized content, got %q", content) } } @@ -282,11 +282,11 @@ func TestNormalizeOpenAIMessagesForPrompt_CanonicalizesStandaloneAssistantToolMa } content, _ := normalized[0]["content"].(string) for _, want := range []string{ - "<|DSML|tool_calls>", - `<|DSML|invoke name="Bash">`, - `<|DSML|parameter name="command">`, - `<|DSML|parameter name="description">`, - "", + "<|DSML|tool_calls>", + `<|DSML|invoke name="Bash">`, + `<|DSML|parameter name="command">`, + `<|DSML|parameter name="description">`, + "", } { if !strings.Contains(content, want) { t.Fatalf("expected canonicalized assistant tool markup to contain %q, got %q", want, content) diff --git a/internal/promptcompat/prompt_build.go b/internal/promptcompat/prompt_build.go index 9d2ee4e..6ba0f9a 100644 --- a/internal/promptcompat/prompt_build.go +++ b/internal/promptcompat/prompt_build.go @@ -9,10 +9,22 @@ func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string, thi } func BuildOpenAIPrompt(messagesRaw []any, toolsRaw any, traceID string, toolPolicy ToolChoicePolicy, thinkingEnabled bool) (string, []string) { + return buildOpenAIPrompt(messagesRaw, toolsRaw, traceID, toolPolicy, thinkingEnabled, true) +} + +func BuildOpenAIPromptWithToolInstructionsOnly(messagesRaw []any, toolsRaw any, traceID string, toolPolicy ToolChoicePolicy, thinkingEnabled bool) (string, []string) { + return buildOpenAIPrompt(messagesRaw, toolsRaw, traceID, toolPolicy, thinkingEnabled, false) +} + +func buildOpenAIPrompt(messagesRaw []any, toolsRaw any, traceID string, toolPolicy ToolChoicePolicy, thinkingEnabled bool, includeToolDescriptions bool) (string, []string) { messages := NormalizeOpenAIMessagesForPrompt(messagesRaw, traceID) toolNames := []string{} if tools, ok := toolsRaw.([]any); ok && len(tools) > 0 { - messages, toolNames = injectToolPrompt(messages, tools, toolPolicy) + if includeToolDescriptions { + messages, toolNames = injectToolPrompt(messages, tools, toolPolicy) + } else { + messages, toolNames = injectToolPromptInstructionsOnly(messages, tools, toolPolicy) + } } return prompt.MessagesPrepareWithThinking(messages, thinkingEnabled), toolNames } diff --git a/internal/promptcompat/prompt_build_test.go b/internal/promptcompat/prompt_build_test.go index 0a96973..61c5ede 100644 --- a/internal/promptcompat/prompt_build_test.go +++ b/internal/promptcompat/prompt_build_test.go @@ -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, "<|DSML|tool_calls>") { + if !strings.Contains(finalPrompt, "<|DSML|tool_calls>") { t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt) } - if !strings.Contains(finalPrompt, `<|DSML|invoke name="get_weather">`) { + if !strings.Contains(finalPrompt, `<|DSML|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 <|DSML|tool_calls>... block at the end of your response.") { + if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>... 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") { @@ -88,6 +88,64 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t * } } +func TestBuildOpenAIPromptWithToolInstructionsOnlyOmitsSchemas(t *testing.T) { + messages := []any{ + map[string]any{"role": "system", "content": "You are helpful"}, + map[string]any{"role": "user", "content": "请调用工具"}, + } + tools := []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + "description": "search docs", + "parameters": map[string]any{ + "type": "object", + }, + }, + }, + } + + finalPrompt, toolNames := BuildOpenAIPromptWithToolInstructionsOnly(messages, tools, "", DefaultToolChoicePolicy(), false) + if len(toolNames) != 1 || toolNames[0] != "search" { + t.Fatalf("unexpected tool names: %#v", toolNames) + } + if strings.Contains(finalPrompt, "You have access to these tools") || strings.Contains(finalPrompt, "Description: search docs") || strings.Contains(finalPrompt, "Parameters:") { + t.Fatalf("tool descriptions should be externalized, got: %q", finalPrompt) + } + if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") || !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools") { + t.Fatalf("expected tool format instructions to remain in live prompt, got: %q", finalPrompt) + } +} + +func TestBuildOpenAIToolsContextTranscriptContainsOnlyDescriptions(t *testing.T) { + tools := []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + "description": "search docs", + "parameters": map[string]any{ + "type": "object", + }, + }, + }, + } + + transcript, toolNames := BuildOpenAIToolsContextTranscript(tools, DefaultToolChoicePolicy()) + if len(toolNames) != 1 || toolNames[0] != "search" { + t.Fatalf("unexpected tool names: %#v", toolNames) + } + for _, want := range []string{"# DS2API_TOOLS.txt", "You have access to these tools", "Tool: search", "Description: search docs", `Parameters: {"type":"object"}`} { + if !strings.Contains(transcript, want) { + t.Fatalf("expected tools transcript to contain %q, got: %q", want, transcript) + } + } + if strings.Contains(transcript, "TOOL CALL FORMAT") || strings.Contains(transcript, "<|DSML|tool_calls>") { + t.Fatalf("tools transcript should not duplicate format instructions, got: %q", transcript) + } +} + func TestBuildOpenAIFinalPromptPrependsOutputIntegrityGuard(t *testing.T) { messages := []any{ map[string]any{"role": "system", "content": "You are helpful"}, diff --git a/internal/promptcompat/responses_input_items_test.go b/internal/promptcompat/responses_input_items_test.go index dfc5371..81c2157 100644 --- a/internal/promptcompat/responses_input_items_test.go +++ b/internal/promptcompat/responses_input_items_test.go @@ -88,7 +88,7 @@ func TestNormalizeResponsesInputArrayMergesReasoningMessageIntoFunctionCallHisto if !strings.Contains(history, "[reasoning_content]\nneed fresh docs before answering\n[/reasoning_content]") { t.Fatalf("expected reasoning in history transcript, got %q", history) } - if !strings.Contains(history, `<|DSML|invoke name="search_web">`) { + if !strings.Contains(history, `<|DSML|invoke name="search_web">`) { t.Fatalf("expected tool call in history transcript, got %q", history) } } diff --git a/internal/promptcompat/tool_prompt.go b/internal/promptcompat/tool_prompt.go index 4e5d03f..d6b6144 100644 --- a/internal/promptcompat/tool_prompt.go +++ b/internal/promptcompat/tool_prompt.go @@ -9,10 +9,50 @@ import ( "ds2api/internal/toolcall" ) +const CurrentToolsContextFilename = "DS2API_TOOLS.txt" + +const toolsTranscriptTitle = "# DS2API_TOOLS.txt" +const toolsTranscriptSummary = "Available tool descriptions and parameter schemas for this request." + +type toolPromptParts struct { + Descriptions string + Instructions string + Names []string +} + func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoicePolicy) ([]map[string]any, []string) { + return injectToolPromptWithDescriptions(messages, tools, policy, true) +} + +func injectToolPromptInstructionsOnly(messages []map[string]any, tools []any, policy ToolChoicePolicy) ([]map[string]any, []string) { + return injectToolPromptWithDescriptions(messages, tools, policy, false) +} + +func injectToolPromptWithDescriptions(messages []map[string]any, tools []any, policy ToolChoicePolicy, includeDescriptions bool) ([]map[string]any, []string) { if policy.IsNone() { return messages, nil } + parts := buildToolPromptParts(tools, policy) + if parts.Instructions == "" { + return messages, parts.Names + } + toolPrompt := parts.Instructions + if includeDescriptions && parts.Descriptions != "" { + toolPrompt = parts.Descriptions + "\n\n" + toolPrompt + } + + for i := range messages { + if messages[i]["role"] == "system" { + old, _ := messages[i]["content"].(string) + messages[i]["content"] = strings.TrimSpace(old + "\n\n" + toolPrompt) + return messages, parts.Names + } + } + messages = append([]map[string]any{{"role": "system", "content": toolPrompt}}, messages...) + return messages, parts.Names +} + +func buildToolPromptParts(tools []any, policy ToolChoicePolicy) toolPromptParts { toolSchemas := make([]string, 0, len(tools)) names := make([]string, 0, len(tools)) isAllowed := func(name string) bool { @@ -44,29 +84,47 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoiceP toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, string(b))) } if len(toolSchemas) == 0 { - return messages, names + return toolPromptParts{Names: names} } - toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\n" + toolcall.BuildToolCallInstructions(names) + descriptions := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + instructions := toolcall.BuildToolCallInstructions(names) if hasReadLikeTool(names) { - toolPrompt += "\n\nRead-tool cache guard: If a Read/read_file-style tool result says the file is unchanged, already available in history, should be referenced from previous context, or otherwise provides no file body, treat that result as missing content. Do not repeatedly call the same read request for that missing body. Request a full-content read if the tool supports it, or tell the user that the file contents need to be provided again." + instructions += "\n\nRead-tool cache guard: If a Read/read_file-style tool result says the file is unchanged, already available in history, should be referenced from previous context, or otherwise provides no file body, treat that result as missing content. Do not repeatedly call the same read request for that missing body. Request a full-content read if the tool supports it, or tell the user that the file contents need to be provided again." } if policy.Mode == ToolChoiceRequired { - toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list." + instructions += "\n7) For this response, you MUST call at least one tool from the allowed list." } if policy.Mode == ToolChoiceForced && strings.TrimSpace(policy.ForcedName) != "" { - toolPrompt += "\n7) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName) - toolPrompt += "\n8) Do not call any other tool." + instructions += "\n7) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName) + instructions += "\n8) Do not call any other tool." } + return toolPromptParts{ + Descriptions: descriptions, + Instructions: instructions, + Names: names, + } +} - for i := range messages { - if messages[i]["role"] == "system" { - old, _ := messages[i]["content"].(string) - messages[i]["content"] = strings.TrimSpace(old + "\n\n" + toolPrompt) - return messages, names - } +func BuildOpenAIToolsContextTranscript(toolsRaw any, policy ToolChoicePolicy) (string, []string) { + if policy.IsNone() { + return "", nil } - messages = append([]map[string]any{{"role": "system", "content": toolPrompt}}, messages...) - return messages, names + tools, ok := toolsRaw.([]any) + if !ok || len(tools) == 0 { + return "", nil + } + parts := buildToolPromptParts(tools, policy) + if strings.TrimSpace(parts.Descriptions) == "" { + return "", parts.Names + } + var b strings.Builder + b.WriteString(toolsTranscriptTitle) + b.WriteString("\n") + b.WriteString(toolsTranscriptSummary) + b.WriteString("\n\n") + b.WriteString(parts.Descriptions) + b.WriteString("\n") + return b.String(), parts.Names } func hasReadLikeTool(names []string) bool { diff --git a/internal/toolcall/tool_prompt.go b/internal/toolcall/tool_prompt.go index 9f278e5..c9b4a51 100644 --- a/internal/toolcall/tool_prompt.go +++ b/internal/toolcall/tool_prompt.go @@ -11,19 +11,19 @@ import "strings" func BuildToolCallInstructions(toolNames []string) string { return `TOOL CALL FORMAT — FOLLOW EXACTLY: -<|DSML|tool_calls> - <|DSML|invoke name="TOOL_NAME_HERE"> - <|DSML|parameter name="PARAMETER_NAME"> - - +<|DSML|tool_calls> + <|DSML|invoke name="TOOL_NAME_HERE"> + <|DSML|parameter name="PARAMETER_NAME"> + + RULES: -1) Use the <|DSML|tool_calls> wrapper format. -2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_calls> root. -3) Put the tool name in the invoke name attribute: <|DSML|invoke name="TOOL_NAME">. -3a) Tag punctuation alphabet: ASCII < > / = " plus the fullwidth vertical bar |. +1) Use the <|DSML|tool_calls> wrapper format. +2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_calls> root. +3) Put the tool name in the invoke name attribute: <|DSML|invoke name="TOOL_NAME">. +3a) Tag punctuation alphabet: ASCII < > / = " plus the halfwidth pipe |. 4) All string values must use , even short ones. This includes code, scripts, file contents, prompts, paths, names, and queries. -5) Every top-level argument must be a <|DSML|parameter name="ARG_NAME">... node. +5) Every top-level argument must be a <|DSML|parameter name="ARG_NAME">... node. 6) Objects use nested XML elements inside the parameter body. Arrays may repeat children. 7) Numbers, booleans, and null stay plain text. 8) Use only the parameter names in the tool schema. Do not invent fields. @@ -31,35 +31,35 @@ RULES: 10) If a required parameter value is unknown, ask the user or answer normally instead of outputting an empty tool call. 11) For shell tools such as Bash / execute_command, the command/script must be inside the command parameter. Never call them with an empty command. 12) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue. -13) If you call a tool, the first non-whitespace characters of that tool block must be exactly <|DSML|tool_calls>. -14) Never omit the opening <|DSML|tool_calls> tag, even if you already plan to close with . +13) If you call a tool, the first non-whitespace characters of that tool block must be exactly <|DSML|tool_calls>. +14) Never omit the opening <|DSML|tool_calls> tag, even if you already plan to close with . 15) Compatibility note: the runtime also accepts the legacy XML tags / / , but prefer the DSML-prefixed form above. PARAMETER SHAPES: -- string => <|DSML|parameter name="x"> -- object => <|DSML|parameter name="x">... -- array => <|DSML|parameter name="x">...... -- number/bool/null => <|DSML|parameter name="x">plain_text +- string => <|DSML|parameter name="x"> +- object => <|DSML|parameter name="x">... +- array => <|DSML|parameter name="x">...... +- number/bool/null => <|DSML|parameter name="x">plain_text 【WRONG — Do NOT do these】: Wrong 1 — mixed text after XML: - <|DSML|tool_calls>... I hope this helps. + <|DSML|tool_calls>... I hope this helps. Wrong 2 — Markdown code fences: ` + "```xml" + ` - <|DSML|tool_calls>... + <|DSML|tool_calls>... ` + "```" + ` Wrong 3 — missing opening wrapper: - <|DSML|invoke name="TOOL_NAME">... - + <|DSML|invoke name="TOOL_NAME">... + Wrong 4 — empty parameters: - <|DSML|tool_calls> - <|DSML|invoke name="Bash"> - <|DSML|parameter name="command"> - - + <|DSML|tool_calls> + <|DSML|invoke name="Bash"> + <|DSML|parameter name="command"> + + -Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>... block at the end of your response. +Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>... block at the end of your response. ` + buildCorrectToolExamples(toolNames) } @@ -150,21 +150,21 @@ func firstScriptExample(names []string) (promptToolExample, bool) { func renderToolExampleBlock(calls []promptToolExample) string { var b strings.Builder - b.WriteString("<|DSML|tool_calls>\n") + b.WriteString("<|DSML|tool_calls>\n") for _, call := range calls { - b.WriteString(` <|DSML|invoke name="`) + b.WriteString(` <|DSML|invoke name="`) b.WriteString(call.name) b.WriteString(`">` + "\n") b.WriteString(indentPromptParameters(call.params, " ")) - b.WriteString("\n \n") + b.WriteString("\n \n") } - b.WriteString("") + b.WriteString("") return b.String() } func indentPromptParameters(body, indent string) string { if strings.TrimSpace(body) == "" { - return indent + `<|DSML|parameter name="content">` + return indent + `<|DSML|parameter name="content">` } lines := strings.Split(body, "\n") for i, line := range lines { @@ -178,7 +178,7 @@ func indentPromptParameters(body, indent string) string { } func wrapParameter(name, inner string) string { - return `<|DSML|parameter name="` + name + `">` + inner + `` + return `<|DSML|parameter name="` + name + `">` + inner + `` } func exampleBasicParams(name string) (string, bool) { @@ -204,7 +204,7 @@ func exampleBasicParams(name string) (string, bool) { case "Edit": return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar")), true case "MultiEdit": - return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits">` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true + return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits">` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true } return "", false } @@ -212,11 +212,11 @@ func exampleBasicParams(name string) (string, bool) { func exampleNestedParams(name string) (string, bool) { switch strings.TrimSpace(name) { case "MultiEdit": - return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits">` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true + return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits">` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true case "Task": return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures")), true case "ask_followup_question": - return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<|DSML|parameter name="follow_up">` + promptCDATA("Option A") + `` + promptCDATA("Option B") + ``, true + return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<|DSML|parameter name="follow_up">` + promptCDATA("Option A") + `` + promptCDATA("Option B") + ``, true } return "", false } diff --git a/internal/toolcall/tool_prompt_test.go b/internal/toolcall/tool_prompt_test.go index 1c3757c..66bbe7a 100644 --- a/internal/toolcall/tool_prompt_test.go +++ b/internal/toolcall/tool_prompt_test.go @@ -7,20 +7,20 @@ import ( func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) { out := BuildToolCallInstructions([]string{"exec_command"}) - if !strings.Contains(out, `<|DSML|invoke name="exec_command">`) { + if !strings.Contains(out, `<|DSML|invoke name="exec_command">`) { t.Fatalf("expected exec_command in examples, got: %s", out) } - if !strings.Contains(out, `<|DSML|parameter name="cmd">`) { + if !strings.Contains(out, `<|DSML|parameter name="cmd">`) { 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, `<|DSML|invoke name="execute_command">`) { + if !strings.Contains(out, `<|DSML|invoke name="execute_command">`) { t.Fatalf("expected execute_command in examples, got: %s", out) } - if !strings.Contains(out, `<|DSML|parameter name="command">`) { + if !strings.Contains(out, `<|DSML|parameter name="command">`) { t.Fatalf("expected command parameter example for execute_command, got: %s", out) } } @@ -34,20 +34,20 @@ func TestBuildToolCallInstructions_BashUsesCommandAndDescriptionExamples(t *test sawDescription := false for _, block := range blocks { - if !strings.Contains(block, `<|DSML|parameter name="command">`) { + if !strings.Contains(block, `<|DSML|parameter name="command">`) { t.Fatalf("expected every Bash example to use command parameter, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { + if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { t.Fatalf("expected Bash examples not to use file write parameters, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="description">`) { + if strings.Contains(block, `<|DSML|parameter name="description">`) { sawDescription = true } } if !sawDescription { t.Fatalf("expected Bash long-script example to include description, got: %s", out) } - if strings.Contains(out, `<|DSML|invoke name="Read">`) { + if strings.Contains(out, `<|DSML|invoke name="Read">`) { t.Fatalf("expected examples to avoid unavailable hard-coded Read tool, got: %s", out) } } @@ -60,10 +60,10 @@ func TestBuildToolCallInstructions_ExecuteCommandLongScriptUsesCommand(t *testin } for _, block := range blocks { - if !strings.Contains(block, `<|DSML|parameter name="command">`) { + if !strings.Contains(block, `<|DSML|parameter name="command">`) { t.Fatalf("expected execute_command examples to use command parameter, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { + if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { t.Fatalf("expected execute_command examples not to use file write parameters, got: %s", block) } } @@ -80,10 +80,10 @@ func TestBuildToolCallInstructions_ExecCommandLongScriptUsesCmd(t *testing.T) { } for _, block := range blocks { - if !strings.Contains(block, `<|DSML|parameter name="cmd">`) { + if !strings.Contains(block, `<|DSML|parameter name="cmd">`) { t.Fatalf("expected exec_command examples to use cmd parameter, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="command">`) || strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { + if strings.Contains(block, `<|DSML|parameter name="command">`) || strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { t.Fatalf("expected exec_command examples not to use command or file write parameters, got: %s", block) } } @@ -100,10 +100,10 @@ func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) { } for _, block := range blocks { - if !strings.Contains(block, `<|DSML|parameter name="file_path">`) || !strings.Contains(block, `<|DSML|parameter name="content">`) { + if !strings.Contains(block, `<|DSML|parameter name="file_path">`) || !strings.Contains(block, `<|DSML|parameter name="content">`) { t.Fatalf("expected Write examples to use file_path and content, got: %s", block) } - if strings.Contains(block, `<|DSML|parameter name="path">`) { + if strings.Contains(block, `<|DSML|parameter name="path">`) { t.Fatalf("expected Write examples not to use path, got: %s", block) } } @@ -111,7 +111,7 @@ func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) { func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *testing.T) { out := BuildToolCallInstructions([]string{"read_file"}) - if !strings.Contains(out, "Never omit the opening <|DSML|tool_calls> tag") { + if !strings.Contains(out, "Never omit the opening <|DSML|tool_calls> tag") { t.Fatalf("expected explicit missing-opening-tag warning, got: %s", out) } if !strings.Contains(out, "Wrong 3 — missing opening wrapper") { @@ -135,7 +135,7 @@ func TestBuildToolCallInstructions_RejectsEmptyParametersInPrompt(t *testing.T) func TestBuildToolCallInstructions_UsesPositiveTagPunctuationAlphabet(t *testing.T) { out := BuildToolCallInstructions([]string{"Bash"}) - want := `Tag punctuation alphabet: ASCII < > / = " plus the fullwidth vertical bar |.` + want := `Tag punctuation alphabet: ASCII < > / = " plus the halfwidth pipe |.` if !strings.Contains(out, want) { t.Fatalf("expected positive tag punctuation alphabet %q, got: %s", want, out) } @@ -147,7 +147,7 @@ func TestBuildToolCallInstructions_UsesPositiveTagPunctuationAlphabet(t *testing } func findInvokeBlocks(text, name string) []string { - open := `<|DSML|invoke name="` + name + `">` + open := `<|DSML|invoke name="` + name + `">` remaining := text blocks := []string{} for { @@ -156,11 +156,11 @@ func findInvokeBlocks(text, name string) []string { return blocks } remaining = remaining[start:] - end := strings.Index(remaining, ``) + end := strings.Index(remaining, ``) if end < 0 { return blocks } - end += len(``) + end += len(``) blocks = append(blocks, remaining[:end]) remaining = remaining[end:] } diff --git a/internal/toolcall/toolcalls_candidates.go b/internal/toolcall/toolcalls_candidates.go index 3d5cf76..187d61a 100644 --- a/internal/toolcall/toolcalls_candidates.go +++ b/internal/toolcall/toolcalls_candidates.go @@ -491,8 +491,6 @@ func consumeToolMarkupPipe(text string, idx int) (int, bool) { switch { case text[idx] == '|': return idx + 1, true - case strings.HasPrefix(text[idx:], "|"): - return idx + len("|"), true case strings.HasPrefix(text[idx:], "│"): return idx + len("│"), true case strings.HasPrefix(text[idx:], "∣"): diff --git a/internal/toolcall/toolcalls_test.go b/internal/toolcall/toolcalls_test.go index eb62ce5..28ff910 100644 --- a/internal/toolcall/toolcalls_test.go +++ b/internal/toolcall/toolcalls_test.go @@ -131,14 +131,14 @@ func TestParseToolCallsRejectsCamelPrefixedToolMarkupLookalike(t *testing.T) { } func TestParseToolCallsSupportsFullwidthDSMLShell(t *testing.T) { - text := `<dSML|tool_calls> - <dSML|invoke name="Read"> - <dSML|parameter name="file_path"> - - <dSML|invoke name="Read"> - <dSML|parameter name="file_path"> - -` + text := `<dSML|tool_calls> + <dSML|invoke name="Read"> + <dSML|parameter name="file_path"> + + <dSML|invoke name="Read"> + <dSML|parameter name="file_path"> + +` calls := ParseToolCalls(text, []string{"Read"}) if len(calls) != 2 { t.Fatalf("expected two fullwidth DSML calls, got %#v", calls) @@ -152,20 +152,20 @@ func TestParseToolCallsSupportsFullwidthDSMLShell(t *testing.T) { } func TestParseToolCallsSupportsCJKAngleDSMDrift(t *testing.T) { - text := ` - -〈![CDATA[Show commits on local dev not on origin/dev]]〉〈/DSM|parameter〉 -〈![CDATA[git log --oneline origin/dev..dev]]〉〈/DSM|parameter〉 -〈/DSM|invoke〉 - -〈![CDATA[Show commits on origin/dev not on local dev]]〉〈/DSM|parameter〉 -〈![CDATA[git log --oneline dev..origin/dev]]〉〈/DSM|parameter〉 -〈/DSM|invoke〉 - -〈![CDATA[Check tracking branch status]]〉〈/DSM|parameter〉 -〈![CDATA[git status -b --short]]〉〈/DSM|parameter〉 -〈/DSM|invoke〉 -〈/DSM|tool_calls〉` + text := ` + +〈![CDATA[Show commits on local dev not on origin/dev]]〉〈/DSM|parameter〉 +〈![CDATA[git log --oneline origin/dev..dev]]〉〈/DSM|parameter〉 +〈/DSM|invoke〉 + +〈![CDATA[Show commits on origin/dev not on local dev]]〉〈/DSM|parameter〉 +〈![CDATA[git log --oneline dev..origin/dev]]〉〈/DSM|parameter〉 +〈/DSM|invoke〉 + +〈![CDATA[Check tracking branch status]]〉〈/DSM|parameter〉 +〈![CDATA[git status -b --short]]〉〈/DSM|parameter〉 +〈/DSM|invoke〉 +〈/DSM|tool_calls〉` calls := ParseToolCalls(text, []string{"Bash"}) if len(calls) != 3 { @@ -1203,7 +1203,7 @@ func TestFindMatchingToolMarkupCloseBoundaryConditions(t *testing.T) { } func TestParseToolCallsSupportsDSMLShellWithFullwidthClosingSlash(t *testing.T) { - text := `<|DSML|tool_calls><|DSML|invoke name="execute_code"><|DSML|parameter name="code"></DSML|tool_calls>` + text := `<|DSML|tool_calls><|DSML|invoke name="execute_code"><|DSML|parameter name="code"></DSML|tool_calls>` calls := ParseToolCalls(text, []string{"execute_code"}) if len(calls) != 1 { t.Fatalf("expected 1 DSML call with fullwidth closing slash, got %#v", calls) @@ -1214,7 +1214,7 @@ func TestParseToolCallsSupportsDSMLShellWithFullwidthClosingSlash(t *testing.T) } func TestParseToolCallsSupportsDSMLShellWithSentencePieceSeparatorAndFullwidthGT(t *testing.T) { - text := `<|DSML▁tool_calls|><|DSML▁invoke▁name="execute_code"><|DSML▁parameter▁name="code"><|DSML▁invoke▁name="execute_code"><|DSML▁parameter▁name="code"></DSML|parameter></DSML|invoke></DSML|tool_calls>` + text := `<|DSML tool_calls><|DSML invoke name=“execute_code”><|DSML parameter name=“code”></DSML|parameter></DSML|invoke></DSML|tool_calls>` calls := ParseToolCalls(text, []string{"execute_code"}) if len(calls) != 1 { t.Fatalf("expected 1 DSML call with fullwidth opening delimiter and Unicode attribute confusables, got %#v", calls) diff --git a/internal/toolstream/complex_edge_test.go b/internal/toolstream/complex_edge_test.go index 3337773..5e400d0 100644 --- a/internal/toolstream/complex_edge_test.go +++ b/internal/toolstream/complex_edge_test.go @@ -316,11 +316,11 @@ func TestSieve_CharByCharToolCall(t *testing.T) { func TestSieve_FullwidthPipeWrapperDSMLInvoke(t *testing.T) { var state State chunks := []string{ - "<|tool_calls>\n", + "<|tool_calls>\n", "<|DSML|invoke name=\"read_file\">\n", "<|DSML|parameter name=\"path\">README.md\n", "\n", - "", + "", } var events []Event for _, c := range chunks { @@ -382,7 +382,7 @@ func TestSieve_TagMentionInTextThenRealToolCall(t *testing.T) { chunks := []string{ "建议的 commit message:\n\nfeat: expand DSML alias support\n\n", "Add support for , ", - "<|tool_calls> (fullwidth pipe),\n", + "<|tool_calls> (pipe alias),\n", "and <|tool_calls> wrapper variants.\n\n", "<|DSML|tool_calls>\n", "<|DSML|invoke name=\"Bash\">\n", @@ -466,14 +466,14 @@ func TestSieve_ReviewSampleWithAliasMentionsPreservesBodyAndToolCalls(t *testing chunks := []string{ "Done reviewing the diff. Here's my analysis before we commit:\n\n", "Summary of Changes\n", - "DSML wrapper variant support — recognize aliases (, <|tool_calls>, <|tool_calls>) alongside canonical and <|DSML|tool_calls> wrappers.\n\n", + "DSML wrapper variant support — recognize aliases (, <|tool_calls>) alongside canonical and <|DSML|tool_calls> wrappers.\n\n", "<|DSML|tool_calls>\n", "<|DSML|invoke name=\"Bash\">\n", "<|DSML|parameter name=\"command\">\n", "<|DSML|parameter name=\"description\">\n", "\n", "<|DSML|invoke name=\"Bash\">\n", - "<|DSML|parameter name=\"command\">, <|tool_calls>, <|tool_calls> alongside existing canonical wrappers.\nEOF\n)\"]]>\n", + "<|DSML|parameter name=\"command\"> and <|tool_calls> alongside existing canonical wrappers.\nEOF\n)\"]]>\n", "<|DSML|parameter name=\"description\">\n", "\n", "", diff --git a/internal/toolstream/tool_sieve_xml_test.go b/internal/toolstream/tool_sieve_xml_test.go index 2701b2f..418c812 100644 --- a/internal/toolstream/tool_sieve_xml_test.go +++ b/internal/toolstream/tool_sieve_xml_test.go @@ -626,13 +626,13 @@ func TestProcessToolSieveEmitsAllEmptyDSMLToolBlock(t *testing.T) { func TestProcessToolSieveEmitsChunkedAllEmptyArbitraryPrefixedToolBlock(t *testing.T) { chunk := strings.Join([]string{ - ``, - ` `, - ` `, - ` `, - ` `, - ` `, - ` `, + ``, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, }, "\n") calls := collectToolCallsForChunks(t, splitEveryNRBytes(chunk, 8), []string{"TaskOutput"}) if len(calls) != 1 { @@ -811,8 +811,8 @@ func TestFindPartialXMLToolTagStart(t *testing.T) { {"partial_tool_calls", "Hello (U+FF5C) should be buffered and parsed. +// Test escaped U+FF5C pipe variant: <\uff5ctool_calls> should be buffered and parsed. func TestProcessToolSieveFullwidthPipeVariantDoesNotLeak(t *testing.T) { var state State chunks := []string{ @@ -1115,19 +1115,19 @@ func TestProcessToolSieveFullwidthPipeVariantDoesNotLeak(t *testing.T) { } if strings.Contains(textContent, "invoke") || strings.Contains(textContent, "execute_command") { - t.Fatalf("fullwidth pipe variant leaked to text: %q", textContent) + t.Fatalf("escaped U+FF5C pipe variant leaked to text: %q", textContent) } if toolCalls != 1 { - t.Fatalf("expected one tool call from fullwidth pipe variant, got %d events=%#v", toolCalls, events) + t.Fatalf("expected one tool call from escaped U+FF5C pipe variant, got %d events=%#v", toolCalls, events) } } -// Test <|DSML|tool_calls> with DSML invoke/parameter tags should buffer the +// Test <|DSML|tool_calls> with DSML invoke/parameter tags should buffer the // wrapper instead of leaking it before the block is complete. func TestProcessToolSieveFullwidthDSMLPrefixVariantDoesNotLeak(t *testing.T) { var state State chunks := []string{ - "<|DSML|tool", + "<|DSML|tool", "_calls>\n", "<|DSML|invoke name=\"Bash\">\n", "<|DSML|parameter name=\"command\">\n", @@ -1232,12 +1232,12 @@ func TestProcessToolSieveDSMLBarePrefixVariantDoesNotLeak(t *testing.T) { func TestProcessToolSieveCJKAngleDSMDriftDoesNotLeak(t *testing.T) { var state State chunks := []string{ - "\n", - "\n", - "〈![CDATA[Check tracking branch status]]〉〈/DSM|parameter〉\n", - "〈![CDATA[git status -b --short]]〉〈/DSM|parameter〉\n", - "〈/DSM|invoke〉\n", - "〈/DSM|tool_calls〉", + "\n", + "\n", + "〈![CDATA[Check tracking branch status]]〉〈/DSM|parameter〉\n", + "〈![CDATA[git status -b --short]]〉〈/DSM|parameter〉\n", + "〈/DSM|invoke〉\n", + "〈/DSM|tool_calls〉", } var events []Event for _, c := range chunks { @@ -1338,7 +1338,7 @@ func TestProcessToolSieveIdeographicCommaDSMLDriftDoesNotLeak(t *testing.T) { func TestProcessToolSieveParsesFullwidthClosingSlashAndKeepsSuffixText(t *testing.T) { var state State - chunk := `<|DSML|tool_calls><|DSML|invoke name="execute_code"><|DSML|parameter name="code"></DSML|tool_calls> sao cụm này lại đc trả là 1 message` + chunk := `<|DSML|tool_calls><|DSML|invoke name="execute_code"><|DSML|parameter name="code"></DSML|tool_calls> sao cụm này lại đc trả là 1 message` events := ProcessChunk(&state, chunk, []string{"execute_code"}) events = append(events, Flush(&state, []string{"execute_code"})...) @@ -1365,7 +1365,7 @@ func TestProcessToolSieveParsesFullwidthClosingSlashAndKeepsSuffixText(t *testin func TestProcessToolSieveParsesSentencePieceSeparatorAndFullwidthTerminator(t *testing.T) { var state State - chunk := `<|DSML▁tool_calls|><|DSML▁invoke▁name="execute_code"><|DSML▁parameter▁name="code"><|DSML▁invoke▁name="execute_code"><|DSML▁parameter▁name="code"></DSML|parameter></DSML|invoke></DSML|tool_calls> suffix` + chunk := `<|DSML tool_calls><|DSML invoke name=“execute_code”><|DSML parameter name=“code”></DSML|parameter></DSML|invoke></DSML|tool_calls> suffix` events := ProcessChunk(&state, chunk, []string{"execute_code"}) events = append(events, Flush(&state, []string{"execute_code"})...) diff --git a/internal/util/messages_test.go b/internal/util/messages_test.go index 569e65d..0b2b1f4 100644 --- a/internal/util/messages_test.go +++ b/internal/util/messages_test.go @@ -13,10 +13,10 @@ func TestMessagesPrepareBasic(t *testing.T) { if got == "" { t.Fatal("expected non-empty prompt") } - if !strings.HasPrefix(got, "<|begin▁of▁sentence|><|System|>") { + if !strings.HasPrefix(got, "<|begin▁of▁sentence|><|System|>") { t.Fatalf("expected output integrity guard at the start, got %q", got) } - if !strings.Contains(got, "Hello") || !strings.HasSuffix(got, "<|Assistant|>") { + if !strings.Contains(got, "Hello") || !strings.HasSuffix(got, "<|Assistant|>") { t.Fatalf("unexpected prompt: %q", got) } } @@ -33,31 +33,31 @@ func TestMessagesPrepareRoles(t *testing.T) { if !contains(got, "Output integrity guard") { t.Fatalf("expected output integrity guard in %q", got) } - if !contains(got, "You are helper") || !contains(got, "<|User|>Hi") { + if !contains(got, "You are helper") || !contains(got, "<|User|>Hi") { t.Fatalf("expected system/user content in %q", got) } - if !contains(got, "<|begin▁of▁sentence|>") { + if !contains(got, "<|begin▁of▁sentence|>") { t.Fatalf("expected begin marker in %q", got) } - if !contains(got, "<|User|>Hi<|Assistant|>Hello<|end▁of▁sentence|>") { + if !contains(got, "<|User|>Hi<|Assistant|>Hello<|end▁of▁sentence|>") { t.Fatalf("expected user/assistant separation in %q", got) } - if !contains(got, "<|Assistant|>Hello<|end▁of▁sentence|><|Tool|>Search results<|end▁of▁toolresults|>") { + if !contains(got, "<|Assistant|>Hello<|end▁of▁sentence|><|Tool|>Search results<|end▁of▁toolresults|>") { t.Fatalf("expected assistant/tool separation in %q", got) } - if !contains(got, "<|Tool|>Search results<|end▁of▁toolresults|><|User|>How are you") { + if !contains(got, "<|Tool|>Search results<|end▁of▁toolresults|><|User|>How are you") { t.Fatalf("expected tool/user separation in %q", got) } - if !contains(got, "<|Assistant|>") { + if !contains(got, "<|Assistant|>") { t.Fatalf("expected assistant marker in %q", got) } - if !contains(got, "<|System|>") { + if !contains(got, "<|System|>") { t.Fatalf("expected system marker in %q", got) } - if !contains(got, "<|User|>") { + if !contains(got, "<|User|>") { t.Fatalf("expected user marker in %q", got) } - if !contains(got, "<|Tool|>") { + if !contains(got, "<|Tool|>") { t.Fatalf("expected tool marker in %q", got) } } diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index 463df1a..1943d6c 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -162,20 +162,20 @@ func TestMessagesPrepareMergesConsecutiveSameRole(t *testing.T) { {"role": "user", "content": "World"}, } got := MessagesPrepare(messages) - if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") { + if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") { t.Fatalf("expected user marker at the start, got %q", got) } if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") { t.Fatalf("expected both messages, got %q", got) } // Should be merged into a single user turn with one marker at the start. - count := strings.Count(got, "<|User|>") + count := strings.Count(got, "<|User|>") if count != 1 { t.Fatalf("expected one User marker for the merged pair, got %d occurrences", count) } // User messages no longer have end_of_sentence markers in the official format. // The merged pair should have zero end_of_sentence markers (user turn only). - if count := strings.Count(got, "<|end▁of▁sentence|>"); count != 0 { + if count := strings.Count(got, "<|end▁of▁sentence|>"); count != 0 { t.Fatalf("expected zero sentence terminators for user-only merge, got %d occurrences", count) } } @@ -186,16 +186,16 @@ func TestMessagesPrepareAssistantMarkers(t *testing.T) { {"role": "assistant", "content": "Hello!"}, } got := MessagesPrepare(messages) - if !strings.Contains(got, "<|Assistant|>") { + if !strings.Contains(got, "<|Assistant|>") { t.Fatalf("expected assistant marker, got %q", got) } - if !strings.Contains(got, "<|end▁of▁sentence|>") { + if !strings.Contains(got, "<|end▁of▁sentence|>") { t.Fatalf("expected end of sentence marker, got %q", got) } - if strings.Count(got, "<|end▁of▁sentence|>") != 1 { + if strings.Count(got, "<|end▁of▁sentence|>") != 1 { t.Fatalf("expected one end_of_sentence (assistant only), got %q", got) } - if !strings.Contains(got, "<|Assistant|>Hello!<|end▁of▁sentence|>") { + if !strings.Contains(got, "<|Assistant|>Hello!<|end▁of▁sentence|>") { t.Fatalf("expected assistant EOS suffix, got %q", got) } if strings.Contains(got, "") || strings.Contains(got, "") { diff --git a/tests/node/chat-history-utils.test.js b/tests/node/chat-history-utils.test.js index 05bf2f0..d9db46a 100644 --- a/tests/node/chat-history-utils.test.js +++ b/tests/node/chat-history-utils.test.js @@ -18,9 +18,9 @@ test('chat history strict parser merges current input file placeholder', async ( content: 'Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly.', }], history_text: [ - '<|begin▁of▁sentence|>', - '<|User|>hello', - '<|Assistant|>hi<|end▁of▁sentence|>', + '<|begin▁of▁sentence|>', + '<|User|>hello', + '<|Assistant|>hi<|end▁of▁sentence|>', ].join(''), }; @@ -43,9 +43,9 @@ test('chat history strict parser inserts history after system messages', async ( { role: 'user', content: 'latest' }, ], history_text: [ - '<|begin▁of▁sentence|>', - '<|User|>old', - '<|Assistant|>done<|end▁of▁sentence|>', + '<|begin▁of▁sentence|>', + '<|User|>old', + '<|Assistant|>done<|end▁of▁sentence|>', ].join(''), }; diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index 6cf3e0d..97226d9 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -646,7 +646,7 @@ test('parseChunkForContent strips citation and reference markers from fragment c test('parseChunkForContent strips leaked thought control markers from content', () => { const chunk = { p: 'response/content', - v: '<|▁of▁thought|>A<| of_thought |>B<| end_of_thought |>C', + v: '<|▁of▁thought|>A<| of_thought |>B<| end_of_thought |>C', }; const parsed = parseChunkForContent(chunk, false, 'text'); assert.equal(parsed.finished, false); diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index c78fb51..b989fdb 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -58,7 +58,7 @@ test('parseToolCalls parses DSML shell as XML-compatible tool call', () => { }); test('parseToolCalls tolerates fullwidth closing slash in DSML wrapper', () => { - const payload = '<|DSML|tool_calls><|DSML|invoke name="execute_code"><|DSML|parameter name="code"></DSML|tool_calls>'; + const payload = '<|DSML|tool_calls><|DSML|invoke name="execute_code"><|DSML|parameter name="code"></DSML|tool_calls>'; const calls = parseToolCalls(payload, ['execute_code']); assert.equal(calls.length, 1); assert.equal(calls[0].name, 'execute_code'); @@ -66,7 +66,7 @@ test('parseToolCalls tolerates fullwidth closing slash in DSML wrapper', () => { }); test('parseToolCalls tolerates sentencepiece separator and fullwidth terminator', () => { - const payload = '<|DSML▁tool_calls|><|DSML▁invoke▁name="execute_code"><|DSML▁parameter▁name="code"><|DSML▁invoke▁name="execute_code"><|DSML▁parameter▁name="code"> { - const payload = '<|DSML tool_calls><|DSML invoke name=“execute_code”><|DSML parameter name=“code”></DSML|parameter></DSML|invoke></DSML|tool_calls>'; + const payload = '<|DSML tool_calls><|DSML invoke name=“execute_code”><|DSML parameter name=“code”></DSML|parameter></DSML|invoke></DSML|tool_calls>'; const calls = parseToolCalls(payload, ['execute_code']); assert.equal(calls.length, 1); assert.equal(calls[0].name, 'execute_code'); @@ -162,14 +162,14 @@ test('parseToolCalls ignores camel-prefixed tool markup lookalike', () => { }); test('parseToolCalls parses fullwidth DSML shell drift', () => { - const payload = `<dSML|tool_calls> - <dSML|invoke name="Read"> - <dSML|parameter name="file_path"> - - <dSML|invoke name="Read"> - <dSML|parameter name="file_path"> - -`; + const payload = `<dSML|tool_calls> + <dSML|invoke name="Read"> + <dSML|parameter name="file_path"> + + <dSML|invoke name="Read"> + <dSML|parameter name="file_path"> + +`; const calls = parseToolCalls(payload, ['Read']); assert.equal(calls.length, 2); assert.equal(calls[0].name, 'Read'); @@ -179,20 +179,20 @@ test('parseToolCalls parses fullwidth DSML shell drift', () => { }); test('parseToolCalls parses CJK-angle DSM drift', () => { - const payload = ` - -〈![CDATA[Show commits on local dev not on origin/dev]]〉〈/DSM|parameter〉 -〈![CDATA[git log --oneline origin/dev..dev]]〉〈/DSM|parameter〉 -〈/DSM|invoke〉 - -〈![CDATA[Show commits on origin/dev not on local dev]]〉〈/DSM|parameter〉 -〈![CDATA[git log --oneline dev..origin/dev]]〉〈/DSM|parameter〉 -〈/DSM|invoke〉 - -〈![CDATA[Check tracking branch status]]〉〈/DSM|parameter〉 -〈![CDATA[git status -b --short]]〉〈/DSM|parameter〉 -〈/DSM|invoke〉 -〈/DSM|tool_calls〉`; + const payload = ` + +〈![CDATA[Show commits on local dev not on origin/dev]]〉〈/DSM|parameter〉 +〈![CDATA[git log --oneline origin/dev..dev]]〉〈/DSM|parameter〉 +〈/DSM|invoke〉 + +〈![CDATA[Show commits on origin/dev not on local dev]]〉〈/DSM|parameter〉 +〈![CDATA[git log --oneline dev..origin/dev]]〉〈/DSM|parameter〉 +〈/DSM|invoke〉 + +〈![CDATA[Check tracking branch status]]〉〈/DSM|parameter〉 +〈![CDATA[git status -b --short]]〉〈/DSM|parameter〉 +〈/DSM|invoke〉 +〈/DSM|tool_calls〉`; const calls = parseToolCalls(payload, ['Bash']); assert.equal(calls.length, 3); assert.equal(calls[0].name, 'Bash'); @@ -262,13 +262,13 @@ test('parseToolCalls parses arbitrary-prefixed tool tags', () => { }); test('parseToolCalls allows all-empty parameter payloads', () => { - const payload = ` - - - - - -`; + const payload = ` + + + + + +`; const calls = parseToolCalls(payload, ['TaskOutput']); assert.equal(calls.length, 1); assert.equal(calls[0].name, 'TaskOutput'); @@ -603,7 +603,7 @@ test('sieve emits tool_calls for DSML space-separator typo', () => { }); test('sieve emits tool_calls for fullwidth closing slash and preserves suffix text', () => { - const input = '<|DSML|tool_calls><|DSML|invoke name="execute_code"><|DSML|parameter name="code"></DSML|tool_calls> sao cụm này lại đc trả là 1 message'; + const input = '<|DSML|tool_calls><|DSML|invoke name="execute_code"><|DSML|parameter name="code"></DSML|tool_calls> sao cụm này lại đc trả là 1 message'; const events = runSieve([input], ['execute_code']); const text = collectText(events); const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); @@ -614,7 +614,7 @@ test('sieve emits tool_calls for fullwidth closing slash and preserves suffix te }); test('sieve emits tool_calls for sentencepiece separator and fullwidth terminator', () => { - const input = '<|DSML▁tool_calls|><|DSML▁invoke▁name="execute_code"><|DSML▁parameter▁name="code"><|DSML▁invoke▁name="execute_code"><|DSML▁parameter▁name="code"> evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); @@ -625,7 +625,7 @@ test('sieve emits tool_calls for sentencepiece separator and fullwidth terminato }); test('sieve emits tool_calls for fullwidth opening delimiter and Unicode attribute confusables', () => { - const input = '<|DSML tool_calls><|DSML invoke name=“execute_code”><|DSML parameter name=“code”></DSML|parameter></DSML|invoke></DSML|tool_calls> suffix'; + const input = '<|DSML tool_calls><|DSML invoke name=“execute_code”><|DSML parameter name=“code”></DSML|parameter></DSML|invoke></DSML|tool_calls> suffix'; const events = runSieve([input], ['execute_code']); const text = collectText(events); const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); @@ -718,12 +718,12 @@ test('sieve emits tool_calls for arbitrary-prefixed tool tags', () => { test('sieve emits tool_calls for CJK-angle DSM drift', () => { const events = runSieve([ - '\n', - '\n', - '〈![CDATA[Check tracking branch status]]〉〈/DSM|parameter〉\n', - '〈![CDATA[git status -b --short]]〉〈/DSM|parameter〉\n', - '〈/DSM|invoke〉\n', - '〈/DSM|tool_calls〉', + '\n', + '\n', + '〈![CDATA[Check tracking branch status]]〉〈/DSM|parameter〉\n', + '〈![CDATA[git status -b --short]]〉〈/DSM|parameter〉\n', + '〈/DSM|invoke〉\n', + '〈/DSM|tool_calls〉', ], ['Bash']); const finalCalls = events.flatMap((evt) => (evt.type === 'tool_calls' ? evt.calls : [])); assert.equal(finalCalls.length, 1); @@ -770,13 +770,13 @@ test('sieve emits tool_calls for ideographic-comma DSML drift', () => { test('sieve emits all-empty arbitrary-prefixed tool tags without leaking text', () => { const payload = [ - '\n', - ' \n', - ' \n', - ' \n', - ' \n', - ' \n', - '', + '\n', + ' \n', + ' \n', + ' \n', + ' \n', + ' \n', + '', ].join(''); for (const chunks of [[payload], payload.match(/.{1,8}/gs)]) { const events = runSieve(chunks, ['TaskOutput']); @@ -859,14 +859,14 @@ test('sieve preserves review body with alias mentions before real DSML tool call const events = runSieve([ "Done reviewing the diff. Here's my analysis before we commit:\n\n", 'Summary of Changes\n', - 'DSML wrapper variant support — recognize aliases (, <|tool_calls>, <|tool_calls>) alongside canonical and <|DSML|tool_calls> wrappers.\n\n', + 'DSML wrapper variant support — recognize aliases (, <|tool_calls>) alongside canonical and <|DSML|tool_calls> wrappers.\n\n', '<|DSML|tool_calls>\n', '<|DSML|invoke name="Bash">\n', '<|DSML|parameter name="command">\n', '<|DSML|parameter name="description">\n', '\n', '<|DSML|invoke name="Bash">\n', - '<|DSML|parameter name="command">, <|tool_calls>, <|tool_calls> alongside existing canonical wrappers.\nEOF\n)"]]>\n', + '<|DSML|parameter name="command"> and <|tool_calls> alongside existing canonical wrappers.\nEOF\n)"]]>\n', '<|DSML|parameter name="description">\n', '\n', '', @@ -993,7 +993,7 @@ test('sieve emits tool_calls when DSML tag spans multiple chunks', () => { test('sieve emits tool_calls when fullwidth DSML prefix variant spans multiple chunks', () => { const events = runSieve( [ - '<|DSML|tool', + '<|DSML|tool', '_calls>\n', '<|DSML|invoke name="Bash">\n', '<|DSML|parameter name="command">\n', diff --git a/tests/raw_stream_samples/continue-thinking-snapshot-replay-20260405/meta.json b/tests/raw_stream_samples/continue-thinking-snapshot-replay-20260405/meta.json index 02d9cd4..40f7abc 100644 --- a/tests/raw_stream_samples/continue-thinking-snapshot-replay-20260405/meta.json +++ b/tests/raw_stream_samples/continue-thinking-snapshot-replay-20260405/meta.json @@ -5,7 +5,7 @@ "request": { "chat_session_id": "0a3c904d-5761-4cf0-ae51-9b41c1c78f1e", "parent_message_id": null, - "prompt": "<|System|>\n**Memories**\nThese are memories stored via the memory_tool that you can reference in future conversations.\n[]\n\n\n**Recent Chats**\nThese are some of the user's recent conversations. You can use them to understand user preferences:\n[\n {\n \"title\": \"\",\n \"last_chat\": \"2026年4月6日\"\n },\n {\n \"title\": \"\",\n \"last_chat\": \"2026年4月6日\"\n },\n {\n \"title\": \"江青判刑原因\",\n \"last_chat\": \"2026年4月5日\"\n },\n {\n \"title\": \"GitHub個人檔案\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"DS2API架構圖\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"Markdown範例\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"廣州天氣概況\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"Xbox手把SVG\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"清除记忆\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"SVG與安卓XML示例\",\n \"last_chat\": \"2026年4月4日\"\n }\n]\n\n\n\n\n\n\n\n\n\n\nYou have access to these tools:\n\nTool: memory_tool\nDescription: The memory tool stores long-term information across conversations.\nUse `action` to control the operation: `create` (add), `edit` (update), `delete` (remove).\n- No relevant record: `create` + `content`\n- Existing relevant record: `edit` + `id` + `content`\n- Outdated/irrelevant record: `delete` + `id`\nMemories will automatically appear in the tag in later conversations.\nDo not store sensitive information (e.g., ethnicity, religion, sexual orientation, political views, sex life, criminal records).\nYou may store: preferred name, preferences, plans, work-related notes, chat style preferences, first chat time, etc.\nDo not show memory content directly in the conversation unless the user explicitly asks.\nToday is 2026年4月6日.\nSimilar memories should be merged; prefer updating existing records.\n\nExamples:\n{\"action\":\"create\",\"content\":\"User prefers brief replies and is more active on weekends.\"}\n{\"action\":\"edit\",\"id\":12,\"content\":\"User’s preferred name updated to “A-Xing”, prefers Chinese replies.\"}\n{\"action\":\"delete\",\"id\":7}\nParameters: {\"properties\":{\"action\":{\"description\":\"Operation to perform: create, edit, or delete\",\"enum\":[\"create\",\"edit\",\"delete\"],\"type\":\"string\"},\"content\":{\"description\":\"The content of the memory record (required for create/edit)\",\"type\":\"string\"},\"id\":{\"description\":\"The id of the memory record (required for edit/delete)\",\"type\":\"integer\"}},\"required\":[\"action\"],\"type\":\"object\"}\n\nTool: search_web\nDescription: Search the web for up-to-date or specific information.\nUse this when the user asks for the latest news, current facts, or needs verification.\nGenerate focused keywords and run multiple searches if needed.\nToday is 2026年4月6日.\n\nResponse format:\n- items[].id (short id), title, url, text\n\nCitations:\n- After using results, add `[citation,domain](id)` after the sentence.\n- Multiple citations are allowed.\n- If no results are cited, omit citations.\n\nExample:\nThe capital of France is Paris. [citation,example.com](abc123)\nThe population is about 2.1 million. [citation,example.com](abc123) [citation,example2.com](def456)\nParameters: {\"properties\":{\"query\":{\"description\":\"search keyword\",\"type\":\"string\"},\"topic\":{\"description\":\"search topic (one of `general`, `news`, `finance`)\",\"enum\":[\"general\",\"news\",\"finance\"],\"type\":\"string\"}},\"required\":[\"query\"],\"type\":\"object\"}\n\nTool: scrape_web\nDescription: Scrape a URL for detailed page content.\nUse this when the user requests content from a specific page or when search snippets are insufficient.\nAvoid using it for common questions unless the user asks.\nParameters: {\"properties\":{\"url\":{\"description\":\"url to scrape\",\"type\":\"string\"}},\"required\":[\"url\"],\"type\":\"object\"}\n\nTool: eval_javascript\nDescription: Execute JavaScript code using QuickJS engine (ES2020). The result is the value of the last expression in the code. For calculations with decimals, use toFixed() to control precision. Console output (log/info/warn/error) is captured and returned in 'logs' field. No DOM or Node.js APIs available. Example: '1 + 2' returns 3; 'const x = 5; x * 2' returns 10.\nParameters: {\"properties\":{\"code\":{\"description\":\"The JavaScript code to execute\",\"type\":\"string\"}},\"required\":[\"code\"],\"type\":\"object\"}\n\nTool: get_time_info\nDescription: Get the current local date and time info from the device. Returns year/month/day, weekday, ISO date/time strings, timezone, and timestamp.\nParameters: {\"properties\":{},\"type\":\"object\"}\n\nTool: clipboard_tool\nDescription: Read or write plain text from the device clipboard. Use action: read or write. For write, provide text. Do NOT write to the clipboard unless the user has explicitly requested it.\nParameters: {\"properties\":{\"action\":{\"description\":\"Operation to perform: read or write\",\"enum\":[\"read\",\"write\"],\"type\":\"string\"},\"text\":{\"description\":\"Text to write to the clipboard (required for write)\",\"type\":\"string\"}},\"required\":[\"action\"],\"type\":\"object\"}\n\nTool: text_to_speech\nDescription: Speak text aloud to the user using the device's text-to-speech engine. Use this when the user asks you to read something aloud, or when audio output is appropriate. The tool returns immediately; audio plays in the background on the device. Provide natural, readable text without markdown formatting.\nParameters: {\"properties\":{\"text\":{\"description\":\"The text to speak aloud\",\"type\":\"string\"}},\"required\":[\"text\"],\"type\":\"object\"}\n\nTool: ask_user\nDescription: Ask the user one or more questions when you need clarification, additional information, or confirmation. Each question can optionally provide a list of suggested options for the user to choose from. The user may select an option or provide their own free-text answer for each question. The answers will be returned as a JSON object mapping question IDs to the user's responses.\nParameters: {\"properties\":{\"questions\":{\"description\":\"List of questions to ask the user\",\"items\":{\"properties\":{\"id\":{\"description\":\"Unique identifier for this question\",\"type\":\"string\"},\"options\":{\"description\":\"Optional list of suggested options for the user to choose from\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"question\":{\"description\":\"The question text to display to the user\",\"type\":\"string\"},\"selection_type\":{\"description\":\"Answer type: text (free text input, default), single (select exactly one option), multi (select one or more options)\",\"enum\":[\"text\",\"single\",\"multi\"],\"type\":\"string\"}},\"required\":[\"id\",\"question\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"questions\"],\"type\":\"object\"}\n\nTOOL CALL FORMAT — FOLLOW EXACTLY:\n\n\n \n \n \n\n\nRULES:\n1) Use the XML wrapper format only.\n2) Put one or more entries under a single root.\n3) Use for the tool name and for each argument.\n4) All string values should use when they may contain code, markup, JSON, paths, prompts, or other special characters.\n5) Objects use nested XML inside a ; arrays may repeat children.\n6) Numbers, booleans, and null stay plain text.\n7) Use only the parameter names in the tool schema. Do not invent fields.\n8) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue.\n\nPARAMETER SHAPES:\n- string => \n- object => ...\n- array => ...\n- number/bool/null => plain text\n\n【WRONG — Do NOT do these】:\n\nWrong 1 — mixed text after XML:\n ... I hope this helps.\nWrong 2 — old canonical tags or raw payloads:\n read_file{\"path\":\"x\"}\nWrong 3 — Markdown code fences:\n ```xml\n ...\n ```\n\nRemember: The ONLY valid way to use tools is the ... XML block at the end of your response.\n\n【CORRECT EXAMPLES】:\n\nExample A — Single tool:\n\n \n \n \n\n\nExample B — Two tools in parallel:\n\n \n \n \n \n \n \n \n\n\nExample C — Tool with nested XML parameters:\n\n \n \n \n \n\n<|end▁of▁instructions|>\n\n<|User|>\n<|User|>\n在一个类似2022×2022的花园的每个方格中,最初都有一个高度为0的树,园丁和伐木工交替进行以下游戏,园丁首先开始:园丁选择花园中的一个方格,该方格上的每棵树以及周围至多八个方格中的所有树都会增长一单位,伐木工随后选择板上的四个不同方格,这些方格上正高的树都会减少一单位,称一棵树为雄伟的,如果其高度至少为10的六次方.确定园丁能够确保板上最终有K棵雄伟的树,无论伐木工如何操作,求最大的K<|end▁of▁sentence|><|end▁of▁sentence|>", + "prompt": "<|System|>\n**Memories**\nThese are memories stored via the memory_tool that you can reference in future conversations.\n[]\n\n\n**Recent Chats**\nThese are some of the user's recent conversations. You can use them to understand user preferences:\n[\n {\n \"title\": \"\",\n \"last_chat\": \"2026年4月6日\"\n },\n {\n \"title\": \"\",\n \"last_chat\": \"2026年4月6日\"\n },\n {\n \"title\": \"江青判刑原因\",\n \"last_chat\": \"2026年4月5日\"\n },\n {\n \"title\": \"GitHub個人檔案\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"DS2API架構圖\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"Markdown範例\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"廣州天氣概況\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"Xbox手把SVG\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"清除记忆\",\n \"last_chat\": \"2026年4月4日\"\n },\n {\n \"title\": \"SVG與安卓XML示例\",\n \"last_chat\": \"2026年4月4日\"\n }\n]\n\n\n\n\n\n\n\n\n\n\nYou have access to these tools:\n\nTool: memory_tool\nDescription: The memory tool stores long-term information across conversations.\nUse `action` to control the operation: `create` (add), `edit` (update), `delete` (remove).\n- No relevant record: `create` + `content`\n- Existing relevant record: `edit` + `id` + `content`\n- Outdated/irrelevant record: `delete` + `id`\nMemories will automatically appear in the tag in later conversations.\nDo not store sensitive information (e.g., ethnicity, religion, sexual orientation, political views, sex life, criminal records).\nYou may store: preferred name, preferences, plans, work-related notes, chat style preferences, first chat time, etc.\nDo not show memory content directly in the conversation unless the user explicitly asks.\nToday is 2026年4月6日.\nSimilar memories should be merged; prefer updating existing records.\n\nExamples:\n{\"action\":\"create\",\"content\":\"User prefers brief replies and is more active on weekends.\"}\n{\"action\":\"edit\",\"id\":12,\"content\":\"User’s preferred name updated to “A-Xing”, prefers Chinese replies.\"}\n{\"action\":\"delete\",\"id\":7}\nParameters: {\"properties\":{\"action\":{\"description\":\"Operation to perform: create, edit, or delete\",\"enum\":[\"create\",\"edit\",\"delete\"],\"type\":\"string\"},\"content\":{\"description\":\"The content of the memory record (required for create/edit)\",\"type\":\"string\"},\"id\":{\"description\":\"The id of the memory record (required for edit/delete)\",\"type\":\"integer\"}},\"required\":[\"action\"],\"type\":\"object\"}\n\nTool: search_web\nDescription: Search the web for up-to-date or specific information.\nUse this when the user asks for the latest news, current facts, or needs verification.\nGenerate focused keywords and run multiple searches if needed.\nToday is 2026年4月6日.\n\nResponse format:\n- items[].id (short id), title, url, text\n\nCitations:\n- After using results, add `[citation,domain](id)` after the sentence.\n- Multiple citations are allowed.\n- If no results are cited, omit citations.\n\nExample:\nThe capital of France is Paris. [citation,example.com](abc123)\nThe population is about 2.1 million. [citation,example.com](abc123) [citation,example2.com](def456)\nParameters: {\"properties\":{\"query\":{\"description\":\"search keyword\",\"type\":\"string\"},\"topic\":{\"description\":\"search topic (one of `general`, `news`, `finance`)\",\"enum\":[\"general\",\"news\",\"finance\"],\"type\":\"string\"}},\"required\":[\"query\"],\"type\":\"object\"}\n\nTool: scrape_web\nDescription: Scrape a URL for detailed page content.\nUse this when the user requests content from a specific page or when search snippets are insufficient.\nAvoid using it for common questions unless the user asks.\nParameters: {\"properties\":{\"url\":{\"description\":\"url to scrape\",\"type\":\"string\"}},\"required\":[\"url\"],\"type\":\"object\"}\n\nTool: eval_javascript\nDescription: Execute JavaScript code using QuickJS engine (ES2020). The result is the value of the last expression in the code. For calculations with decimals, use toFixed() to control precision. Console output (log/info/warn/error) is captured and returned in 'logs' field. No DOM or Node.js APIs available. Example: '1 + 2' returns 3; 'const x = 5; x * 2' returns 10.\nParameters: {\"properties\":{\"code\":{\"description\":\"The JavaScript code to execute\",\"type\":\"string\"}},\"required\":[\"code\"],\"type\":\"object\"}\n\nTool: get_time_info\nDescription: Get the current local date and time info from the device. Returns year/month/day, weekday, ISO date/time strings, timezone, and timestamp.\nParameters: {\"properties\":{},\"type\":\"object\"}\n\nTool: clipboard_tool\nDescription: Read or write plain text from the device clipboard. Use action: read or write. For write, provide text. Do NOT write to the clipboard unless the user has explicitly requested it.\nParameters: {\"properties\":{\"action\":{\"description\":\"Operation to perform: read or write\",\"enum\":[\"read\",\"write\"],\"type\":\"string\"},\"text\":{\"description\":\"Text to write to the clipboard (required for write)\",\"type\":\"string\"}},\"required\":[\"action\"],\"type\":\"object\"}\n\nTool: text_to_speech\nDescription: Speak text aloud to the user using the device's text-to-speech engine. Use this when the user asks you to read something aloud, or when audio output is appropriate. The tool returns immediately; audio plays in the background on the device. Provide natural, readable text without markdown formatting.\nParameters: {\"properties\":{\"text\":{\"description\":\"The text to speak aloud\",\"type\":\"string\"}},\"required\":[\"text\"],\"type\":\"object\"}\n\nTool: ask_user\nDescription: Ask the user one or more questions when you need clarification, additional information, or confirmation. Each question can optionally provide a list of suggested options for the user to choose from. The user may select an option or provide their own free-text answer for each question. The answers will be returned as a JSON object mapping question IDs to the user's responses.\nParameters: {\"properties\":{\"questions\":{\"description\":\"List of questions to ask the user\",\"items\":{\"properties\":{\"id\":{\"description\":\"Unique identifier for this question\",\"type\":\"string\"},\"options\":{\"description\":\"Optional list of suggested options for the user to choose from\",\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"question\":{\"description\":\"The question text to display to the user\",\"type\":\"string\"},\"selection_type\":{\"description\":\"Answer type: text (free text input, default), single (select exactly one option), multi (select one or more options)\",\"enum\":[\"text\",\"single\",\"multi\"],\"type\":\"string\"}},\"required\":[\"id\",\"question\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"questions\"],\"type\":\"object\"}\n\nTOOL CALL FORMAT — FOLLOW EXACTLY:\n\n\n \n \n \n\n\nRULES:\n1) Use the XML wrapper format only.\n2) Put one or more entries under a single root.\n3) Use for the tool name and for each argument.\n4) All string values should use when they may contain code, markup, JSON, paths, prompts, or other special characters.\n5) Objects use nested XML inside a ; arrays may repeat children.\n6) Numbers, booleans, and null stay plain text.\n7) Use only the parameter names in the tool schema. Do not invent fields.\n8) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue.\n\nPARAMETER SHAPES:\n- string => \n- object => ...\n- array => ...\n- number/bool/null => plain text\n\n【WRONG — Do NOT do these】:\n\nWrong 1 — mixed text after XML:\n ... I hope this helps.\nWrong 2 — old canonical tags or raw payloads:\n read_file{\"path\":\"x\"}\nWrong 3 — Markdown code fences:\n ```xml\n ...\n ```\n\nRemember: The ONLY valid way to use tools is the ... XML block at the end of your response.\n\n【CORRECT EXAMPLES】:\n\nExample A — Single tool:\n\n \n \n \n\n\nExample B — Two tools in parallel:\n\n \n \n \n \n \n \n \n\n\nExample C — Tool with nested XML parameters:\n\n \n \n \n \n\n<|end▁of▁instructions|>\n\n<|User|>\n<|User|>\n在一个类似2022×2022的花园的每个方格中,最初都有一个高度为0的树,园丁和伐木工交替进行以下游戏,园丁首先开始:园丁选择花园中的一个方格,该方格上的每棵树以及周围至多八个方格中的所有树都会增长一单位,伐木工随后选择板上的四个不同方格,这些方格上正高的树都会减少一单位,称一棵树为雄伟的,如果其高度至少为10的六次方.确定园丁能够确保板上最终有K棵雄伟的树,无论伐木工如何操作,求最大的K<|end▁of▁sentence|><|end▁of▁sentence|>", "ref_file_ids": [], "search_enabled": false, "thinking_enabled": true diff --git a/webui/src/features/chatHistory/chatHistoryUtils.js b/webui/src/features/chatHistory/chatHistoryUtils.js index 6359c39..0c10060 100644 --- a/webui/src/features/chatHistory/chatHistoryUtils.js +++ b/webui/src/features/chatHistory/chatHistoryUtils.js @@ -3,14 +3,14 @@ export const DISABLED_LIMIT = 0 export const MESSAGE_COLLAPSE_AT = 700 export const VIEW_MODE_KEY = 'ds2api_chat_history_view_mode' -const BEGIN_SENTENCE_MARKER = '<|begin▁of▁sentence|>' -const SYSTEM_MARKER = '<|System|>' -const USER_MARKER = '<|User|>' -const ASSISTANT_MARKER = '<|Assistant|>' -const TOOL_MARKER = '<|Tool|>' -const END_INSTRUCTIONS_MARKER = '<|end▁of▁instructions|>' -const END_SENTENCE_MARKER = '<|end▁of▁sentence|>' -const END_TOOL_RESULTS_MARKER = '<|end▁of▁toolresults|>' +const BEGIN_SENTENCE_MARKER = '<|begin▁of▁sentence|>' +const SYSTEM_MARKER = '<|System|>' +const USER_MARKER = '<|User|>' +const ASSISTANT_MARKER = '<|Assistant|>' +const TOOL_MARKER = '<|Tool|>' +const END_INSTRUCTIONS_MARKER = '<|end▁of▁instructions|>' +const END_SENTENCE_MARKER = '<|end▁of▁sentence|>' +const END_TOOL_RESULTS_MARKER = '<|end▁of▁toolresults|>' const CURRENT_INPUT_FILE_PROMPT = 'Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly.' const LEGACY_CURRENT_INPUT_FILE_PROMPTS = new Set([ 'The current request and prior conversation context have already been provided. Answer the latest user request directly.',