From 3cc7f469f38401514cbd7fbea34f57f143e85c46 Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 10 May 2026 00:19:03 +0800 Subject: [PATCH] feat: implement support for arbitrary tool markup prefixes and control character separators in tool sieve parsing --- API.md | 4 +- docs/prompt-compatibility.md | 2 +- docs/toolcall-semantics.md | 2 +- .../stream-tool-sieve/parse_payload.js | 66 ++++++++++- internal/toolcall/toolcalls_scan.go | 101 ++++++++++++++++- internal/toolstream/tool_sieve_xml_test.go | 105 ++++++++++++++++++ tests/node/stream-tool-sieve.test.js | 64 +++++++++++ 7 files changed, 335 insertions(+), 9 deletions(-) diff --git a/API.md b/API.md index 0470fd5..86772f7 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 别名 ``、`<|tool_calls>`、`<|tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo(如 ``),以及旧式 canonical XML `` → `` → ``。实现上采用窄容错结构扫描:只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 会进入工具路径,裸 `` 不计为已支持语法;流式场景继续执行防泄漏筛分。若参数体本身是合法 JSON 字面量(如 `123`、`true`、`null`、数组或对象),会按结构化值输出,不再一律当作字符串;若 CDATA 偶发漏闭合,则会在最终 parse / flush 恢复阶段做窄修复,尽量保住已完整包裹的外层工具调用。 +- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:推荐模型输出 DSML 外壳 `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`;兼容层也接受 DSML wrapper 别名 ``、`<|tool_calls>`、`<|tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo(如 ``)、控制分隔符漂移(如 `` / 原始 STX `\x02`)、任意协议前缀壳(如 ``),以及旧式 canonical XML `` → `` → ``。实现上采用结构扫描:只要固定本地标签名是 `tool_calls` / `invoke` / `parameter`,前缀壳会在解析入口归一化;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 会进入工具路径,裸 `` 不计为已支持语法;流式场景继续执行防泄漏筛分。若参数体本身是合法 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 别名(``、`<|tool_calls>`、`<|tool_calls>`)、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`)、`DSML` 与工具标签名黏连的常见 typo(如 `` / `` / ``)和旧式 canonical XML 工具块(`` / `` / ``)作为可执行调用解析;DSML 会先归一化回 XML,内部仍以 XML 解析语义为准。旧式 ``、``、``、``、``、`tool_use`、antml 风格与纯 JSON `tool_calls` 片段默认都会按普通文本处理。 +- 解析器当前把 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`)、任意协议前缀壳(如 ``)和旧式 canonical XML 工具块(`` / `` / ``)作为可执行调用解析;这些前缀壳会先归一化回 XML,内部仍以 XML 解析语义为准。旧式 ``、``、``、``、``、`tool_use`、antml 风格与纯 JSON `tool_calls` 片段默认都会按普通文本处理。 - 当最终可见正文为空但思维链里包含可执行工具调用时,Chat / Responses 会在收尾阶段补发标准 OpenAI `tool_calls` / `function_call` 输出;如果客户端未开启 thinking / reasoning,该思维链只用于检测,不会作为可见正文或 `reasoning_content` 暴露。 - Markdown fenced code block(例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。 diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index d7bd479..3dfaa45 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -168,7 +168,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认 4. 把这整段内容并入 system prompt。 工具调用正例现在优先示范官方 DSML 风格:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`。 -兼容层仍接受旧式纯 `` wrapper,并会容错若干 DSML 标签变体,包括短横线形式 `` / `` / ``、下划线形式 `` / `` / ``,以及其他前缀分隔形态如 `` / `` / ``;标签壳扫描还会把全角 ASCII 漂移归一化,例如 `<dSML|tool_calls>` 与全角 `>` 结束符。但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;这些别名会在解析入口归一化回现有 XML 标签后继续走同一套 parser。解析器会先截获非代码块中的疑似工具 wrapper,完整解析失败或工具语义无效时再按普通文本放行。 +兼容层仍接受旧式纯 `` wrapper,并会容错若干 DSML 标签变体,包括短横线形式 `` / `` / ``、下划线形式 `` / `` / ``,以及其他前缀分隔形态如 `` / `` / ``;标签壳扫描还会把全角 ASCII 漂移归一化,例如 `<dSML|tool_calls>` 与全角 `>` 结束符。更一般地,Go / Node tag 扫描以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,标签名前任意协议前缀壳都会在解析入口剥离,例如 ``、`` 这类控制符或非 ASCII 分隔符漂移也会归一化回现有 XML 标签后继续走同一套 parser。但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现。解析器会先截获非代码块中的疑似工具 wrapper,完整解析失败或工具语义无效时再按普通文本放行。 数组参数使用 `...` 子节点表示;当某个参数体只包含 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` 等)。 diff --git a/docs/toolcall-semantics.md b/docs/toolcall-semantics.md index e84945d..86df137 100644 --- a/docs/toolcall-semantics.md +++ b/docs/toolcall-semantics.md @@ -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>`、``、`<` 都会归一化;相似但非固定标签名(如 `tool_calls_extra`)仍按普通文本处理。 +- Go / Node 解析层不再枚举每一种 DSML typo。它以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,把标签名前的任意协议前缀壳视为可容忍噪声,并继续兼容管道符 `|` / `|`、空白、重复 leading `<`、可视控制符 `␂`、原始 STX `\x02`、非 ASCII 分隔符等漂移。例如 ``、`<<|DSML|tool_calls>`、`<|DSML tool_calls>`、``、`<`、``、`` 都会归一化;相似但非固定标签名(如 `tool_calls_extra`)仍按普通文本处理。 - 如果模型在固定工具标签名后多输出一个尾部管道符,例如 `<|DSML|tool_calls|` / `<|DSML|invoke|` / `<|DSML|parameter|`,兼容层会把这个尾部 `|` 当作异常标签终止符并补齐缺失的 `>`;如果后面已经有 `>`,也会消费这个多余 `|` 后再归一化。 - 这是一个针对常见模型失误的窄修复,不改变推荐输出格式;prompt 仍要求模型直接输出完整 DSML 外壳。 - 裸 `` / `` 不会被当成“已支持的工具语法”;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 才会进入工具调用路径。 diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index 8c3714b..3f50b03 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -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 }; diff --git a/internal/toolcall/toolcalls_scan.go b/internal/toolcall/toolcalls_scan.go index 8accb1a..a89fbad 100644 --- a/internal/toolcall/toolcalls_scan.go +++ b/internal/toolcall/toolcalls_scan.go @@ -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 } diff --git a/internal/toolstream/tool_sieve_xml_test.go b/internal/toolstream/tool_sieve_xml_test.go index ce2ee77..968d3bb 100644 --- a/internal/toolstream/tool_sieve_xml_test.go +++ b/internal/toolstream/tool_sieve_xml_test.go @@ -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{ + "\n", + ` ` + "\n", + ` ` + "\n", + " \n", + "", + } + 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{ + "\n", + ` ` + "\n", + ` ` + "\n", + " \n", + "", + } + 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{ + "\n", + ` ` + "\n", + ` ` + "\n", + " \n", + "", + } + 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{ diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index ccfff1c..7404178 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -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 = ` + + + +`; + 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 = ` + + + +`; + 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 = 'pwd'; 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([ + `\n', + `\n`, + `\n`, + `\n`, + ``, + ], ['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([ + '\n', + '\n', + '\n', + '\n', + '', + ], ['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',