From 196e3c46f6f65edefa8565b4baf611640e115a75 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 10 May 2026 09:27:30 +0700 Subject: [PATCH] feat(toolcall): harden confusable candidate spans --- docs/prompt-compatibility.md | 4 +- docs/toolcall-semantics.md | 4 + .../openai/leaked_output_sanitize_test.go | 8 + .../openai/shared/leaked_output_sanitize.go | 9 +- internal/js/chat-stream/sse_parse_impl.js | 10 +- .../js/helpers/stream-tool-sieve/parse.js | 43 +- .../stream-tool-sieve/parse_payload.js | 1153 ++++++++++++++--- .../js/helpers/stream-tool-sieve/sieve-xml.js | 34 + .../js/helpers/stream-tool-sieve/sieve.js | 4 + internal/toolcall/toolcalls_candidates.go | 689 +++++++++- internal/toolcall/toolcalls_dsml.go | 89 +- internal/toolcall/toolcalls_markup.go | 40 +- internal/toolcall/toolcalls_parse.go | 35 +- internal/toolcall/toolcalls_parse_markup.go | 45 +- internal/toolcall/toolcalls_scan.go | 44 +- internal/toolcall/toolcalls_test.go | 105 ++ internal/toolstream/tool_sieve_xml.go | 18 +- internal/toolstream/tool_sieve_xml_test.go | 163 +++ tests/node/chat-stream.test.js | 10 + tests/node/stream-tool-sieve.test.js | 113 ++ 20 files changed, 2257 insertions(+), 363 deletions(-) diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index fb03021..6fcc1ab 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>` 与全角 `>` 结束符,也会容错 CJK 尖括号、全角感叹号或顿号分隔符、弯引号属性值、PascalCase 本地名和属性尾部分隔符漂移,例如 `...〈/DSM|parameter〉`、`<!DSML!invoke name=“Bash”>`、`<、DSML、tool_calls>`、``、``。更一般地,Go / Node tag 扫描以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,标签名前或标签名后的非结构性协议分隔符都会在解析入口剥离,例如 ``、`` 这类控制符或非 ASCII 分隔符漂移也会归一化回现有 XML 标签后继续走同一套 parser;结构性字符如 `<` / `>` / `/` / `=` / 引号、空白和 ASCII 字母数字不会被当作这类分隔符。CDATA 开头也使用同一类扫描式容错,`` 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` 等)。 @@ -225,7 +225,7 @@ assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON,而会转成 如果客户端历史里没有结构化 `tool_calls` 字段、却把一个可独立解析的 assistant 工具块放进了普通 `content`,兼容层会在写入后续 prompt 前先按工具调用解析它,再重渲染为规范 DSML 历史外壳。这样可以避免一次 malformed 工具块未被结构化保存后,作为普通 assistant 文本回灌,继续污染后续模型的 few-shot 工具格式。 解析层同时兼容旧式纯 XML 形态:`` / `` / ``。两者都会先归一到现有 XML 解析语义;其他旧格式都会作为普通文本保留,不会作为可执行调用语法。 -例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 `` ... ``(或 DSML 对应标签),但漏掉最前面的 opening wrapper,解析阶段会补回 wrapper 后再尝试识别。 +例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 `` ... ``(或 DSML 对应标签),但漏掉最前面的 opening wrapper,解析阶段会在 wrapper-confidence 足够高时补回 wrapper 后再尝试识别。这里的 wrapper-confidence 指 scanner 已经识别出白名单工具壳结构,剩余失败只像壳层结构漂移,而不是语义上接近但不在白名单内的 near-miss 标签名。修复成功时,wrapper 后面的 suffix prose 会继续保留在可见文本里;修复失败时,该块仍按普通文本处理。 这件事很重要,因为它决定了: diff --git a/docs/toolcall-semantics.md b/docs/toolcall-semantics.md index 4deb80d..598eb47 100644 --- a/docs/toolcall-semantics.md +++ b/docs/toolcall-semantics.md @@ -39,8 +39,11 @@ 兼容修复: - 如果模型漏掉 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`)仍按普通文本处理。 +- 这个 candidate-span canonicalization 不会对普通 prose、参数正文、CDATA 内容或嵌套的非工具 XML 做广义 Unicode 归一化。也就是说,参数里的示例 ``、普通聊天文本里的 confusable 单词、或其他非工具壳 XML 片段都保持原样;只有真正落在工具标签壳上的 whitelist 关键字和结构符号会被折叠。 - 如果模型在固定工具标签名后多输出一个非结构性分隔符,例如 `<|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 才会进入工具调用路径。 @@ -58,6 +61,7 @@ - 如果流里直接从 invoke 开始,但后面补上了 closing wrapper,Go 流式筛分也会按缺失 opening wrapper 的修复路径尝试恢复 - 已识别成功的工具调用不会再次回流到普通文本 - 不符合新格式的块不会执行,并继续按原样文本透传 +- 如果一个 confusable / 漂移过的工具壳在 candidate-span canonicalization + repair 后仍能形成有效工具调用,wrapper 后面的 suffix prose 会继续按普通文本输出;如果 canonicalization 后仍不满足 wrapper-confidence 或 XML 语义,整块就作为普通文本释放,不会半吞半漏。 - fenced code block(反引号 `` ``` `` 和波浪线 `~~~`)中的 XML 示例始终按普通文本处理 - 支持嵌套围栏(如 4 反引号嵌套 3 反引号)和 CDATA 内围栏保护 - 对 `command` / `content` 等长文本参数,CDATA 内部如果包含 Markdown fenced DSML / XML 示例,即使示例里出现 `]]>` / `` 这类看起来像外层结束标签的片段,也会继续按参数原文保留,直到真正位于围栏外的外层结束标签 diff --git a/internal/httpapi/openai/leaked_output_sanitize_test.go b/internal/httpapi/openai/leaked_output_sanitize_test.go index acaf720..3b2884b 100644 --- a/internal/httpapi/openai/leaked_output_sanitize_test.go +++ b/internal/httpapi/openai/leaked_output_sanitize_test.go @@ -34,6 +34,14 @@ 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" + got := sanitizeLeakedOutput(raw) + if got != "ABCDE" { + t.Fatalf("unexpected sanitize result for leaked thought markers: %q", got) + } +} + func TestSanitizeLeakedOutputRemovesDanglingThinkBlock(t *testing.T) { raw := "Answer prefixinternal reasoning that never closes" got := sanitizeLeakedOutput(raw) diff --git a/internal/httpapi/openai/shared/leaked_output_sanitize.go b/internal/httpapi/openai/shared/leaked_output_sanitize.go index 5e54637..b45a3ac 100644 --- a/internal/httpapi/openai/shared/leaked_output_sanitize.go +++ b/internal/httpapi/openai/shared/leaked_output_sanitize.go @@ -18,10 +18,16 @@ var leakedThinkTagPattern = regexp.MustCompile(`(?is)`) // - 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*[|\|]>`) + // 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[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[|\|]>`) +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 @@ -48,6 +54,7 @@ func sanitizeLeakedOutput(text string) string { out = stripDanglingThinkSuffix(out) out = leakedThinkTagPattern.ReplaceAllString(out, "") out = leakedBOSMarkerPattern.ReplaceAllString(out, "") + out = leakedThoughtMarkerPattern.ReplaceAllString(out, "") out = leakedMetaMarkerPattern.ReplaceAllString(out, "") out = stripLeakedToolCallWrapperBlocks(out) out = sanitizeLeakedAgentXMLBlocks(out) diff --git a/internal/js/chat-stream/sse_parse_impl.js b/internal/js/chat-stream/sse_parse_impl.js index 6f5922e..4d9a121 100644 --- a/internal/js/chat-stream/sse_parse_impl.js +++ b/internal/js/chat-stream/sse_parse_impl.js @@ -7,6 +7,10 @@ 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; + function stripThinkTags(text) { @@ -621,7 +625,11 @@ function stripReferenceMarkersText(text) { if (!text) { return text; } - return text.replace(/\[(?:citation|reference):\s*\d+\]/gi, ''); + return text + .replace(/\[(?:citation|reference):\s*\d+\]/gi, '') + .replace(LEAKED_BOS_MARKER_PATTERN, '') + .replace(LEAKED_THOUGHT_MARKER_PATTERN, '') + .replace(LEAKED_META_MARKER_PATTERN, ''); } function asString(v) { diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index f2ba3dc..7a70769 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -7,6 +7,9 @@ const { parseMarkupToolCalls, stripFencedCodeBlocks, containsToolCallWrapperSyntaxOutsideIgnored, + normalizeDSMLToolCallMarkup, + hasRepairableXMLToolCallsWrapper, + indexToolCDATAOpen, sanitizeLooseCDATA, } = require('./parse_payload'); @@ -37,19 +40,23 @@ function parseToolCalls(text, toolNames) { function parseToolCallsDetailed(text, toolNames) { const result = emptyParseResult(); - const normalized = toStringSafe(text); - if (!normalized) { + const raw = toStringSafe(text); + if (!raw) { return result; } - result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized); - if (shouldSkipToolCallParsingForCodeFenceExample(normalized)) { + if (shouldSkipToolCallParsingForCodeFenceExample(raw)) { return result; } + const normalized = normalizeDSMLToolCallMarkup(stripFencedCodeBlocks(raw).trim()); + if (!normalized.ok || !normalized.text) { + return result; + } + result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized.text) || hasRepairableXMLToolCallsWrapper(normalized.text); // XML markup parsing only. - let parsed = parseMarkupToolCalls(normalized); - if (parsed.length === 0 && normalized.toLowerCase().includes('= 0) { + const recovered = sanitizeLooseCDATA(normalized.text); + if (recovered !== normalized.text) { parsed = parseMarkupToolCalls(recovered); } } @@ -70,19 +77,23 @@ function parseStandaloneToolCalls(text, toolNames) { function parseStandaloneToolCallsDetailed(text, toolNames) { const result = emptyParseResult(); - const trimmed = toStringSafe(text); - if (!trimmed) { + const raw = toStringSafe(text); + if (!raw) { return result; } - result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed); - if (shouldSkipToolCallParsingForCodeFenceExample(trimmed)) { + if (shouldSkipToolCallParsingForCodeFenceExample(raw)) { return result; } + const normalized = normalizeDSMLToolCallMarkup(stripFencedCodeBlocks(raw).trim()); + if (!normalized.ok || !normalized.text) { + return result; + } + result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized.text) || hasRepairableXMLToolCallsWrapper(normalized.text); // XML markup parsing only. - let parsed = parseMarkupToolCalls(trimmed); - if (parsed.length === 0 && trimmed.toLowerCase().includes('= 0) { + const recovered = sanitizeLooseCDATA(normalized.text); + if (recovered !== normalized.text) { parsed = parseMarkupToolCalls(recovered); } } diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index a24bd62..37c4df6 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -2,6 +2,8 @@ const CDATA_PATTERN = /^(?:<|〈)(?:!|!)\[CDATA\[([\s\S]*?)]](?:>|>|〉)$/i; const XML_ATTR_PATTERN = /\b([a-z0-9_:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gi; +const XML_TOOL_CALLS_CLOSE_PATTERN = /[<<][\//]tool_calls\s*[>>]/gi; +const XML_INVOKE_START_PATTERN = /[<<]invoke\b[^>>]*\bname\s*[==]\s*(?:"([^"]*)"|'([^']*)'|“([^”]*)”|‘([^’]*)’|"([^"]*)"|'([^']*)')/i; const TOOL_MARKUP_NAMES = [ { raw: 'tool_calls', canonical: 'tool_calls' }, { raw: 'tool-calls', canonical: 'tool_calls', dsmlOnly: true }, @@ -88,8 +90,7 @@ function isFenceCloseLine(trimmed, fenceChar, fenceLen) { } function cdataStartsBeforeFence(line) { - const cdataOpen = findNextCDATAOpen(line, 0); - const cdataIdx = cdataOpen.ok ? cdataOpen.start : -1; + const cdataIdx = indexToolCDATAOpen(line, 0); if (cdataIdx < 0) return false; const fenceIdx = Math.min( line.indexOf('```') >= 0 ? line.indexOf('```') : Infinity, @@ -99,21 +100,28 @@ function cdataStartsBeforeFence(line) { } function updateCDATAStateLine(inCDATA, line) { - const lower = line.toLowerCase(); let pos = 0; let state = inCDATA; - while (pos < lower.length) { + while (pos < line.length) { if (state) { - const cdataEnd = findCDATAEnd(lower, pos); - const end = cdataEnd.index; + let end = -1; + let closeLen = 0; + for (let i = pos; i < line.length; i += 1) { + const foundLen = toolCDATACloseLenAt(line, i); + if (foundLen > 0) { + end = i; + closeLen = foundLen; + break; + } + } if (end < 0) return true; - pos = end + cdataEnd.len; + pos = end + closeLen; state = false; continue; } - const start = findNextCDATAOpen(line, pos); - if (!start.ok) return false; - pos = start.bodyStart; + const start = indexToolCDATAOpen(line, pos); + if (start < 0) return false; + pos = start + toolCDATAOpenLenAt(line, start); state = true; } return state; @@ -124,12 +132,20 @@ function parseMarkupToolCalls(text) { if (!normalized.ok) { return []; } - const raw = normalized.text.trim(); + let raw = normalized.text.trim(); if (!raw) { return []; } + let wrappers = findXmlElementBlocks(raw, 'tool_calls'); + if (wrappers.length === 0 && hasRepairableXMLToolCallsWrapper(raw)) { + const repaired = repairMissingXMLToolCallsOpeningWrapper(raw); + if (repaired !== raw) { + raw = repaired; + wrappers = findXmlElementBlocks(raw, 'tool_calls'); + } + } const out = []; - for (const wrapper of findXmlElementBlocks(raw, 'tool_calls')) { + for (const wrapper of wrappers) { const body = toStringSafe(wrapper.body); for (const block of findXmlElementBlocks(body, 'invoke')) { const parsed = parseMarkupSingleToolCall(block); @@ -146,12 +162,13 @@ function normalizeDSMLToolCallMarkup(text) { if (!raw) { return { text: '', ok: true }; } - const styles = containsToolMarkupSyntaxOutsideIgnored(raw); - if (!styles.dsml) { - return { text: raw, ok: true }; + const canonicalized = canonicalizeToolCallCandidateSpans(raw); + const styles = containsToolMarkupSyntaxOutsideIgnored(canonicalized); + if (!styles.dsml && !styles.canonical) { + return { text: canonicalized, ok: true }; } return { - text: replaceDSMLToolMarkupOutsideIgnored(raw), + text: replaceDSMLToolMarkupOutsideIgnored(canonicalized), ok: true, }; } @@ -170,9 +187,8 @@ function containsToolCallWrapperSyntaxOutsideIgnored(text) { if (!raw) { return styles; } - const lower = raw.toLowerCase(); for (let i = 0; i < raw.length;) { - const skipped = skipXmlIgnoredSection(lower, i); + const skipped = skipXmlIgnoredSection(raw, i); if (skipped.blocked) { return styles; } @@ -208,7 +224,7 @@ function containsToolMarkupSyntaxOutsideIgnored(text) { return styles; } for (let i = 0; i < raw.length;) { - const skipped = skipXmlIgnoredSection(raw.toLowerCase(), i); + const skipped = skipXmlIgnoredSection(raw, i); if (skipped.blocked) { return styles; } @@ -239,10 +255,9 @@ function replaceDSMLToolMarkupOutsideIgnored(text) { if (!raw) { return ''; } - const lower = raw.toLowerCase(); let out = ''; for (let i = 0; i < raw.length;) { - const skipped = skipXmlIgnoredSection(lower, i); + const skipped = skipXmlIgnoredSection(raw, i); if (skipped.blocked) { out += raw.slice(i); break; @@ -254,15 +269,7 @@ function replaceDSMLToolMarkupOutsideIgnored(text) { } const tag = scanToolMarkupTagAt(raw, i); if (tag) { - if (tag.dsmlLike) { - const tail = normalizeToolMarkupTagTailForXML(raw.slice(tag.nameEnd, tag.end + 1)); - out += `<${tag.closing ? '/' : ''}${tag.name}${tail}`; - if (!tail.endsWith('>')) { - out += '>'; - } - } else { - out += raw.slice(tag.start, tag.end + 1); - } + out += `<${tag.closing ? '/' : ''}${tag.name}${raw.slice(tag.nameEnd, tag.end)}>`; i = tag.end + 1; continue; } @@ -345,7 +352,7 @@ function findXmlStartTagOutsideCDATA(text, tag, from) { const lower = text.toLowerCase(); const target = `<${tag}`; for (let i = Math.max(0, from || 0); i < text.length;) { - const skipped = skipXmlIgnoredSection(lower, i); + const skipped = skipXmlIgnoredSection(text, i); if (skipped.blocked) { return null; } @@ -375,7 +382,7 @@ function findMatchingXmlEndTagOutsideCDATA(text, tag, from) { const closeTarget = ` 0) { + const end = findToolCDATAEnd(raw, i + openLen); if (end < 0) { return { advanced: false, blocked: true, next: i }; } - return { advanced: true, blocked: false, next: end + cdataEnd.len }; + return { advanced: true, blocked: false, next: end + toolCDATACloseLenAt(raw, end) }; } - if (lower.startsWith('', i + '', i + '