From 3d8bc326de3d8a55528d97a60b875f2d1ffee001 Mon Sep 17 00:00:00 2001
From: espokaos-ops <264269740+espokaos-ops@users.noreply.github.com>
Date: Fri, 11 Sep 2026 17:30:55 +0200
Subject: [PATCH 1/2] fix(deepseek-web): support native DSML tool calls
---
open-sse/translator/deepseekWebTools.ts | 220 ++++++++++++-
.../translator/deepseekWebTools.dsml.test.ts | 298 ++++++++++++++++++
2 files changed, 507 insertions(+), 11 deletions(-)
create mode 100644 tests/unit/translator/deepseekWebTools.dsml.test.ts
diff --git a/open-sse/translator/deepseekWebTools.ts b/open-sse/translator/deepseekWebTools.ts
index 3e2c7a179253..eb16c4aae242 100644
--- a/open-sse/translator/deepseekWebTools.ts
+++ b/open-sse/translator/deepseekWebTools.ts
@@ -14,6 +14,9 @@
// {json} doubled / nested wrappers
// x{json} XML children
// parameter style
+// <||DSML|| calls><||DSML|| invoke name="bash">… DeepSeek native DSML dialect
+// <||DSML|| parameter name="command">……> (full-width | U+FF5C,
+// closers are sloppy: ||DSML|| calls> or bare <||DSML|| invoke>)
//
// A single regex cannot robustly cover all of these (nesting + attributes + XML children),
// so this parser tokenizes the tool tags and walks them with a stack instead. It reuses the
@@ -77,7 +80,7 @@ export function serializeDeepSeekToolPrompt(tools: unknown): string {
"You can call tools. To call a tool, output ONLY this exact block (no markdown fence):",
`{"name": "", "arguments": { ... }, "_nonce": "${nonce}"}`,
"Rules:",
- "- Use exactly .... Do NOT use , , , , id=/name= attributes, or code fences.",
+ "- Use exactly .... Do NOT use , , , , id=/name= attributes, <||DSML||> markers, or code fences.",
`- Include the secret binding "_nonce": "${nonce}" exactly as shown.`,
'- "name" must be one of the tools below; "arguments" must be a JSON object.',
"- When a tool is needed, emit the block instead of only describing the plan.",
@@ -423,6 +426,170 @@ function extractCall(
return { name, arguments: toArgumentsString(argsValue) };
}
+// ── DeepSeek native DSML dialect ─────────────────────────────────────────────
+// The web model sometimes ignores the `` contract above and emits its
+// native pretraining-time tool dialect instead (observed live — without this
+// the reply degrades to plain text and the client never executes anything):
+// <||DSML|| calls>
+// <||DSML|| invoke name="bash">
+// <||DSML|| parameter name="command" string="true">free -h||DSML|| parameter>
+// <||DSML|| invoke>
+// ||DSML|| calls>
+// Markers use full-width pipes (|, U+FF5C); ASCII pipes are accepted
+// defensively. Closers are sloppy in the wild (`||DSML|| calls>` but bare
+// `<||DSML|| invoke>` / `<||DSML|| parameter>` with no slash), so a tag
+// carrying `name="…"` opens a block and the next same-kind tag closes it.
+// Like the XML-children/tag-suffix shapes, DSML blocks carry no JSON body with
+// a `_nonce`, so the #9343 nonce check does not apply to them.
+
+const DSML_PIPE = "[||]";
+const DSML_MARK = `${DSML_PIPE}{2}DSML${DSML_PIPE}{2}`;
+
+// Matches one DSML tag of the given logical kind (calls|invoke|parameter),
+// tolerating the slash before `<`, after the mark, or missing entirely.
+// Group 1 captures the raw attribute text when the tag carries any.
+function dsmlTagSource(kind: string): string {
+ return `(?:?${DSML_MARK}\\s*${kind}\\b([^>]*)>|<${DSML_MARK}\\s*/\\s*${kind}\\s*>)`;
+}
+
+function hasDsmlTags(text: string): boolean {
+ return new RegExp(`?${DSML_MARK}\\s*(?:calls|invoke|parameter)\\b`, "i").test(text);
+}
+
+// Any single DSML tag (open/close, sloppy or strict). Used to scrub DSML
+// noise out of `` blocks for the hybrid shape the model occasionally
+// emits: `{json}||DSML|| parameter>…` (JSON body with a valid
+// nonce, but DSML closers instead of ``).
+const DSML_ANY_TAG_RE = new RegExp(
+ `?${DSML_MARK}\\s*(?:calls|invoke|parameter)\\b[^>]*>|<${DSML_MARK}\\s*/\\s*(?:calls|invoke|parameter)\\s*>`,
+ "gi"
+);
+
+interface DsmlCall {
+ start: number;
+ end: number;
+ name: string;
+ args: Record;
+}
+
+interface DsmlParse {
+ calls: DsmlCall[];
+ // Wrapper `…` tag ranges so the envelope itself is stripped
+ // from the visible content together with the parsed invokes.
+ strip: Array<{ start: number; end: number }>;
+}
+
+// Extract DSML calls in document order. Reuses the shared getAttr /
+// resolveRequestedToolName helpers so name resolution behaves exactly like the
+// `` shapes (including the unknown-tool fallthrough).
+//
+// Two invocation sites share one builder: `` blocks inside a
+// `` envelope, and bare root-level `` blocks with no envelope
+// (P7 — same conversion either way). Fail-safe rule (L2): an invoke that
+// yields no parameter with a non-empty value produces NO call — an empty,
+// nameless or otherwise ambiguous invoke is skipped instead of emitting a
+// partially-built executable call.
+interface DsmlTag {
+ start: number;
+ end: number;
+ attrs: string;
+}
+
+function collectDsmlTags(source: string, kind: string, base: number): DsmlTag[] {
+ const out: DsmlTag[] = [];
+ const re = new RegExp(dsmlTagSource(kind), "gi");
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(source)) !== null) {
+ out.push({ start: base + m.index, end: base + re.lastIndex, attrs: m[1] ?? "" });
+ }
+ return out;
+}
+
+function buildDsmlCall(
+ open: DsmlTag,
+ close: DsmlTag | undefined,
+ text: string,
+ textEnd: number,
+ requested: RequestedToolName[]
+): DsmlCall | null {
+ const rawName = getAttr(open.attrs, "name");
+ if (!rawName) return null; // stray closer or nameless invoke — ignore
+ const innerEnd = close ? close.start : textEnd;
+ const inner = text.slice(open.end, innerEnd);
+ const ptags = collectDsmlTags(inner, "parameter", open.end);
+ const args: Record = {};
+ // End of the last consumed parameter: when the invoke has no closing tag,
+ // the strip range ends here instead of swallowing trailing legitimate text.
+ let consumedEnd = open.end;
+ for (let k = 0; k < ptags.length; k += 1) {
+ const pOpen = ptags[k];
+ const pName = getAttr(pOpen.attrs, "name");
+ if (!pName) continue;
+ const pClose = ptags[k + 1];
+ // Multiline-safe: value is everything up to the next parameter tag.
+ // Auxiliary attributes (string="true", …) are ignored; a content="…"
+ // attribute is only a fallback when the body is empty.
+ const rawBody = text.slice(pOpen.end, pClose ? pClose.start : innerEnd).trim();
+ args[pName] = rawBody !== "" ? rawBody : (getAttr(pOpen.attrs, "content") ?? "");
+ if (pClose) consumedEnd = pClose.end;
+ else consumedEnd = innerEnd;
+ }
+ // L2 fail-safe: without at least one non-empty argument value the call is
+ // ambiguous (empty invoke, nameless/valueless parameters) — skip it rather
+ // than emitting a partially-built executable call.
+ if (!Object.values(args).some((v) => v !== "")) return null;
+ const name = resolveRequestedToolName(rawName, requested) ?? rawName;
+ return { start: open.start, end: close ? close.end : consumedEnd, name, args };
+}
+
+function parseDsmlBlocks(text: string, requested: RequestedToolName[]): DsmlParse {
+ const out: DsmlCall[] = [];
+ const strip: Array<{ start: number; end: number }> = [];
+ const callsRe = new RegExp(
+ `<${DSML_MARK}\\s*calls\\s*>([\\s\\S]*?)(?:${DSML_MARK}\\s*calls\\s*>|<${DSML_MARK}\\s*/\\s*calls\\s*>)`,
+ "gi"
+ );
+ const closeRe = new RegExp(
+ `(?:${DSML_MARK}\\s*calls\\s*>|<${DSML_MARK}\\s*/\\s*calls\\s*>)\\s*$`,
+ "i"
+ );
+ const envelopeRanges: Array<{ start: number; end: number }> = [];
+ let cm: RegExpExecArray | null;
+ while ((cm = callsRe.exec(text)) !== null) {
+ const envelope = { start: cm.index, end: callsRe.lastIndex };
+ envelopeRanges.push(envelope);
+ const body = cm[1];
+ const bodyBase = cm.index + cm[0].indexOf(body);
+ const closeM = closeRe.exec(cm[0]);
+ strip.push({ start: cm.index, end: bodyBase });
+ strip.push({
+ start: cm.index + (closeM ? closeM.index : cm[0].length),
+ end: cm.index + cm[0].length,
+ });
+ const tags = collectDsmlTags(body, "invoke", bodyBase);
+ for (let i = 0; i < tags.length; i += 1) {
+ // A nameless tag is a stray closer, not an opener: skip it WITHOUT
+ // consuming the next tag (pre-harden pairing semantics).
+ if (!getAttr(tags[i].attrs, "name")) continue;
+ const call = buildDsmlCall(tags[i], tags[i + 1], text, bodyBase + body.length, requested);
+ if (call) out.push(call);
+ if (tags[i + 1]) i += 1; // consume the paired closer
+ }
+ }
+ // P7: bare root-level invokes outside any envelope convert identically.
+ // Tags already consumed inside envelopes are excluded so nothing is parsed twice.
+ const rootTags = collectDsmlTags(text, "invoke", 0).filter(
+ (t) => !envelopeRanges.some((r) => t.start >= r.start && t.end <= r.end)
+ );
+ for (let i = 0; i < rootTags.length; i += 1) {
+ if (!getAttr(rootTags[i].attrs, "name")) continue;
+ const call = buildDsmlCall(rootTags[i], rootTags[i + 1], text, text.length, requested);
+ if (call) out.push(call);
+ if (rootTags[i + 1]) i += 1; // consume the paired closer
+ }
+ return { calls: out.sort((a, b) => a.start - b.start), strip };
+}
+
// ── Public parser ─────────────────────────────────────────────────────────────
/**
@@ -442,7 +609,8 @@ export function parseDeepSeekToolCalls(
}
const tokens = tokenizeToolTags(text);
- if (tokens.length === 0) {
+ const dsmlPresent = hasDsmlTags(text);
+ if (tokens.length === 0 && !dsmlPresent) {
// No DeepSeek-specific tags — defer to the proven canonical parser (bare JSON, etc.).
return parseToolCallsFromText(text, idSeed, requestedTools);
}
@@ -456,8 +624,7 @@ export function parseDeepSeekToolCalls(
const isLeaf = (b: ToolBlock) =>
!blocks.some((o) => o !== b && o.open.start >= b.innerStart && o.close.end <= b.innerEnd);
- const toolCalls: OpenAIToolCall[] = [];
- const acceptedRanges: Array<{ start: number; end: number }> = [];
+ const found: Array<{ start: number; end: number; call: ExtractedCall }> = [];
const nonce = getToolNonce(requestedTools);
for (const block of blocks.filter(isLeaf).sort((a, b) => a.open.start - b.open.start)) {
@@ -466,7 +633,7 @@ export function parseDeepSeekToolCalls(
getAttr(block.open.attrs, "name") ||
getAttr(block.open.attrs, "id") ||
"";
- const inner = text.slice(block.innerStart, block.innerEnd);
+ const inner = text.slice(block.innerStart, block.innerEnd).replace(DSML_ANY_TAG_RE, "");
const call = extractCall(tagName, inner, requested, schemaMap);
if (!call) continue;
@@ -482,15 +649,29 @@ export function parseDeepSeekToolCalls(
if (parsed && typeof parsed.name === "string" && parsed._nonce !== undefined && parsed._nonce !== nonce) continue;
}
- toolCalls.push({
- id: `${idSeed}_${toolCalls.length}`,
- type: "function",
- function: { name: call.name, arguments: call.arguments },
+ found.push({
+ start: block.open.start,
+ end: block.close.end,
+ call,
});
- acceptedRanges.push({ start: block.open.start, end: block.close.end });
}
- if (toolCalls.length === 0) {
+ // Native DSML dialect (no nonce concept — same policy as the XML/tag-suffix
+ // shapes, which likewise carry no JSON body to bind).
+ let dsmlStrip: Array<{ start: number; end: number }> = [];
+ if (dsmlPresent) {
+ const dsml = parseDsmlBlocks(text, requested);
+ dsmlStrip = dsml.strip;
+ for (const d of dsml.calls) {
+ found.push({
+ start: d.start,
+ end: d.end,
+ call: { name: d.name, arguments: toArgumentsString(d.args) },
+ });
+ }
+ }
+
+ if (found.length === 0) {
// Tags were present but none parsed (e.g. malformed or nonce-rejected).
// Do NOT fall back to parseToolCallsFromText — that would re-process content
// already seen by this parser and potentially promote rejected tagged output
@@ -498,12 +679,29 @@ export function parseDeepSeekToolCalls(
return { content: text, toolCalls: null };
}
+ // Document order across both dialects so mixed DSML + replies keep a
+ // stable, deterministic id sequence.
+ found.sort((a, b) => a.start - b.start);
+ const toolCalls: OpenAIToolCall[] = [];
+ const acceptedRanges: Array<{ start: number; end: number }> = [];
+ for (const f of found) {
+ toolCalls.push({
+ id: `${idSeed}_${toolCalls.length}`,
+ type: "function",
+ function: { name: f.call.name, arguments: f.call.arguments },
+ });
+ acceptedRanges.push({ start: f.start, end: f.end });
+ }
+
// Strip the accepted blocks plus any stray tool tags left outside them (the unmatched outer
// `` of a doubled wrapper, leftover `` of a non-leaf wrapper, etc.).
+ // DSML wrapper tags are stripped via dsmlStrip (invoke ranges already cover
+ // the calls they belong to).
const within = (tok: TagToken) =>
acceptedRanges.some((r) => tok.start >= r.start && tok.end <= r.end);
const ranges = [
...acceptedRanges,
+ ...dsmlStrip,
...tokens.filter((t) => !within(t)).map((t) => ({ start: t.start, end: t.end })),
];
diff --git a/tests/unit/translator/deepseekWebTools.dsml.test.ts b/tests/unit/translator/deepseekWebTools.dsml.test.ts
new file mode 100644
index 000000000000..46879206aa15
--- /dev/null
+++ b/tests/unit/translator/deepseekWebTools.dsml.test.ts
@@ -0,0 +1,298 @@
+// Permanent tests for DeepSeek Web's native DSML dialect.
+// Canonical project location: tests/unit/** (see package.json → test:unit).
+// Run: node --import tsx --test tests/unit/translator/deepseekWebTools.dsml.test.ts
+// (workdir = omniroute package root).
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { parseDeepSeekToolCalls } from "../../../open-sse/translator/deepseekWebTools.ts";
+
+const bashTools = [
+ {
+ type: "function",
+ function: {
+ name: "bash",
+ description: "run shell",
+ parameters: {
+ type: "object",
+ properties: { command: { type: "string" } },
+ required: ["command"],
+ },
+ },
+ },
+];
+
+const multiTools = [
+ {
+ type: "function",
+ function: {
+ name: "write_file",
+ description: "write",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string" },
+ content: { type: "string" },
+ },
+ required: ["path", "content"],
+ },
+ },
+ },
+];
+
+const DSML_FREE = `<||DSML|| calls>
+<||DSML|| invoke name="bash">
+<||DSML|| parameter name="command" string="true">free -h||DSML|| parameter>
+<||DSML|| invoke>
+||DSML|| calls>`;
+
+function argsOf(r: { toolCalls: Array<{ function: { arguments: string } }> | null }, i = 0) {
+ assert.ok(r.toolCalls, "expected toolCalls, got null");
+ return JSON.parse(r.toolCalls[i].function.arguments) as Record;
+}
+
+describe("deepseekWebTools DSML", () => {
+ it("1. canonical + bash + echo HOLA (regression)", () => {
+ const r = parseDeepSeekToolCalls(
+ `{"name":"bash","arguments":{"command":"echo HOLA"}}`,
+ "t1",
+ bashTools
+ );
+ assert.equal(r.toolCalls?.length, 1);
+ assert.equal(r.toolCalls![0].function.name, "bash");
+ assert.deepEqual(argsOf(r), { command: "echo HOLA" });
+ assert.equal(r.content.trim(), "");
+ });
+
+ it("2. DSML + bash + free -h", () => {
+ const r = parseDeepSeekToolCalls(DSML_FREE, "t2", bashTools);
+ assert.equal(r.toolCalls?.length, 1);
+ assert.equal(r.toolCalls![0].type, "function");
+ assert.equal(r.toolCalls![0].function.name, "bash");
+ assert.deepEqual(argsOf(r), { command: "free -h" });
+ assert.equal(r.content.trim(), "");
+ });
+
+ it("3. DSML + bash + uname -a", () => {
+ const r = parseDeepSeekToolCalls(
+ DSML_FREE.replace("free -h", "uname -a"),
+ "t3",
+ bashTools
+ );
+ assert.deepEqual(argsOf(r), { command: "uname -a" });
+ });
+
+ it("4. DSML with multiple parameters", () => {
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| calls>\n<||DSML|| invoke name="write_file">\n` +
+ `<||DSML|| parameter name="path" string="true">/tmp/x.txt||DSML|| parameter>\n` +
+ `<||DSML|| parameter name="content" string="true">hola mundo||DSML|| parameter>\n` +
+ `<||DSML|| invoke>\n||DSML|| calls>`,
+ "t4",
+ multiTools
+ );
+ assert.equal(r.toolCalls?.length, 1);
+ assert.equal(r.toolCalls![0].function.name, "write_file");
+ assert.deepEqual(argsOf(r), { path: "/tmp/x.txt", content: "hola mundo" });
+ });
+
+ it("5. DSML with multiple invokes", () => {
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| calls>\n` +
+ `<||DSML|| invoke name="bash">\n<||DSML|| parameter name="command">free -h||DSML|| parameter>\n<||DSML|| invoke>\n` +
+ `<||DSML|| invoke name="bash">\n<||DSML|| parameter name="command">uname -a||DSML|| parameter>\n<||DSML|| invoke>\n` +
+ `||DSML|| calls>`,
+ "t5",
+ bashTools
+ );
+ assert.equal(r.toolCalls?.length, 2);
+ assert.equal(r.toolCalls![0].id, "t5_0");
+ assert.equal(r.toolCalls![1].id, "t5_1");
+ assert.deepEqual(argsOf(r, 0), { command: "free -h" });
+ assert.deepEqual(argsOf(r, 1), { command: "uname -a" });
+ assert.equal(r.content.trim(), "");
+ });
+
+ it("6. DSML multiline values", () => {
+ const script = "echo línea1\necho línea2\necho fin";
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter name="command" string="true">${script}||DSML|| parameter>\n` +
+ `<||DSML|| invoke>\n||DSML|| calls>`,
+ "t6",
+ bashTools
+ );
+ assert.deepEqual(argsOf(r), { command: script });
+ });
+
+ it("7. plain text without tools → null", () => {
+ const r = parseDeepSeekToolCalls("Tienes 14 GiB de RAM.", "t7", bashTools);
+ assert.equal(r.toolCalls, null);
+ assert.equal(r.content, "Tienes 14 GiB de RAM.");
+ });
+
+ it("8. unknown tool: DSML ↔ canonical parity", () => {
+ const canon = parseDeepSeekToolCalls(
+ `{"name":"frobnicate","arguments":{"x":1}}`,
+ "t8a",
+ bashTools
+ );
+ const dsml = parseDeepSeekToolCalls(
+ `<||DSML|| calls>\n<||DSML|| invoke name="frobnicate">\n` +
+ `<||DSML|| parameter name="x" string="true">1||DSML|| parameter>\n` +
+ `<||DSML|| invoke>\n||DSML|| calls>`,
+ "t8b",
+ bashTools
+ );
+ // Paridad con el path : el nombre desconocido se propaga tal cual.
+ assert.equal(dsml.toolCalls?.[0]?.function?.name, canon.toolCalls?.[0]?.function?.name);
+ assert.equal(dsml.toolCalls?.length, 1);
+ });
+
+ it("9. DSML + coexistence in document order", () => {
+ const r = parseDeepSeekToolCalls(
+ `${DSML_FREE}\n{"name":"bash","arguments":{"command":"echo HOLA"}}`,
+ "t9",
+ bashTools
+ );
+ assert.equal(r.toolCalls?.length, 2);
+ assert.deepEqual(argsOf(r, 0), { command: "free -h" });
+ assert.deepEqual(argsOf(r, 1), { command: "echo HOLA" });
+ assert.equal(r.content.trim(), "");
+ });
+
+ it("10. JSON escaping in values", () => {
+ const cmd = `echo "hola \\ mundo" && ls 'C:\\tmp'`;
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter name="command" string="true">${cmd}||DSML|| parameter>\n` +
+ `<||DSML|| invoke>\n||DSML|| calls>`,
+ "t10",
+ bashTools
+ );
+ // Debe sobrevivir como JSON válido y redondo.
+ assert.deepEqual(argsOf(r), { command: cmd });
+ assert.doesNotThrow(() => JSON.parse(r.toolCalls![0].function.arguments));
+ });
+
+ it("11. ASCII marker variant", () => {
+ const ascii = DSML_FREE.replace(/|/g, "|");
+ assert.ok(ascii.includes("<||DSML|| calls>"));
+ const r = parseDeepSeekToolCalls(ascii, "t11", bashTools);
+ assert.equal(r.toolCalls?.length, 1);
+ assert.deepEqual(argsOf(r), { command: "free -h" });
+ });
+
+ it("12. hybrid {json} + DSML closers (observed live)", () => {
+ const hybrid =
+ `{"name": "bash", "arguments": {"command": "uname -a"}}` +
+ `||DSML|| parameter>\n||DSML|| invoke>\n||DSML|| calls>`;
+ const r = parseDeepSeekToolCalls(hybrid, "t12", bashTools);
+ assert.equal(r.toolCalls?.length, 1);
+ assert.equal(r.toolCalls![0].function.name, "bash");
+ assert.deepEqual(argsOf(r), { command: "uname -a" });
+ assert.ok(!r.content.includes("DSML"), r.content);
+ });
+
+ it("13. DSML invoke without envelope", () => {
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter name="command" string="true">echo DOS||DSML|| parameter>\n` +
+ `<||DSML|| invoke>`,
+ "t13",
+ bashTools
+ );
+ assert.equal(r.toolCalls?.length, 1);
+ assert.equal(r.toolCalls![0].function.name, "bash");
+ assert.deepEqual(argsOf(r), { command: "echo DOS" });
+ assert.ok(!r.content.includes("DSML"), r.content);
+ });
+
+ it("14. envelopeless DSML + in order", () => {
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter name="command" string="true">echo DOS||DSML|| parameter>\n` +
+ `<||DSML|| invoke>\n` +
+ `{"name":"bash","arguments":{"command":"echo UNO"}}`,
+ "t14",
+ bashTools
+ );
+ assert.equal(r.toolCalls?.length, 2);
+ assert.deepEqual(argsOf(r, 0), { command: "echo DOS" });
+ assert.deepEqual(argsOf(r, 1), { command: "echo UNO" });
+ assert.equal(r.content.trim(), "");
+ });
+
+ it("15. + envelopeless DSML in reverse order", () => {
+ const r = parseDeepSeekToolCalls(
+ `{"name":"bash","arguments":{"command":"echo UNO"}}\n` +
+ `<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter name="command" string="true">echo DOS||DSML|| parameter>\n` +
+ `<||DSML|| invoke>`,
+ "t15",
+ bashTools
+ );
+ assert.equal(r.toolCalls?.length, 2);
+ assert.deepEqual(argsOf(r, 0), { command: "echo UNO" });
+ assert.deepEqual(argsOf(r, 1), { command: "echo DOS" });
+ assert.equal(r.content.trim(), "");
+ });
+
+ it("16. invoke without valid parameters → no tool call (L2 fail-safe)", () => {
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n<||DSML|| invoke>\n||DSML|| calls>`,
+ "t16",
+ bashTools
+ );
+ assert.equal(r.toolCalls, null);
+ });
+
+ it("17. parameter without name → no tool call for that invoke (L2 fail-safe)", () => {
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter string="true">echo HOLA||DSML|| parameter>\n` +
+ `<||DSML|| invoke>\n||DSML|| calls>`,
+ "t17",
+ bashTools
+ );
+ assert.equal(r.toolCalls, null);
+ });
+
+ it("18. empty parameter → fail-safe, no tool call (L2)", () => {
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| calls>\n<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter name="command">||DSML|| parameter>\n` +
+ `<||DSML|| invoke>\n||DSML|| calls>`,
+ "t18",
+ bashTools
+ );
+ assert.equal(r.toolCalls, null);
+ });
+
+ it("19. envelope + root invoke coexist in order", () => {
+ const r = parseDeepSeekToolCalls(
+ `${DSML_FREE}\n` +
+ `<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter name="command" string="true">uname -a||DSML|| parameter>\n` +
+ `<||DSML|| invoke>`,
+ "t19",
+ bashTools
+ );
+ assert.equal(r.toolCalls?.length, 2);
+ assert.deepEqual(argsOf(r, 0), { command: "free -h" });
+ assert.deepEqual(argsOf(r, 1), { command: "uname -a" });
+ assert.equal(r.content.trim(), "");
+ });
+
+ it("20. unclosed root invoke keeps trailing text (audit C)", () => {
+ const r = parseDeepSeekToolCalls(
+ `<||DSML|| invoke name="bash">\n` +
+ `<||DSML|| parameter name="command">echo X||DSML|| parameter>\n` +
+ `Texto legítimo después.`,
+ "t20",
+ bashTools
+ );
+ assert.equal(r.toolCalls?.length, 1);
+ assert.deepEqual(argsOf(r), { command: "echo X" });
+ assert.equal(r.content.trim(), "Texto legítimo después.");
+ });
+});
From dbce901bffa1aae31cd3e73c80612665d6ede025 Mon Sep 17 00:00:00 2001
From: espokaos-ops <264269740+espokaos-ops@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:13:47 +0100
Subject: [PATCH 2/2] docs: add changelog for DeepSeek Web DSML tool calls
---
changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md
diff --git a/changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md b/changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md
new file mode 100644
index 000000000000..230521557580
--- /dev/null
+++ b/changelog.d/fixes/13345-deepseek-web-dsml-tool-calls.md
@@ -0,0 +1 @@
+- **fix(deepseek-web):** handle native DeepSeek Web DSML tool calls (`` / `` / ``, including root-level invokes) so they parse into tool calls instead of leaking as plain text ([#13345](https://github.com/diegosouzapw/OmniRoute/pull/13345)) — thanks @espokaos-ops