Repository navigation
DSML leak: a closing tag like </||DSML|| parameter> still stalls the agent loop (closing tags / pipe variants / cross-chunk splits) #837
Description
Symptom
DeepSeek (chat_completions streaming) sometimes emits its DSML tool-call markup as plain content text instead of a real tool call. The agent loop then stalls: the raw markup is appended to the conversation, is sent back on the next request, and the model keeps regenerating broken DSML instead of calling a tool.
The concrete case that blocks my sessions is a closing tag of a non-calls marker:
</||DSML|| parameter>
</|DSML| parameter>
</||DSML|| parameter>
(also seen with the invoke / parameter / tool_calls marker families, and with |/‖ single/double pipe variants)
Why the existing fixes do not cover it
This is a follow-up to the closed issue #804 and the closed (unmerged) PR #805, and to the merged workaround f7e4cc5 / the still-open PR #836. Three holes remain:
| # | Hole | Where |
|---|---|---|
| 1 | The marker regex requires < to be immediately followed by the pipe+DSML sequence, so it never matched a closing tag </...DSML...>; it also only accepted the single | (U+FF5C) pipe, not ` |
or the double||/‖` variants DeepSeek actually emits. A leaked closing tag is therefore passed through untouched. |
| 2 | Filtering is applied per stream chunk with a fast path (if "DSML" not in text: return text), so a marker split across two chunks (`"hello<|" + " |
DSML||tool_calls>world"`) is never reassembled and never matches. Streaming is exactly where the leak appears. |
| 3 | The non-streaming path (_parse_openai_json, api_mode other than chat_completions) never filtered content at all. |
llmcore.py _parse_openai_json |
| 4 | The no-tool guard only reacts to an exact suffix: content.rstrip().endswith('</|DSML| calls>'). A leak anywhere except the very end of the turn - and every closing tag of a non-calls marker, which is the case in the title - passes straight through. |
ga.py do_no_tool (f7e4cc5) |
Reproductions of 1-3 are pure string/stream assertions (no network, no API key) and are included in the PR:
# closing tag of a non-calls marker: expected "", actual "</|DSML| parameter>" style residue
strip("</||DSML|| parameter>")
# cross-chunk split: expected "helloworld", actual "hello<||DSML||tool_calls>world"
"".join(strip(c) for c in ["hello<|", "|DSML||tool_calls>", "world"])Proposed fix (PR: #838)
llmcore.py: one variant-agnostic marker regex (optional/,[||‖]{1,2}, optional whitespace around theDSMLtoken, any payload up to>), applied on all text paths (chat_completions stream, responses stream, non-streaming JSON, and the tool-call text blocks).llmcore.py: a small stateful stream filter that keeps a trailing partial<...in a hold buffer and re-joins it with the next chunk, so split markers are matched exactly once and in order. A leak also sets a per-response latch (exposed asresponse.dsml_leak).ga.py:do_no_toolretries on that latch (positive signal) and keeps the cheap tail check as a fallback for other code paths, instead of relying on one hardcoded suffix.
Notes
- The fix is deliberately conservative: normal tool calls are unaffected (a real tool call is parsed by the provider adapter before this text path), and the tests assert that ordinary text and the useful payload between two leaked markers are preserved (
<... parameter name="cmd">ls -la</... parameter>->ls -la). - Tests live in
frontends/tests/test_dsml_marker_strip.pyand run in the existing desktop CI job (no network access needed).
Activity
llmcore/agent_loop/ga: execute tool calls the model wrote as text (ls…
agent_loop/ga: actually recover text-channel tool calls (schema name …
lunxian8 commentedon Oct 21, 2026
遇到一样的问题
lunxian8 commentedon Oct 22, 2026
Same family here - one more variant worth pinning, plus a local mitigation that fully removed the stall.
Observed today with deepseek-flash (chat_completions streaming), three consecutive regenerations emitted:
Two defects there: a stray quote after the tag name (
<ask_user">), and parameters emitted as bare child tags instead of<parameter name="...">.The guard behaves correctly (no real tool call -> partial -> retry), but after three retries
_retry_or_exitexits the turn, so the session stalls - that is what users see.Local mitigation (tests included, 18 pass):
llmcore.split_text_tool_calls: if a trailing call body has no<parameter>tags, parse bare<tag>...</tag>children into arguments (nested same-name children become a list). Only bodies starting with<are considered, so rawcode_runscripts are never misparsed.<code_run>{"script": "..."}</code_run>- a JSON object body and no quote after the tag name.frontends/tests/test_text_tool_call_recovery.pycovering the stray-quote tag, bare child tags, truncated<code_run">, raw-script false positives, and<parameter>precedence.Result: the case above is now recovered and executed as a real
ask_usercall instead of aborting after three retries.Complementary to the DSML marker stripping in #838: marker leak and call-written-as-plain-XML are two shapes of the same stall.
llmcore/agent_loop/ga: execute tool calls the model wrote as text (ls…
agent_loop/ga: actually recover text-channel tool calls (schema name …
llmcore: recover more text-channel call variants (lsdefine#837)
ZhulongNT commentedon Oct 27, 2026
Another shape that survives the current fixes — two stalls on 2026-10-27 (both sessions
ended with
finish_reason=stop, notool_use, and the user had to type "continue"):(
<script>used as the parameter container, stray</invoke>/</function>after theclosing tag, and one sample truncated mid-value so it never even closed.)
Why the existing matcher misses it:
<script>is not accepted as a parameter container, so the body has no args;</code_run>makessplit_text_tool_callsreturn "not a call";partial, so no retry fires and theturn ends silently.
Fixed in PR #838 (rebased onto
e4d956f, two new commits): container names are mapped todeclared parameter names, a tail of stray closing tags is tolerated, and a truncated call
returns
partialso the turn is retried. Also fixed there: the no-tool guard now ignoresmarkup that is quoted in code spans/fences (a reply that discusses this bug was being
rejected as a leak), while a fence at the very end still counts, since that is where a
leaked call actually hides.
Both 2026-10-27 stalls now parse into a
code_runcall; 6 tests added,frontends/tests30 passed on that branch.