Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
cd1df1f9ff fix(sse): parse deepseek-web double-pipe DSML invoke/parameter markup (#14208)
deepseek-web's tool-call parser only recognized tags literally named
`tool`/`tool_call`. Some harness builds instead emit a well-formed grammar
wrapped in doubled full-width-pipe "DSML" namespace markers
(`<||DSML|| calls>`, `<||DSML|| invoke name="...">`,
`<||DSML|| parameter name="...">`), which neither this parser, the generic
single-pipe DSML normalizer, nor the canonical <tool> fallback recognized —
the block was silently dropped and the raw DSML text leaked to the user.

Add normalizeDsmlInvokeMarkup() to rewrite the DSML calls/invoke/parameter
grammar (single- and double-pipe) into the canonical <tool>/<parameter> tags
before tokenization, so parseDeepSeekToolCalls resolves it into a proper
tool_calls entry.
2026-09-22 05:43:41 -03:00
4 changed files with 139 additions and 0 deletions

View File

@@ -0,0 +1 @@
- fix(sse): parse deepseek-web double-pipe DSML invoke/parameter tool-call markup (#14208)

View File

@@ -470,6 +470,40 @@ function extractCall(
return { name, arguments: toArgumentsString(argsValue) };
}
// ── DSML invoke-markup normalization ────────────────────────────────────────
//
// Some DeepSeek-web harness builds emit tool calls wrapped in a different, well-formed
// grammar using doubled full-width-pipe "DSML" namespace markers with space-separated
// structural words instead of the `<tool>`/`<tool_call>` vocabulary above:
// <||DSML|| calls> <||DSML|| invoke name="write">
// <||DSML|| parameter name="file_path" string="true">...</||DSML|| parameter>
// </||DSML|| invoke> </||DSML|| calls>
// (see #14208). Neither TAG_TOKEN_RE above nor the generic single-pipe `dsmlToolCalls.ts`
// normalizer recognize this double-pipe shape. Rather than teach every downstream consumer
// this namespace, rewrite it into the canonical `<tool>`/`<parameter>` tags this file's own
// tokenizer already understands (same pattern the `dsmlToolCalls.ts` module documents for
// DeepSeek's single-pipe `<|DSML|:Tool>` shape), before tokenization runs.
const DSML_INVOKE_OPEN_RE = /<[||]{1,2}DSML[||]{1,2}\s+(calls|invoke|parameter)([^>]*)>/gi;
const DSML_INVOKE_CLOSE_RE = /<\/[||]{1,2}DSML[||]{1,2}\s+(calls|invoke|parameter)\s*>/gi;
function normalizeDsmlInvokeMarkup(text: string): string {
if (!text.includes("DSML")) return text;
const withOpens = text.replace(DSML_INVOKE_OPEN_RE, (_full, word: string, attrs: string) => {
const tag = word.toLowerCase();
if (tag === "calls") return "";
const name = getAttr(attrs, "name");
const nameAttr = name !== null ? ` name="${name}"` : "";
return tag === "invoke" ? `<tool${nameAttr}>` : `<parameter${nameAttr}>`;
});
return withOpens.replace(DSML_INVOKE_CLOSE_RE, (_full, word: string) => {
const tag = word.toLowerCase();
if (tag === "calls") return "";
return tag === "invoke" ? "</tool>" : "</parameter>";
});
}
// ── Public parser ─────────────────────────────────────────────────────────────
/**
@@ -488,6 +522,8 @@ export function parseDeepSeekToolCalls(
return { content: text ?? "", toolCalls: null };
}
text = normalizeDsmlInvokeMarkup(text);
const tokens = tokenizeToolTags(text);
if (tokens.length === 0) {
// No DeepSeek-specific tags — defer to the proven canonical parser (bare JSON, etc.).

View File

@@ -0,0 +1,63 @@
// Coverage for the #14208 DSML invoke/parameter normalization in
// open-sse/translator/deepseekWebTools.ts::normalizeDsmlInvokeMarkup (applied at the top of
// parseDeepSeekToolCalls). See issue-14208-deepseek-web-dsml-invoke-format.test.ts for the
// exact reported-payload repro; this file covers the surrounding contract.
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts";
const WRITE_TOOL = [
{
type: "function",
function: { name: "write", parameters: { properties: { file_path: {}, content: {} } } },
},
];
test("deepseek-web DSML invoke: double-pipe DSML markup with Cyrillic content and a Windows path resolves to a write call", () => {
const raw = `<||DSML|| calls> <||DSML|| invoke name="write"> <||DSML|| parameter name="file_path" string="true">D:\\Проекты\\файл.txt</||DSML|| parameter> <||DSML|| parameter name="content" string="true">Привет, мир.</||DSML|| parameter> </||DSML|| invoke> </||DSML|| calls>`;
const { content, toolCalls } = parseDeepSeekToolCalls(raw, "call", WRITE_TOOL);
assert.ok(toolCalls, "expected a parsed tool call");
assert.equal(toolCalls!.length, 1);
assert.equal(toolCalls![0].function.name, "write");
const args = JSON.parse(toolCalls![0].function.arguments);
assert.equal(args.file_path, "D:\\Проекты\\файл.txt");
assert.equal(args.content, "Привет, мир.");
assert.ok(!content.includes("DSML"), `raw DSML markup leaked into content: ${content}`);
});
test("deepseek-web DSML invoke: single-pipe defensive variant also resolves to a write call", () => {
const raw = `<|DSML| calls> <|DSML| invoke name="write"> <|DSML| parameter name="file_path" string="true">notes.txt</|DSML| parameter> <|DSML| parameter name="content" string="true">hello</|DSML| parameter> </|DSML| invoke> </|DSML| calls>`;
const { content, toolCalls } = parseDeepSeekToolCalls(raw, "call", WRITE_TOOL);
assert.ok(toolCalls, "expected a parsed tool call for the single-pipe DSML variant");
assert.equal(toolCalls!.length, 1);
assert.equal(toolCalls![0].function.name, "write");
const args = JSON.parse(toolCalls![0].function.arguments);
assert.equal(args.file_path, "notes.txt");
assert.equal(args.content, "hello");
assert.ok(!content.includes("DSML"), `raw DSML markup leaked into content: ${content}`);
});
test("deepseek-web DSML invoke: an invoke naming a tool that was never requested degrades gracefully (no throw) and matches the canonical <tool> tag's existing contract", () => {
// The DSML normalizer must not change the pre-existing name-resolution contract of the
// canonical `<tool>` tag it rewrites into: an unresolved name still falls through via the
// same raw-tag-name fallback `extractCall` already applies today (see #3260) — normalizing
// must reproduce that behavior exactly, not invent a stricter one.
const dsmlRaw = `<||DSML|| calls> <||DSML|| invoke name="delete_everything"> <||DSML|| parameter name="path" string="true">/</||DSML|| parameter> </||DSML|| invoke> </||DSML|| calls>`;
const canonicalRaw = `<tool name="delete_everything"><parameter name="path">/</parameter></tool>`;
let dsmlResult: ReturnType<typeof parseDeepSeekToolCalls> | undefined;
assert.doesNotThrow(() => {
dsmlResult = parseDeepSeekToolCalls(dsmlRaw, "call", WRITE_TOOL);
});
const canonicalResult = parseDeepSeekToolCalls(canonicalRaw, "call", WRITE_TOOL);
assert.deepEqual(
dsmlResult!.toolCalls?.map((c) => ({ name: c.function.name, args: c.function.arguments })),
canonicalResult.toolCalls?.map((c) => ({ name: c.function.name, args: c.function.arguments })),
"normalized DSML markup must resolve identically to the equivalent canonical <tool> tag"
);
});

View File

@@ -0,0 +1,39 @@
// Repro for issue #14208 — deepseek-web "DSML" invoke/parameter markup is not parsed into
// tool_calls. Unlike the malformed-trailing-garbage shape fixed by PR #13226
// (`<tool>{json}` immediately followed by corrupted pseudo-tags), this shape is a
// COMPLETE, well-nested block using a different tag vocabulary entirely:
// <||DSML|| calls> <||DSML|| invoke name="write">
// <||DSML|| parameter name="file_path" string="true">...</||DSML|| parameter>
// <||DSML|| parameter name="content" string="true">...</||DSML|| parameter>
// </||DSML|| invoke> </||DSML|| calls>
//
// `parseDeepSeekToolCalls` only recognizes tags literally named `tool`/`tool_call`
// (TAG_TOKEN_RE in deepseekWebTools.ts), so it never tokenizes this text and falls back to
// the canonical `parseToolCallsFromText`, which requires a literal `<tool>`/`<tool_call`
// substring — also absent here. Net result: toolCalls stays null and the raw DSML text is
// returned as content, exactly matching the issue's reported symptom.
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts";
const RAW = `<||DSML|| calls> <||DSML|| invoke name="write"> <||DSML|| parameter name="file_path" string="true">D:\\Projects\\maxnadeev.ru\\1.txt</||DSML|| parameter> <||DSML|| parameter name="content" string="true">Random text.</||DSML|| parameter> </||DSML|| invoke> </||DSML|| calls>`;
test("issue #14208: deepseek-web DSML invoke/parameter markup is parsed into a tool call", () => {
const requestedTools = [
{
type: "function",
function: { name: "write", parameters: { properties: { file_path: {}, content: {} } } },
},
];
const { content, toolCalls } = parseDeepSeekToolCalls(RAW, "call", requestedTools);
assert.ok(toolCalls, "expected the DSML invoke block to be parsed into tool_calls, got null");
assert.equal(toolCalls!.length, 1);
assert.equal(toolCalls![0].function.name, "write");
const args = JSON.parse(toolCalls![0].function.arguments);
assert.equal(args.file_path, "D:\\Projects\\maxnadeev.ru\\1.txt");
assert.equal(args.content, "Random text.");
// The raw DSML markers must not leak into the content shown to the user.
assert.ok(!content.includes("DSML"), `raw DSML markup leaked into content: ${content}`);
});