From 2f7cb473fcf218f7d396a275d6a118a1c2b3bab9 Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 3 May 2026 03:09:10 +0800 Subject: [PATCH] feat: support hyphenated DSML tag variants in tool-call parsing Add compatibility for // tag forms alongside the canonical pipe-prefixed DSML shell. Hyphenated forms only activate when a DSML prefix is detected, preventing false matches on bare XML lookalikes. Go and Node parsers aligned, with tests covering here-doc CDATA, streaming sieve, and negative lookalike cases. Co-Authored-By: Claude Opus 4.7 --- docs/prompt-compatibility.md | 2 +- docs/toolcall-semantics.md | 2 +- .../stream-tool-sieve/parse_payload.js | 26 ++++++--- internal/toolcall/toolcalls_scan.go | 32 ++++++++--- internal/toolcall/toolcalls_test.go | 39 ++++++++++++++ internal/toolstream/complex_edge_test.go | 45 ++++++++++++++++ tests/node/stream-tool-sieve.test.js | 54 +++++++++++++++++++ 7 files changed, 184 insertions(+), 16 deletions(-) diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index ac1c9fe..83d7763 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -167,7 +167,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认 4. 把这整段内容并入 system prompt。 工具调用正例现在优先示范官方 DSML 风格:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`。 -兼容层仍接受旧式纯 `` wrapper,但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。 +兼容层仍接受旧式纯 `` wrapper,并会容错若干 DSML 标签变体,包括短横线形式 `` / `` / ``;但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。 数组参数使用 `...` 子节点表示;当某个参数体只包含 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` 列表,才会触发结构化恢复。 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 0f24d6f..fd34ec1 100644 --- a/docs/toolcall-semantics.md +++ b/docs/toolcall-semantics.md @@ -54,7 +54,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 的修复路径尝试恢复 - 已识别成功的工具调用不会再次回流到普通文本 - 不符合新格式的块不会执行,并继续按原样文本透传 diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index 35c69ed..6b8077e 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -2,7 +2,12 @@ const CDATA_PATTERN = /^$/i; const XML_ATTR_PATTERN = /\b([a-z0-9_:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gi; -const TOOL_MARKUP_NAMES = ['tool_calls', 'invoke', 'parameter']; +const TOOL_MARKUP_NAMES = [ + { raw: 'tool_calls', canonical: 'tool_calls' }, + { raw: 'tool-calls', canonical: 'tool_calls', dsmlOnly: true }, + { raw: 'invoke', canonical: 'invoke' }, + { raw: 'parameter', canonical: 'parameter' }, +]; const { toStringSafe, @@ -437,7 +442,7 @@ function scanToolMarkupTagAt(text, start) { const prefix = consumeToolMarkupNamePrefix(raw, lower, i); i = prefix.next; const dsmlLike = prefix.dsmlLike; - const { name, len } = matchToolMarkupName(lower, i); + const { name, len } = matchToolMarkupName(lower, i, dsmlLike); if (!name) { return null; } @@ -610,24 +615,31 @@ function consumeToolMarkupNamePrefixOnce(raw, lower, idx) { return { next: idx + 1, ok: true }; } if (lower.startsWith('dsml', idx)) { - return { next: idx + 'dsml'.length, ok: true }; + let next = idx + 'dsml'.length; + if (next < raw.length && raw[next] === '-') { + next += 1; + } + return { next, ok: true }; } return { next: idx, ok: false }; } function hasToolMarkupNamePrefix(lowerTail) { for (const name of TOOL_MARKUP_NAMES) { - if (lowerTail.startsWith(name) || name.startsWith(lowerTail)) { + if (lowerTail.startsWith(name.raw) || name.raw.startsWith(lowerTail)) { return true; } } return false; } -function matchToolMarkupName(lower, start) { +function matchToolMarkupName(lower, start, dsmlLike) { for (const name of TOOL_MARKUP_NAMES) { - if (lower.startsWith(name, start)) { - return { name, len: name.length }; + if (name.dsmlOnly && !dsmlLike) { + continue; + } + if (lower.startsWith(name.raw, start)) { + return { name: name.canonical, len: name.raw.length }; } } return { name: '', len: 0 }; diff --git a/internal/toolcall/toolcalls_scan.go b/internal/toolcall/toolcalls_scan.go index f1d3c4a..3b8ce88 100644 --- a/internal/toolcall/toolcalls_scan.go +++ b/internal/toolcall/toolcalls_scan.go @@ -2,7 +2,18 @@ package toolcall import "strings" -var toolMarkupNames = []string{"tool_calls", "invoke", "parameter"} +type toolMarkupNameAlias struct { + raw string + canonical string + dsmlOnly bool +} + +var toolMarkupNames = []toolMarkupNameAlias{ + {raw: "tool_calls", canonical: "tool_calls"}, + {raw: "tool-calls", canonical: "tool_calls", dsmlOnly: true}, + {raw: "invoke", canonical: "invoke"}, + {raw: "parameter", canonical: "parameter"}, +} type ToolMarkupTag struct { Start int @@ -137,7 +148,7 @@ func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) { i++ } i, dsmlLike := consumeToolMarkupNamePrefix(lower, text, i) - name, nameLen := matchToolMarkupName(lower, i) + name, nameLen := matchToolMarkupName(lower, i, dsmlLike) if nameLen == 0 { return ToolMarkupTag{}, false } @@ -230,24 +241,31 @@ func consumeToolMarkupNamePrefixOnce(lower, text string, idx int) (int, bool) { return idx + 1, true } if strings.HasPrefix(lower[idx:], "dsml") { - return idx + len("dsml"), true + next := idx + len("dsml") + if next < len(text) && text[next] == '-' { + next++ + } + return next, true } return idx, false } func hasToolMarkupNamePrefix(lowerTail string) bool { for _, name := range toolMarkupNames { - if strings.HasPrefix(lowerTail, name) || strings.HasPrefix(name, lowerTail) { + if strings.HasPrefix(lowerTail, name.raw) || strings.HasPrefix(name.raw, lowerTail) { return true } } return false } -func matchToolMarkupName(lower string, start int) (string, int) { +func matchToolMarkupName(lower string, start int, dsmlLike bool) (string, int) { for _, name := range toolMarkupNames { - if strings.HasPrefix(lower[start:], name) { - return name, len(name) + if name.dsmlOnly && !dsmlLike { + continue + } + if strings.HasPrefix(lower[start:], name.raw) { + return name.canonical, len(name.raw) } } return "", 0 diff --git a/internal/toolcall/toolcalls_test.go b/internal/toolcall/toolcalls_test.go index 0abb01b..ba32d2b 100644 --- a/internal/toolcall/toolcalls_test.go +++ b/internal/toolcall/toolcalls_test.go @@ -41,6 +41,45 @@ func TestParseToolCallsSupportsDSMLShell(t *testing.T) { } } +func TestParseToolCallsSupportsHyphenatedDSMLShellWithHereDocCDATA(t *testing.T) { + text := ` + + + + +` + calls := ParseToolCalls(text, []string{"Bash"}) + if len(calls) != 1 { + t.Fatalf("expected 1 hyphenated DSML call, got %#v", calls) + } + if calls[0].Name != "Bash" { + t.Fatalf("expected Bash tool, got %#v", calls[0]) + } + command, _ := calls[0].Input["command"].(string) + if !strings.Contains(command, `git commit -m "$(cat <<'EOF'`) || !strings.Contains(command, "Co-Authored-By: Claude Opus 4.7") { + t.Fatalf("expected here-doc CDATA command to be preserved, got %q", command) + } + if calls[0].Input["description"] != "Create commit with architecture doc updates" { + t.Fatalf("expected description parameter, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsIgnoresBareHyphenatedToolCallsLookalike(t *testing.T) { + text := `pwd` + calls := ParseToolCalls(text, []string{"Bash"}) + if len(calls) != 0 { + t.Fatalf("expected bare hyphenated lookalike to be ignored, got %#v", calls) + } +} + func TestParseToolCallsToleratesDSMLTrailingPipeTagTerminator(t *testing.T) { text := strings.Join([]string{ `<|DSML|tool_calls| `, diff --git a/internal/toolstream/complex_edge_test.go b/internal/toolstream/complex_edge_test.go index 759a80f..3337773 100644 --- a/internal/toolstream/complex_edge_test.go +++ b/internal/toolstream/complex_edge_test.go @@ -555,6 +555,51 @@ func TestSieve_ChineseReviewSamplePreservesInlineDSMLMention(t *testing.T) { } } +func TestSieve_HyphenatedDSMLShellWithHereDocCDATA(t *testing.T) { + var state State + chunks := []string{ + "\n", + "\n", + "\n", + "\n", + "\n", + "", + } + var events []Event + for _, c := range chunks { + events = append(events, ProcessChunk(&state, c, []string{"Bash"})...) + } + events = append(events, Flush(&state, []string{"Bash"})...) + + var text strings.Builder + var command string + callCount := 0 + for _, e := range events { + text.WriteString(e.Content) + for _, call := range e.ToolCalls { + callCount++ + command, _ = call.Input["command"].(string) + } + } + + if callCount != 1 { + t.Fatalf("应解析出 1 个 hyphenated DSML 工具调用,got %d, text=%q", callCount, text.String()) + } + if !strings.Contains(command, `git commit -m "$(cat <<'EOF'`) || !strings.Contains(command, "Co-Authored-By: Claude Opus 4.7") { + t.Fatalf("here-doc command 未完整保留, got %q", command) + } + if strings.Contains(text.String(), "dsml-tool-calls") || strings.Contains(text.String(), "git commit -m") { + t.Fatalf("真实工具块不应泄漏到正文, got %q", text.String()) + } +} + func TestSieve_ToleratesDSMLSpaceSeparatorTypo(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 c8f01ca..eb9b5f3 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -57,6 +57,35 @@ test('parseToolCalls parses DSML shell as XML-compatible tool call', () => { assert.deepEqual(calls[0].input, { path: 'README.MD' }); }); +test('parseToolCalls parses hyphenated DSML shell with here-doc CDATA', () => { + const payload = ` + + + + +`; + const calls = parseToolCalls(payload, ['Bash']); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, 'Bash'); + assert.equal(calls[0].input.description, 'Create commit with architecture doc updates'); + assert.equal(calls[0].input.command.includes('git commit -m "$(cat <<\'EOF\''), true); + assert.equal(calls[0].input.command.includes('Co-Authored-By: Claude Opus 4.7'), true); +}); + +test('parseToolCalls ignores bare hyphenated tool_calls lookalike', () => { + const payload = 'pwd'; + const calls = parseToolCalls(payload, ['Bash']); + assert.equal(calls.length, 0); +}); + test('parseToolCalls tolerates DSML trailing pipe tag terminator', () => { const payload = [ '<|DSML|tool_calls| ', @@ -471,6 +500,31 @@ test('sieve preserves Chinese review body with inline DSML mention before real t assert.equal(text.includes('<|DSML|invoke'), false); }); +test('sieve captures hyphenated DSML shell with here-doc CDATA', () => { + const events = runSieve([ + '\n', + '\n', + '\n', + '\n', + '\n', + '', + ], ['Bash']); + const text = collectText(events); + const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + assert.equal(finalCalls.length, 1); + assert.equal(finalCalls[0].input.command.includes('git commit -m "$(cat <<\'EOF\''), true); + assert.equal(finalCalls[0].input.command.includes('Co-Authored-By: Claude Opus 4.7'), true); + assert.equal(text.includes('dsml-tool-calls'), false); + assert.equal(text.includes('git commit -m'), false); +}); + test('parseToolCalls ignores JSON tool_calls payload (XML-only)', () => { const payload = JSON.stringify({ tool_calls: [{ name: 'read_file', input: { path: 'README.MD' } }],