Skip to content
This repository was archived by the owner on May 13, 2026. It is now read-only.

Commit 3beb313

Browse files
authored
Merge pull request #473 from LoGGGG240211/codex/integrate-confusable-hardening
feat(toolcall): harden confusable candidate spans
2 parents 1aa791e + 196e3c4 commit 3beb313

20 files changed

Lines changed: 2253 additions & 359 deletions

docs/prompt-compatibility.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认
168168
4. 把这整段内容并入 system prompt。
169169

170170
工具调用正例现在优先示范全角分隔符 DSML 风格:`<|DSML|tool_calls>``<|DSML|invoke name="...">``<|DSML|parameter name="...">`
171-
兼容层仍接受旧式纯 `<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 漂移归一化,例如 `<dSML|tool_calls>` 与全角 `>` 结束符,也会容错 CJK 尖括号、全角感叹号或顿号分隔符、弯引号属性值、PascalCase 本地名和属性尾部分隔符漂移,例如 `<DSM|parameter name="command"|>...〈/DSM|parameter〉`、`<!DSML!invoke name=“Bash”>`、`<、DSML、tool_calls>`、`<DSmartToolCalls>`、`<DSMLtool_calls※>`。更一般地,Go / Node tag 扫描以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,标签名前或标签名后的非结构性协议分隔符都会在解析入口剥离,例如 `<DSML␂tool_calls>`、`<proto💥tool_calls>` 这类控制符或非 ASCII 分隔符漂移也会归一化回现有 XML 标签后继续走同一套 parser;结构性字符如 `<` / `>` / `/` / `=` / 引号、空白和 ASCII 字母数字不会被当作这类分隔符。CDATA 开头也使用同一类扫描式容错,`<![CDATA[` / `<![CDATA[` / `<、[CDATA[` 都会作为参数原文容器处理。但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现。解析器会先截获非代码块中的疑似工具 wrapper,完整解析失败或工具语义无效时再按普通文本放行。
171+
兼容层仍接受旧式纯 `<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 漂移归一化,例如 `<dSML|tool_calls>` 与全角 `>` 结束符,也会容错 CJK 尖括号、全角感叹号或顿号分隔符、弯引号属性值、PascalCase 本地名和属性尾部分隔符漂移,例如 `<DSM|parameter name="command"|>...〈/DSM|parameter〉`、`<!DSML!invoke name=“Bash”>`、`<、DSML、tool_calls>`、`<DSmartToolCalls>`、`<DSMLtool_calls※>`。更一般地,Go / Node tag 扫描以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,标签名前或标签名后的非结构性协议分隔符都会在解析入口剥离,例如 `<DSML␂tool_calls>`、`<proto💥tool_calls>` 这类控制符或非 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 开头也使用同一类扫描式容错,`<![CDATA[` / `<![CDATA[` / `<、[CDATA[` 都会作为参数原文容器处理。但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现。解析器会先截获非代码块中的疑似工具 wrapper,完整解析失败或工具语义无效时再按普通文本放行。
172172
数组参数使用 `<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 结束标签。
173173
Go 侧读取 DeepSeek SSE 时不再依赖 `bufio.Scanner` 的固定 2MiB 单行上限;当写文件类工具把很长的 `content` 放在单个 `data:` 行里返回时,非流式收集、流式解析和 auto-continue 透传都会保留完整行,再进入同一套工具解析与序列化流程。
174174
在 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,而会转成
225225
如果客户端历史里没有结构化 `tool_calls` 字段、却把一个可独立解析的 assistant 工具块放进了普通 `content`,兼容层会在写入后续 prompt 前先按工具调用解析它,再重渲染为规范 DSML 历史外壳。这样可以避免一次 malformed 工具块未被结构化保存后,作为普通 assistant 文本回灌,继续污染后续模型的 few-shot 工具格式。
226226

227227
解析层同时兼容旧式纯 XML 形态:`<tool_calls>` / `<invoke>` / `<parameter>`。两者都会先归一到现有 XML 解析语义;其他旧格式都会作为普通文本保留,不会作为可执行调用语法。
228-
例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 `<invoke ...>` ... `</tool_calls>`(或 DSML 对应标签),但漏掉最前面的 opening wrapper,解析阶段会补回 wrapper 后再尝试识别。
228+
例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 `<invoke ...>` ... `</tool_calls>`(或 DSML 对应标签),但漏掉最前面的 opening wrapper,解析阶段会在 wrapper-confidence 足够高时补回 wrapper 后再尝试识别。这里的 wrapper-confidence 指 scanner 已经识别出白名单工具壳结构,剩余失败只像壳层结构漂移,而不是语义上接近但不在白名单内的 near-miss 标签名。修复成功时,wrapper 后面的 suffix prose 会继续保留在可见文本里;修复失败时,该块仍按普通文本处理
229229

230230
这件事很重要,因为它决定了:
231231

docs/toolcall-semantics.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@
3939
兼容修复:
4040

4141
- 如果模型漏掉 opening wrapper,但后面仍输出了一个或多个 invoke 并以 closing wrapper 收尾,Go 解析链路会在解析前补回缺失的 opening wrapper。
42+
- 在进入现有 DSML rewrite / XML parse 之前,Go / Node 都会先做一次非常窄的 candidate-span canonicalization:只处理已经被 scanner 识别为工具标签壳的 wrapper / `invoke` / `parameter` / `name` / `CDATA` / `DSML` 及其结构分隔符;这里会移除零宽 / BOM / 控制类干扰字符,并把 `<``>``/``|``=`、引号、Unicode 空白、常见 dash / underscore 变体这类工具语法外壳符号折回 ASCII 语义。
4243
- Go / Node 解析层不再枚举每一种 DSML typo。它以固定本地标签名 `tool_calls` / `invoke` / `parameter` 为准,把标签名前的任意协议前缀壳视为可容忍噪声,并继续兼容管道符 `|` / ``、全角感叹号 ``、顿号 ``、空白、重复 leading `<`、可视控制符 ``、原始 STX `\x02`、非 ASCII 分隔符、CJK 尖括号 `` / ``、弯引号属性值、PascalCase 本地名等漂移。例如 `<DSML|tool_calls>``<<|DSML|tool_calls>``<|DSML tool_calls>``<DSMLtool_calls>``<DSmartToolCalls>``<<DSML|DSML|tool_calls>``<DSML␂tool_calls>``<proto💥tool_calls>``<DSM|tool_calls>...〈/DSM|tool_calls〉``<!DSML!tool_calls>...<!/DSML!tool_calls>``<、DSML、tool_calls>...<、/DSML、tool_calls>` 都会归一化;相似但非固定标签名(如 `tool_calls_extra` / `ToolCallsExtra`)仍按普通文本处理。
44+
- 这个 candidate-span canonicalization 不会对普通 prose、参数正文、CDATA 内容或嵌套的非工具 XML 做广义 Unicode 归一化。也就是说,参数里的示例 `<invοke>`、普通聊天文本里的 confusable 单词、或其他非工具壳 XML 片段都保持原样;只有真正落在工具标签壳上的 whitelist 关键字和结构符号会被折叠。
4345
- 如果模型在固定工具标签名后多输出一个非结构性分隔符,例如 `<|DSML|tool_calls|` / `<|DSML|invoke|` / `<|DSML|parameter|` / `<DSMLtool_calls※>`,或在带属性标签的结束符前多输出一个尾部分隔符(如 `<DSM|parameter name="command"|>`),兼容层会把这个尾部分隔符当作异常标签终止符并补齐或归一化;如果后面已经有 `>` / ``,也会消费这个多余分隔符后再归一化。结构性字符如 `<` / `>` / `/` / `=` / 引号、空白和 ASCII 字母数字不会被当作这类分隔符。
46+
- “缺失 opening wrapper”的修复只会在 wrapper-confidence 足够高时触发:scanner 必须已经识别出白名单工具壳结构(wrapper / invoke / parameter / `name=` 等),且剩余失败看起来只是壳层结构问题。相似但不在白名单内的 near-miss 标签名,或缺少足够 wrapper 证据的 malformed 片段,仍会按普通文本透传。
4447
- 这是一个针对常见模型失误的窄修复,不改变推荐输出格式;prompt 仍要求模型直接输出完整 DSML 外壳。
4548
-`<invoke ...>` / `<parameter ...>` 不会被当成“已支持的工具语法”;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 才会进入工具调用路径。
4649

@@ -58,6 +61,7 @@
5861
- 如果流里直接从 invoke 开始,但后面补上了 closing wrapper,Go 流式筛分也会按缺失 opening wrapper 的修复路径尝试恢复
5962
- 已识别成功的工具调用不会再次回流到普通文本
6063
- 不符合新格式的块不会执行,并继续按原样文本透传
64+
- 如果一个 confusable / 漂移过的工具壳在 candidate-span canonicalization + repair 后仍能形成有效工具调用,wrapper 后面的 suffix prose 会继续按普通文本输出;如果 canonicalization 后仍不满足 wrapper-confidence 或 XML 语义,整块就作为普通文本释放,不会半吞半漏。
6165
- fenced code block(反引号 `` ``` `` 和波浪线 `~~~`)中的 XML 示例始终按普通文本处理
6266
- 支持嵌套围栏(如 4 反引号嵌套 3 反引号)和 CDATA 内围栏保护
6367
-`command` / `content` 等长文本参数,CDATA 内部如果包含 Markdown fenced DSML / XML 示例,即使示例里出现 `]]></parameter>` / `</tool_calls>` 这类看起来像外层结束标签的片段,也会继续按参数原文保留,直到真正位于围栏外的外层结束标签

internal/httpapi/openai/leaked_output_sanitize_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ func TestSanitizeLeakedOutputRemovesThinkAndBosMarkers(t *testing.T) {
3434
}
3535
}
3636

37+
func TestSanitizeLeakedOutputRemovesThoughtMarkers(t *testing.T) {
38+
raw := "A<|▁of▁thought|>B<| of_thought |>C<| begin_of_thought |>D<| end_of_thought |>E"
39+
got := sanitizeLeakedOutput(raw)
40+
if got != "ABCDE" {
41+
t.Fatalf("unexpected sanitize result for leaked thought markers: %q", got)
42+
}
43+
}
44+
3745
func TestSanitizeLeakedOutputRemovesDanglingThinkBlock(t *testing.T) {
3846
raw := "Answer prefix<think>internal reasoning that never closes"
3947
got := sanitizeLeakedOutput(raw)

internal/httpapi/openai/shared/leaked_output_sanitize.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ var leakedThinkTagPattern = regexp.MustCompile(`(?is)</?\s*think\s*>`)
1818
// - U+2581 variant: <|begin▁of▁sentence|>
1919
var leakedBOSMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*begin[_▁]of[_▁]sentence\s*[|\|]>`)
2020

21+
// leakedThoughtMarkerPattern matches leaked thought control markers in both
22+
// explicit and compact forms:
23+
// - ASCII underscore: <| of_thought |>, <| begin_of_thought |>
24+
// - U+2581 variant: <|▁of▁thought|>, <|begin▁of▁thought|>
25+
var leakedThoughtMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:begin[_▁])?[_▁]*of[_▁]thought\s*[|\|]>`)
26+
2127
// leakedMetaMarkerPattern matches the remaining DeepSeek special tokens in BOTH forms:
2228
// - ASCII underscore: <|end_of_sentence|>, <|end_of_toolresults|>, <|end_of_instructions|>
2329
// - U+2581 variant: <|end▁of▁sentence|>, <|end▁of▁toolresults|>, <|end▁of▁instructions|>
24-
var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[|\|]>`)
30+
var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]thought|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[|\|]>`)
2531

2632
// leakedAgentXMLBlockPatterns catch agent-style XML blocks that leak through
2733
// when the sieve fails to capture them. These are applied only to complete
@@ -48,6 +54,7 @@ func sanitizeLeakedOutput(text string) string {
4854
out = stripDanglingThinkSuffix(out)
4955
out = leakedThinkTagPattern.ReplaceAllString(out, "")
5056
out = leakedBOSMarkerPattern.ReplaceAllString(out, "")
57+
out = leakedThoughtMarkerPattern.ReplaceAllString(out, "")
5158
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
5259
out = stripLeakedToolCallWrapperBlocks(out)
5360
out = sanitizeLeakedAgentXMLBlocks(out)

internal/js/chat-stream/sse_parse_impl.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const {
77
SKIP_EXACT_PATHS,
88
} = require('../shared/deepseek-constants');
99

10+
const LEAKED_BOS_MARKER_PATTERN = /<[|]\s*begin[_]of[_]sentence\s*[|]>/gi;
11+
const LEAKED_THOUGHT_MARKER_PATTERN = /<[|]\s*(?:begin[_])?[_]*of[_]thought\s*[|]>/gi;
12+
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;
13+
1014

1115

1216
function stripThinkTags(text) {
@@ -621,7 +625,11 @@ function stripReferenceMarkersText(text) {
621625
if (!text) {
622626
return text;
623627
}
624-
return text.replace(/\[(?:citation|reference):\s*\d+\]/gi, '');
628+
return text
629+
.replace(/\[(?:citation|reference):\s*\d+\]/gi, '')
630+
.replace(LEAKED_BOS_MARKER_PATTERN, '')
631+
.replace(LEAKED_THOUGHT_MARKER_PATTERN, '')
632+
.replace(LEAKED_META_MARKER_PATTERN, '');
625633
}
626634

627635
function asString(v) {

internal/js/helpers/stream-tool-sieve/parse.js

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const {
77
parseMarkupToolCalls,
88
stripFencedCodeBlocks,
99
containsToolCallWrapperSyntaxOutsideIgnored,
10+
normalizeDSMLToolCallMarkup,
11+
hasRepairableXMLToolCallsWrapper,
12+
indexToolCDATAOpen,
1013
sanitizeLooseCDATA,
1114
} = require('./parse_payload');
1215

@@ -37,19 +40,23 @@ function parseToolCalls(text, toolNames) {
3740

3841
function parseToolCallsDetailed(text, toolNames) {
3942
const result = emptyParseResult();
40-
const normalized = toStringSafe(text);
41-
if (!normalized) {
43+
const raw = toStringSafe(text);
44+
if (!raw) {
4245
return result;
4346
}
44-
result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized);
45-
if (shouldSkipToolCallParsingForCodeFenceExample(normalized)) {
47+
if (shouldSkipToolCallParsingForCodeFenceExample(raw)) {
4648
return result;
4749
}
50+
const normalized = normalizeDSMLToolCallMarkup(stripFencedCodeBlocks(raw).trim());
51+
if (!normalized.ok || !normalized.text) {
52+
return result;
53+
}
54+
result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized.text) || hasRepairableXMLToolCallsWrapper(normalized.text);
4855
// XML markup parsing only.
49-
let parsed = parseMarkupToolCalls(normalized);
50-
if (parsed.length === 0 && normalized.toLowerCase().includes('<![cdata[')) {
51-
const recovered = sanitizeLooseCDATA(normalized);
52-
if (recovered !== normalized) {
56+
let parsed = parseMarkupToolCalls(normalized.text);
57+
if (parsed.length === 0 && indexToolCDATAOpen(normalized.text, 0) >= 0) {
58+
const recovered = sanitizeLooseCDATA(normalized.text);
59+
if (recovered !== normalized.text) {
5360
parsed = parseMarkupToolCalls(recovered);
5461
}
5562
}
@@ -70,19 +77,23 @@ function parseStandaloneToolCalls(text, toolNames) {
7077

7178
function parseStandaloneToolCallsDetailed(text, toolNames) {
7279
const result = emptyParseResult();
73-
const trimmed = toStringSafe(text);
74-
if (!trimmed) {
80+
const raw = toStringSafe(text);
81+
if (!raw) {
82+
return result;
83+
}
84+
if (shouldSkipToolCallParsingForCodeFenceExample(raw)) {
7585
return result;
7686
}
77-
result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed);
78-
if (shouldSkipToolCallParsingForCodeFenceExample(trimmed)) {
87+
const normalized = normalizeDSMLToolCallMarkup(stripFencedCodeBlocks(raw).trim());
88+
if (!normalized.ok || !normalized.text) {
7989
return result;
8090
}
91+
result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized.text) || hasRepairableXMLToolCallsWrapper(normalized.text);
8192
// XML markup parsing only.
82-
let parsed = parseMarkupToolCalls(trimmed);
83-
if (parsed.length === 0 && trimmed.toLowerCase().includes('<![cdata[')) {
84-
const recovered = sanitizeLooseCDATA(trimmed);
85-
if (recovered !== trimmed) {
93+
let parsed = parseMarkupToolCalls(normalized.text);
94+
if (parsed.length === 0 && indexToolCDATAOpen(normalized.text, 0) >= 0) {
95+
const recovered = sanitizeLooseCDATA(normalized.text);
96+
if (recovered !== normalized.text) {
8697
parsed = parseMarkupToolCalls(recovered);
8798
}
8899
}

0 commit comments

Comments
 (0)