feat: implement support for arbitrary tool markup prefixes and control character separators in tool sieve parsing

This commit is contained in:
CJACK
2026-05-10 00:19:03 +08:00
parent 7c66742a19
commit 3cc7f469f3
7 changed files with 335 additions and 9 deletions

4
API.md
View File

@@ -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>``<|tool_calls>``<tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo`<DSMLtool_calls>`),以及旧式 canonical XML `<tool_calls>``<invoke name="...">``<parameter name="...">`。实现上采用窄容错结构扫描:只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 会进入工具路径,裸 `<invoke>` 不计为已支持语法;流式场景继续执行防泄漏筛分。若参数体本身是合法 JSON 字面量(如 `123``true``null`、数组或对象),会按结构化值输出,不再一律当作字符串;若 CDATA 偶发漏闭合,则会在最终 parse / flush 恢复阶段做窄修复,尽量保住已完整包裹的外层工具调用。
- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:推荐模型输出 DSML 外壳 `<|DSML|tool_calls>``<|DSML|invoke name="...">``<|DSML|parameter name="...">`;兼容层也接受 DSML wrapper 别名 `<dsml|tool_calls>``<|tool_calls>``<tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo`<DSMLtool_calls>`、控制分隔符漂移(如 `<DSML␂tool_calls>` / 原始 STX `\x02`)、任意协议前缀壳(如 `<proto💥tool_calls>`,以及旧式 canonical XML `<tool_calls>``<invoke name="...">``<parameter name="...">`。实现上采用结构扫描:只要固定本地标签名是 `tool_calls` / `invoke` / `parameter`,前缀壳会在解析入口归一化;只`tool_calls` wrapper 或可修复的缺失 opening wrapper 会进入工具路径,裸 `<invoke>` 不计为已支持语法;流式场景继续执行防泄漏筛分。若参数体本身是合法 JSON 字面量(如 `123``true``null`、数组或对象),会按结构化值输出,不再一律当作字符串;若 CDATA 偶发漏闭合,则会在最终 parse / flush 恢复阶段做窄修复,尽量保住已完整包裹的外层工具调用。
- `Admin API` 将配置与运行时策略分开:`/admin/config*` 管静态配置,`/admin/settings*` 管运行时行为。
- 当上游返回 thinking-only 响应(模型输出了推理链但无可见文本)时,非流式补全会自动重试一次:以多轮对话 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 次。
- 引用标记处理边界:流式输出默认隐藏 `[citation:N]` / `[reference:N]` 这类上游内部占位符;非流式输出默认把 DeepSeek 搜索引用标记转换为 Markdown 引用链接。
@@ -358,7 +358,7 @@ data: [DONE]
补充说明:
- **非代码块上下文**下,工具负载即使与普通文本混合,也会按特征识别并产出可执行 tool call前后普通文本仍可透传
- 解析器当前把 DSML 外壳(`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`、DSML wrapper 别名(`<dsml|tool_calls>``<|tool_calls>``<tool_calls>`)、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`)、`DSML` 与工具标签名黏连的常见 typo`<DSMLtool_calls>` / `<DSMLinvoke>` / `<DSMLparameter>`)和旧式 canonical XML 工具块(`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`)作为可执行调用解析;DSML 会先归一化回 XML内部仍以 XML 解析语义为准。旧式 `<tools>``<tool_call>``<tool_name>``<param>``<function_call>``tool_use`、antml 风格与纯 JSON `tool_calls` 片段默认都会按普通文本处理。
- 解析器当前把 DSML 外壳(`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`、DSML wrapper 别名(`<dsml|tool_calls>``<|tool_calls>``<tool_calls>`)、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`)、`DSML` 与工具标签名黏连的常见 typo`<DSMLtool_calls>` / `<DSMLinvoke>` / `<DSMLparameter>`、控制分隔符漂移(如 `<DSML␂tool_calls>` / 原始 STX `\x02`)、任意协议前缀壳(如 `<proto💥tool_calls>`和旧式 canonical XML 工具块(`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`)作为可执行调用解析;这些前缀壳会先归一化回 XML内部仍以 XML 解析语义为准。旧式 `<tools>``<tool_call>``<tool_name>``<param>``<function_call>``tool_use`、antml 风格与纯 JSON `tool_calls` 片段默认都会按普通文本处理。
- 当最终可见正文为空但思维链里包含可执行工具调用时Chat / Responses 会在收尾阶段补发标准 OpenAI `tool_calls` / `function_call` 输出;如果客户端未开启 thinking / reasoning该思维链只用于检测不会作为可见正文或 `reasoning_content` 暴露。
- Markdown fenced code block例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。

View File

@@ -168,7 +168,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认
4. 把这整段内容并入 system prompt。
工具调用正例现在优先示范官方 DSML 风格:`<|DSML|tool_calls>``<|DSML|invoke name="...">``<|DSML|parameter name="...">`
兼容层仍接受旧式纯 `<tool_calls>` wrapper并会容错若干 DSML 标签变体,包括短横线形式 `<dsml-tool-calls>` / `<dsml-invoke>` / `<dsml-parameter>`、下划线形式 `<dsml_tool_calls>` / `<dsml_invoke>` / `<dsml_parameter>`,以及其他前缀分隔形态如 `<vendor|tool_calls>` / `<vendor_tool_calls>` / `<vendor - tool_calls>`;标签壳扫描还会把全角 ASCII 漂移归一化,例如 `<tool_calls>` 与全角 `` 结束符。但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;这些别名会在解析入口归一化回现有 XML 标签后继续走同一套 parser。解析器会先截获非代码块中的疑似工具 wrapper完整解析失败或工具语义无效时再按普通文本放行。
兼容层仍接受旧式纯 `<tool_calls>` wrapper并会容错若干 DSML 标签变体,包括短横线形式 `<dsml-tool-calls>` / `<dsml-invoke>` / `<dsml-parameter>`、下划线形式 `<dsml_tool_calls>` / `<dsml_invoke>` / `<dsml_parameter>`,以及其他前缀分隔形态如 `<vendor|tool_calls>` / `<vendor_tool_calls>` / `<vendor - tool_calls>`;标签壳扫描还会把全角 ASCII 漂移归一化,例如 `<tool_calls>` 与全角 `` 结束符。更一般地Go / Node tag 扫描以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,标签名前任意协议前缀壳都会在解析入口剥离,例如 `<DSML␂tool_calls>``<proto💥tool_calls>` 这类控制符或非 ASCII 分隔符漂移也会归一化回现有 XML 标签后继续走同一套 parser。但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现。解析器会先截获非代码块中的疑似工具 wrapper完整解析失败或工具语义无效时再按普通文本放行。
数组参数使用 `<item>...</item>` 子节点表示;当某个参数体只包含 item 子节点时Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。除此之外,解析器还会回收一些更松散的列表写法,例如 JSON array 字面量或逗号分隔的 JSON 项序列,只要它们足够明确;但 `<item>` 仍然是首选形态。若模型把完整结构化 XML fragment 误包进 CDATA兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `<b>urgent</b>` 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 `item` 列表,才会触发结构化恢复。对 `command` / `content` 等长文本参数CDATA 内部的 Markdown fenced DSML / XML 示例会作为原文保护;示例里的 `]]></parameter>``</tool_calls>` 不会截断外层工具调用,解析器会继续等待围栏外真正的参数 / 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` 等)。

View File

@@ -39,7 +39,7 @@
兼容修复:
- 如果模型漏掉 opening wrapper但后面仍输出了一个或多个 invoke 并以 closing wrapper 收尾Go 解析链路会在解析前补回缺失的 opening wrapper。
- Go / Node 解析层不再枚举每一种 DSML typo。它会把工具标签名前的 `DSML`、管道符 `|` / ``、空白、重复 leading `<` 视为可容忍的协议噪声,然后只匹配固定本地标签名 `tool_calls` / `invoke` / `parameter`。例如 `<DSML|tool_calls>``<<|DSML|tool_calls>``<|DSML tool_calls>``<DSMLtool_calls>``<<DSML|DSML|tool_calls>` 都会归一化;相似但非固定标签名(如 `tool_calls_extra`)仍按普通文本处理。
- Go / Node 解析层不再枚举每一种 DSML typo。它以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,把标签名前的任意协议前缀壳视为可容忍噪声,并继续兼容管道符 `|` / ``、空白、重复 leading `<`、可视控制符 `␂`、原始 STX `\x02`、非 ASCII 分隔符等漂移。例如 `<DSML|tool_calls>``<<|DSML|tool_calls>``<|DSML tool_calls>``<DSMLtool_calls>``<<DSML|DSML|tool_calls>``<DSML␂tool_calls>``<proto💥tool_calls>` 都会归一化;相似但非固定标签名(如 `tool_calls_extra`)仍按普通文本处理。
- 如果模型在固定工具标签名后多输出一个尾部管道符,例如 `<|DSML|tool_calls|` / `<|DSML|invoke|` / `<|DSML|parameter|`,兼容层会把这个尾部 `|` 当作异常标签终止符并补齐缺失的 `>`;如果后面已经有 `>`,也会消费这个多余 `|` 后再归一化。
- 这是一个针对常见模型失误的窄修复不改变推荐输出格式prompt 仍要求模型直接输出完整 DSML 外壳。
-`<invoke ...>` / `<parameter ...>` 不会被当成“已支持的工具语法”;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 才会进入工具调用路径。

View File

@@ -458,11 +458,19 @@ function scanToolMarkupTagAt(text, start) {
i += 1;
}
const prefix = consumeToolMarkupNamePrefix(raw, lower, i);
const prefixStart = i;
i = prefix.next;
const dsmlLike = prefix.dsmlLike;
const { name, len } = matchToolMarkupName(raw, i, dsmlLike);
let dsmlLike = prefix.dsmlLike;
let { name, len } = matchToolMarkupName(raw, i, dsmlLike);
if (!name) {
return null;
const fallback = matchToolMarkupNameAfterArbitraryPrefix(raw, prefixStart);
if (!fallback.ok) {
return null;
}
name = fallback.name;
i = fallback.start;
len = fallback.len;
dsmlLike = true;
}
const originalNameEnd = i + len;
let nameEnd = originalNameEnd;
@@ -574,7 +582,7 @@ function includeDuplicateLeadingLessThan(text, idx) {
}
function isToolMarkupPipe(ch) {
return ch === '|' || ch === '';
return ch === '|' || ch === '' || ch === '␂' || ch === '\x02';
}
function isPartialToolMarkupTagPrefix(text) {
@@ -603,6 +611,9 @@ function isPartialToolMarkupTagPrefix(text) {
if (normalizedASCIITailAt(raw, i).startsWith('dsml') || 'dsml'.startsWith(normalizedASCIITailAt(raw, i))) {
return true;
}
if (hasPartialToolMarkupNameAfterArbitraryPrefix(raw, i)) {
return true;
}
const next = consumeToolMarkupNamePrefixOnce(raw, lower, i);
if (!next.ok) {
return false;
@@ -625,6 +636,53 @@ function consumeToolMarkupNamePrefix(raw, lower, idx) {
}
}
function matchToolMarkupNameAfterArbitraryPrefix(raw, start) {
for (let idx = start; idx < raw.length;) {
if (isToolMarkupTagTerminator(raw, idx)) {
return { ok: false };
}
for (const name of TOOL_MARKUP_NAMES) {
const matched = matchNormalizedASCII(raw, idx, name.raw);
if (!matched.ok) continue;
if (!toolMarkupPrefixAllowsLocalName(raw.slice(start, idx))) continue;
return { ok: true, name: name.canonical, start: idx, len: matched.len };
}
idx += 1;
}
return { ok: false };
}
function hasPartialToolMarkupNameAfterArbitraryPrefix(raw, start) {
for (let idx = start; idx < raw.length;) {
if (isToolMarkupTagTerminator(raw, idx)) {
return false;
}
if (toolMarkupPrefixAllowsLocalName(raw.slice(start, idx)) && hasToolMarkupNamePrefix(raw, idx)) {
return true;
}
idx += 1;
}
return false;
}
function toolMarkupPrefixAllowsLocalName(prefix) {
if (!prefix) {
return false;
}
if (normalizedASCIITailAt(prefix, 0).includes('dsml')) {
return true;
}
if (/[="'"]/.test(prefix)) {
return false;
}
const previous = normalizeFullwidthASCIIChar(prefix[prefix.length - 1] || '');
return !/^[A-Za-z0-9]$/.test(previous);
}
function isToolMarkupTagTerminator(raw, idx) {
return raw[idx] === '>' || normalizeFullwidthASCIIChar(raw[idx] || '') === '>';
}
function consumeToolMarkupNamePrefixOnce(raw, lower, idx) {
if (idx < raw.length && isToolMarkupPipe(raw[idx])) {
return { next: idx + 1, ok: true };

View File

@@ -146,10 +146,18 @@ func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) {
closing = true
i++
}
prefixStart := i
i, dsmlLike := consumeToolMarkupNamePrefix(text, i)
name, nameLen := matchToolMarkupName(text, i, dsmlLike)
if nameLen == 0 {
return ToolMarkupTag{}, false
fallbackName, fallbackStart, fallbackLen, ok := matchToolMarkupNameAfterArbitraryPrefix(text, prefixStart)
if !ok {
return ToolMarkupTag{}, false
}
name = fallbackName
i = fallbackStart
nameLen = fallbackLen
dsmlLike = true
}
nameEnd := i + nameLen
nameEndBeforePipes := nameEnd
@@ -210,6 +218,9 @@ func IsPartialToolMarkupTagPrefix(text string) bool {
if hasASCIIPartialPrefixFoldAt(text, i, "dsml") {
return true
}
if hasPartialToolMarkupNameAfterArbitraryPrefix(text, i) {
return true
}
next, ok := consumeToolMarkupNamePrefixOnce(text, i)
if !ok {
return false
@@ -340,6 +351,88 @@ func matchToolMarkupName(text string, start int, dsmlLike bool) (string, int) {
return "", 0
}
func matchToolMarkupNameAfterArbitraryPrefix(text string, start int) (string, int, int, bool) {
for idx := start; idx < len(text); {
if isToolMarkupTagTerminator(text, idx) {
return "", 0, 0, false
}
for _, name := range toolMarkupNames {
nameLen, ok := matchASCIIPrefixFoldAt(text, idx, name.raw)
if !ok {
continue
}
if !toolMarkupPrefixAllowsLocalName(text[start:idx]) {
continue
}
return name.canonical, idx, nameLen, true
}
_, size := utf8.DecodeRuneInString(text[idx:])
if size <= 0 {
size = 1
}
idx += size
}
return "", 0, 0, false
}
func hasPartialToolMarkupNameAfterArbitraryPrefix(text string, start int) bool {
for idx := start; idx < len(text); {
if isToolMarkupTagTerminator(text, idx) {
return false
}
if toolMarkupPrefixAllowsLocalName(text[start:idx]) && hasToolMarkupNamePrefix(text, idx) {
return true
}
_, size := utf8.DecodeRuneInString(text[idx:])
if size <= 0 {
size = 1
}
idx += size
}
return false
}
func toolMarkupPrefixAllowsLocalName(prefix string) bool {
if prefix == "" {
return false
}
if strings.Contains(normalizedASCIILowerString(prefix), "dsml") {
return true
}
if strings.ContainsAny(prefix, "=\"'") {
return false
}
r, _ := utf8.DecodeLastRuneInString(prefix)
r = normalizeFullwidthASCII(r)
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9')
}
func normalizedASCIILowerString(text string) string {
var b strings.Builder
b.Grow(len(text))
for _, r := range text {
r = normalizeFullwidthASCII(r)
if r >= 'A' && r <= 'Z' {
r += 'a' - 'A'
}
if r <= 0x7f {
b.WriteRune(r)
}
}
return b.String()
}
func isToolMarkupTagTerminator(text string, idx int) bool {
if idx >= len(text) {
return false
}
if text[idx] == '>' {
return true
}
r, _ := utf8.DecodeRuneInString(text[idx:])
return normalizeFullwidthASCII(r) == '>'
}
func consumeToolMarkupPipe(text string, idx int) (int, bool) {
if idx >= len(text) {
return idx, false
@@ -347,9 +440,15 @@ func consumeToolMarkupPipe(text string, idx int) (int, bool) {
if text[idx] == '|' {
return idx + 1, true
}
if text[idx] == '\x02' {
return idx + 1, true
}
if strings.HasPrefix(text[idx:], "") {
return idx + len(""), true
}
if strings.HasPrefix(text[idx:], "␂") {
return idx + len("␂"), true
}
return idx, false
}

View File

@@ -104,6 +104,111 @@ func TestProcessToolSieveInterceptsDSMLTrailingPipeToolCallWithoutLeak(t *testin
}
}
func TestProcessToolSieveInterceptsDSMLControlSeparatorWithoutLeak(t *testing.T) {
for _, tc := range []struct {
name string
sep string
}{
{name: "control_picture", sep: "␂"},
{name: "raw_stx", sep: "\x02"},
} {
t.Run(tc.name, func(t *testing.T) {
sep := tc.sep
var state State
chunks := []string{
"<DSML" + sep + "tool",
"_calls>\n",
` <DSML` + sep + `invoke name="Read">` + "\n",
` <DSML` + sep + `parameter name="file_path"><![CDATA[/tmp/input.txt]]></DSML` + sep + `parameter>` + "\n",
" </DSML" + sep + "invoke>\n",
"</DSML" + sep + "tool_calls>",
}
var events []Event
for _, c := range chunks {
events = append(events, ProcessChunk(&state, c, []string{"Read"})...)
}
events = append(events, Flush(&state, []string{"Read"})...)
var textContent strings.Builder
var calls []any
for _, evt := range events {
textContent.WriteString(evt.Content)
for _, call := range evt.ToolCalls {
calls = append(calls, call)
}
}
if text := textContent.String(); strings.Contains(strings.ToLower(text), "dsml") || strings.Contains(text, "Read") || strings.Contains(text, sep) {
t.Fatalf("control-separator DSML tool call leaked to text: %q events=%#v", text, events)
}
if len(calls) != 1 {
t.Fatalf("expected one control-separator DSML tool call, got %d events=%#v", len(calls), events)
}
})
}
}
func TestProcessToolSieveInterceptsArbitraryPrefixedToolTagsWithoutLeak(t *testing.T) {
var state State
chunks := []string{
"<proto💥tool",
"_calls>\n",
` <proto💥invoke name="Read">` + "\n",
` <proto💥parameter name="file_path"><![CDATA[/tmp/input.txt]]></proto💥parameter>` + "\n",
" </proto💥invoke>\n",
"</proto💥tool_calls>",
}
var events []Event
for _, c := range chunks {
events = append(events, ProcessChunk(&state, c, []string{"Read"})...)
}
events = append(events, Flush(&state, []string{"Read"})...)
var textContent strings.Builder
var calls []any
for _, evt := range events {
textContent.WriteString(evt.Content)
for _, call := range evt.ToolCalls {
calls = append(calls, call)
}
}
if text := textContent.String(); strings.Contains(text, "proto") || strings.Contains(text, "Read") || strings.Contains(text, "💥") {
t.Fatalf("arbitrary-prefixed tool call leaked to text: %q events=%#v", text, events)
}
if len(calls) != 1 {
t.Fatalf("expected one arbitrary-prefixed tool call, got %d events=%#v", len(calls), events)
}
}
func TestProcessToolSieveSuppressesEmptyDSMLControlSeparatorBlockWithoutLeak(t *testing.T) {
var state State
sep := "␂"
chunks := []string{
"<DSML" + sep + "tool_calls>\n",
` <DSML` + sep + `invoke name="Read">` + "\n",
` <DSML` + sep + `parameter name="file_path"></DSML` + sep + `parameter>` + "\n",
" </DSML" + sep + "invoke>\n",
"</DSML" + sep + "tool_calls>",
}
var events []Event
for _, c := range chunks {
events = append(events, ProcessChunk(&state, c, []string{"Read"})...)
}
events = append(events, Flush(&state, []string{"Read"})...)
var textContent strings.Builder
toolCalls := 0
for _, evt := range events {
textContent.WriteString(evt.Content)
toolCalls += len(evt.ToolCalls)
}
if toolCalls != 0 {
t.Fatalf("expected empty control-separator block not to produce calls, got %d events=%#v", toolCalls, events)
}
if text := textContent.String(); strings.Contains(strings.ToLower(text), "dsml") || strings.Contains(text, "Read") || strings.Contains(text, sep) {
t.Fatalf("expected empty control-separator block not to leak as text, got %q", text)
}
}
func TestProcessToolSieveInterceptsExtraLeadingLessThanDSMLToolCallWithoutLeak(t *testing.T) {
var state State
chunks := []string{

View File

@@ -129,6 +129,32 @@ test('parseToolCalls parses fullwidth DSML shell drift', () => {
assert.deepEqual(calls[1].input, { file_path: '/Users/aq/Desktop/myproject/Personal_Blog/index.html' });
});
test('parseToolCalls parses DSML control separator drift', () => {
for (const sep of ['␂', '\x02']) {
const payload = `<DSML${sep}tool_calls>
<DSML${sep}invoke name="Read">
<DSML${sep}parameter name="file_path"><![CDATA[/tmp/input.txt]]></DSML${sep}parameter>
</DSML${sep}invoke>
</DSML${sep}tool_calls>`;
const calls = parseToolCalls(payload, ['Read']);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'Read');
assert.deepEqual(calls[0].input, { file_path: '/tmp/input.txt' });
}
});
test('parseToolCalls parses arbitrary-prefixed tool tags', () => {
const payload = `<proto💥tool_calls>
<proto💥invoke name="Read">
<proto💥parameter name="file_path"><![CDATA[/tmp/input.txt]]></proto💥parameter>
</proto💥invoke>
</proto💥tool_calls>`;
const calls = parseToolCalls(payload, ['Read']);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'Read');
assert.deepEqual(calls[0].input, { file_path: '/tmp/input.txt' });
});
test('parseToolCalls ignores bare hyphenated tool_calls lookalike', () => {
const payload = '<tool-calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></tool-calls>';
const calls = parseToolCalls(payload, ['Bash']);
@@ -445,6 +471,44 @@ test('sieve emits tool_calls for DSML trailing pipe tag terminator', () => {
assert.equal(text.toLowerCase().includes('dsml'), false);
});
test('sieve emits tool_calls for DSML control separator drift', () => {
for (const sep of ['␂', '\x02']) {
const events = runSieve([
`<DSML${sep}tool`,
'_calls>\n',
`<DSML${sep}invoke name="Read">\n`,
`<DSML${sep}parameter name="file_path"><![CDATA[/tmp/input.txt]]></DSML${sep}parameter>\n`,
`</DSML${sep}invoke>\n`,
`</DSML${sep}tool_calls>`,
], ['Read']);
const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
assert.equal(finalCalls.length, 1);
assert.equal(finalCalls[0].name, 'Read');
assert.equal(finalCalls[0].input.file_path, '/tmp/input.txt');
const text = collectText(events);
assert.equal(text.toLowerCase().includes('dsml'), false);
assert.equal(text.includes(sep), false);
}
});
test('sieve emits tool_calls for arbitrary-prefixed tool tags', () => {
const events = runSieve([
'<proto💥tool',
'_calls>\n',
'<proto💥invoke name="Read">\n',
'<proto💥parameter name="file_path"><![CDATA[/tmp/input.txt]]></proto💥parameter>\n',
'</proto💥invoke>\n',
'</proto💥tool_calls>',
], ['Read']);
const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
assert.equal(finalCalls.length, 1);
assert.equal(finalCalls[0].name, 'Read');
assert.equal(finalCalls[0].input.file_path, '/tmp/input.txt');
const text = collectText(events);
assert.equal(text.includes('proto'), false);
assert.equal(text.includes('💥'), false);
});
test('sieve emits tool_calls for extra leading less-than DSML tags without leaking prefix', () => {
const events = runSieve([
'<<|DSML|tool_calls>\n',