feat: support hyphenated DSML tag variants in tool-call parsing

Add compatibility for <dsml-tool-calls>/<dsml-invoke>/<dsml-parameter>
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 <noreply@anthropic.com>
This commit is contained in:
CJACK
2026-05-03 03:09:10 +08:00
parent ad80a57efa
commit 2f7cb473fc
7 changed files with 184 additions and 16 deletions

View File

@@ -167,7 +167,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认
4. 把这整段内容并入 system prompt。
工具调用正例现在优先示范官方 DSML 风格:`<|DSML|tool_calls>``<|DSML|invoke name="...">``<|DSML|parameter name="...">`
兼容层仍接受旧式纯 `<tool_calls>` wrapper但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。
兼容层仍接受旧式纯 `<tool_calls>` wrapper并会容错若干 DSML 标签变体,包括短横线形式 `<dsml-tool-calls>` / `<dsml-invoke>` / `<dsml-parameter>`但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。
数组参数使用 `<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` 列表,才会触发结构化恢复。
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

@@ -54,7 +54,7 @@
在流式链路中Go / Node 一致):
- DSML `<|DSML|tool_calls>` wrapper、基于固定本地标签名的 DSML 噪声容错形态、尾部管道符形态(如 `<|DSML|tool_calls|`)和 canonical `<tool_calls>` wrapper 都会进入结构化捕获
- DSML `<|DSML|tool_calls>` wrapper、短横线形式(如 `<dsml-tool-calls>` / `<dsml-invoke>` / `<dsml-parameter>`)、基于固定本地标签名的 DSML 噪声容错形态、尾部管道符形态(如 `<|DSML|tool_calls|`)和 canonical `<tool_calls>` wrapper 都会进入结构化捕获
- 如果流里直接从 invoke 开始,但后面补上了 closing wrapperGo 流式筛分也会按缺失 opening wrapper 的修复路径尝试恢复
- 已识别成功的工具调用不会再次回流到普通文本
- 不符合新格式的块不会执行,并继续按原样文本透传

View File

@@ -2,7 +2,12 @@
const CDATA_PATTERN = /^<!\[CDATA\[([\s\S]*?)]]>$/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 };

View File

@@ -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

View File

@@ -41,6 +41,45 @@ func TestParseToolCallsSupportsDSMLShell(t *testing.T) {
}
}
func TestParseToolCallsSupportsHyphenatedDSMLShellWithHereDocCDATA(t *testing.T) {
text := `<dsml-tool-calls>
<dsml-invoke name="Bash">
<dsml-parameter name="command"><![CDATA[git commit -m "$(cat <<'EOF'
docs: add missing directory entries and package descriptions to architecture docs
Fill gaps identified in architecture audit: add artifacts/ and static/ to
directory tree, and document 7 auxiliary internal/ packages (textclean,
claudeconv, compat, rawsample, devcapture, util, version) in Section 3.
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
EOF
)"]]></dsml-parameter>
<dsml-parameter name="description"><![CDATA[Create commit with architecture doc updates]]></dsml-parameter>
</dsml-invoke>
</dsml-tool-calls>`
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 := `<tool-calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></tool-calls>`
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| `,

View File

@@ -555,6 +555,51 @@ func TestSieve_ChineseReviewSamplePreservesInlineDSMLMention(t *testing.T) {
}
}
func TestSieve_HyphenatedDSMLShellWithHereDocCDATA(t *testing.T) {
var state State
chunks := []string{
"<dsml-tool-calls>\n",
"<dsml-invoke name=\"Bash\">\n",
"<dsml-parameter name=\"command\"><![CDATA[git commit -m \"$(cat <<'EOF'\n",
"docs: add missing directory entries and package descriptions to architecture docs\n",
"Fill gaps identified in architecture audit: add artifacts/ and static/ to\n",
"directory tree, and document 7 auxiliary internal/ packages (textclean,\n",
"claudeconv, compat, rawsample, devcapture, util, version) in Section 3.\n\n",
"Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com\n",
"EOF\n",
")\"]]></dsml-parameter>\n",
"<dsml-parameter name=\"description\"><![CDATA[Create commit with architecture doc updates]]></dsml-parameter>\n",
"</dsml-invoke>\n",
"</dsml-tool-calls>",
}
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{

View File

@@ -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 = `<dsml-tool-calls>
<dsml-invoke name="Bash">
<dsml-parameter name="command"><![CDATA[git commit -m "$(cat <<'EOF'
docs: add missing directory entries and package descriptions to architecture docs
Fill gaps identified in architecture audit: add artifacts/ and static/ to
directory tree, and document 7 auxiliary internal/ packages (textclean,
claudeconv, compat, rawsample, devcapture, util, version) in Section 3.
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com
EOF
)"]]></dsml-parameter>
<dsml-parameter name="description"><![CDATA[Create commit with architecture doc updates]]></dsml-parameter>
</dsml-invoke>
</dsml-tool-calls>`;
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 = '<tool-calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></tool-calls>';
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([
'<dsml-tool-calls>\n',
'<dsml-invoke name="Bash">\n',
'<dsml-parameter name="command"><![CDATA[git commit -m "$(cat <<\'EOF\'\n',
'docs: add missing directory entries and package descriptions to architecture docs\n',
'Fill gaps identified in architecture audit: add artifacts/ and static/ to\n',
'directory tree, and document 7 auxiliary internal/ packages (textclean,\n',
'claudeconv, compat, rawsample, devcapture, util, version) in Section 3.\n\n',
'Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com\n',
'EOF\n',
')"]]></dsml-parameter>\n',
'<dsml-parameter name="description"><![CDATA[Create commit with architecture doc updates]]></dsml-parameter>\n',
'</dsml-invoke>\n',
'</dsml-tool-calls>',
], ['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' } }],