Skip to content

DSML leak: a closing tag like </||DSML|| parameter> still stalls the agent loop (closing tags / pipe variants / cross-chunk splits) #837

Open

Description

@ZhulongNT

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)

  1. llmcore.py: one variant-agnostic marker regex (optional /, [||‖]{1,2}, optional whitespace around the DSML token, any payload up to >), applied on all text paths (chat_completions stream, responses stream, non-streaming JSON, and the tool-call text blocks).
  2. 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 as response.dsml_leak).
  3. ga.py: do_no_tool retries 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.py and run in the existing desktop CI job (no network access needed).

Activity

  1. lunxian8 commented on Oct 21, 2026

    @lunxian8

    遇到一样的问题

  2. lunxian8 commented on Oct 22, 2026

    @lunxian8

    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:

    <ask_user">
    <question>which one?</question>
    <candidates><item>a</item><item>b</item></candidates>
    </ask_user>
    

    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_exit exits the turn, so the session stalls - that is what users see.

    Local mitigation (tests included, 18 pass):

    1. 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 raw code_run scripts are never misparsed.
    2. The retry prompt now states the accepted text form: <code_run>{"script": "..."}</code_run> - a JSON object body and no quote after the tag name.
    3. Added frontends/tests/test_text_tool_call_recovery.py covering 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_user call 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.

  3. ZhulongNT commented on Oct 27, 2026

    @ZhulongNT
    ContributorAuthor

    Another shape that survives the current fixes — two stalls on 2026-10-27 (both sessions
    ended with finish_reason=stop, no tool_use, and the user had to type "continue"):

    <code_run><script>print(1)</script><parameter name="type">python</parameter></code_run></invoke></function>
    

    (<script> used as the parameter container, stray </invoke>/</function> after the
    closing 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;
    • anything after </code_run> makes split_text_tool_calls return "not a call";
    • a value truncated mid-parameter is not reported as partial, so no retry fires and the
      turn ends silently.

    Fixed in PR #838 (rebased onto e4d956f, two new commits): container names are mapped to
    declared parameter names, a tail of stray closing tags is tolerated, and a truncated call
    returns partial so the turn is retried. Also fixed there: the no-tool guard now ignores
    markup 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_run call; 6 tests added, frontends/tests
    30 passed on that branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions