Skip to content

fix: strip DeepSeek DSML tool-call markers from assistant content - #805

Closed
sharadvc wants to merge 1 commit into
lsdefine:mainfrom
sharadvc:fix/dsml-marker-stripping
Closed

sharadvc wants to merge 1 commit into
lsdefine:mainfrom
sharadvc:fix/dsml-marker-stripping

Conversation

@sharadvc

Copy link
Copy Markdown

Problem

DeepSeek's DSML protocol injects tool-call routing markers into the content stream before the actual tool_calls delta arrives. These markers (e.g. <‖DSML‖tool_calls>, <‖DSML‖invoke ...>) leak into the UI when tool calls fail or aren't parsed in time.

Fix

Adds _strip_dsml_markers() — a fast regex filter applied to every content chunk yielded by _parse_openai_sse().

  • Zero overhead on non-DeepSeek streams: the guard short-circuits on chunks that don't contain 'DSML'
  • Catches all known marker variants: full-width ‖, Unicode |, ASCII pipe |, and double-escaped forms
  • Applied at both the streaming yield point (real-time UI) and the final block assembly (fallback)

Testing

  • Verified with synthetic SSE chunks containing <‖DSML‖tool_calls>, <‖DSML‖invoke name="ShellCommand" arguments="{...}"> — all stripped cleanly
  • Non-DeepSeek streams unaffected (guard short-circuits)

Closes #804

DeepSeek's DSML protocol injects tool-call routing markers (e.g.
<‖DSML‖tool_calls>, <‖DSML‖invoke ...>) into the content stream
before the actual tool_calls delta arrives.  These markers leak into
the UI when tool calls fail or are not parsed in time.

This adds _strip_dsml_markers() — a fast regex filter applied to every
content chunk yielded by _parse_openai_sse().  The guard short-circuits
on chunks that don't contain 'DSML' so there is zero overhead on
non-DeepSeek streams.

Closes lsdefine#804
@sharadvc

Copy link
Copy Markdown
Author

Friendly bump — DeepSeek DSML strip is ready for review when you have a moment. Happy to tweak if needed.

@ZhulongNT

Copy link
Copy Markdown
Contributor

I tested the current PR implementation against DSML strings observed in real model_responses. Two gaps remain:

  1. Double full-width-pipe markers are only partially removed
_strip_dsml_markers('<||DSML||parameter name="x">abc</||DSML||parameter>')
# current result: '<abc</'
# expected:       'abc'

The |DSML| regex starts matching at the second pipe of ||DSML||, leaving the outer < / </ behind.

  1. Markers split across SSE chunks are not removed
chunks = ['hello<‖DS', 'ML‖tool_calls>world']
''.join(_strip_dsml_markers(x) for x in chunks)
# current result: 'hello<‖DSML‖tool_calls>world'

Because each chunk is filtered independently and the fast guard requires "DSML" in text, a marker split at any point inside DSML bypasses the filter. This is especially relevant for streaming, and the PR description explicitly mentions chunk-split regression tests.

Suggested direction: maintain a short carry-over buffer/state across SSE chunks, and match the double-pipe variants before their single-pipe substrings. Please also add regression tests for opening/closing parameter tags and every split position within a marker.

@lsdefine

Copy link
Copy Markdown
Owner

Thanks for the PR and @ZhulongNT for the careful review. We've decided not to merge this. DSML leaking into content means the tool call was already not parsed, either on the serving side or because of malformed model output. Stripping the markers on the client doesn't recover the call. It only hides the failure, and it rewrites the history sent back to the model, which stops it from correcting itself. We'd rather keep the model output as-is and fix this at the serving/parser layer. Closing, thanks again!

@lsdefine lsdefine closed this Sep 29, 2026
ZhulongNT pushed a commit to ZhulongNT/GenericAgent-Remote that referenced this pull request Oct 27, 2026
…variants, chunk splits)

DeepSeek occasionally emits its DSML tool-call markup as plain content text instead of a
real tool_calls delta (reported in lsdefine#804, partially addressed by the closed PR lsdefine#805 and by
f7e4cc5 in ga.py). The previous content filter still missed three cases, so `</...DSML
parameter>` (and friends) reached the agent loop as assistant text, no tool call was
registered, and the turn stalled.

1. The pattern required '<' to be immediately followed by the DSML token, so closing tags
   ('</...') never matched, and the double full-width-pipe variant ('|P||P|DSML|P||P|') only
   matched from its second pipe, leaving a stray '<' / '</' behind.
2. Stripping ran per SSE chunk, so a marker split across chunks could not be seen at all.
3. The non-streaming _parse_openai_json path never stripped markers.

Changes:
- one ASCII-safe pattern (built from chr()) covering optional '/', 1-2 pipes of any of
  | / U+FF5C / U+2016, optional whitespace inside the tag, and tag payloads.
- _DsmlStreamFilter holds back a trailing unterminated '<...' (<=64 chars) so markers split
  across SSE chunks are still removed.
- _DSML_LEAK latch + MockResponse.dsml_leak so callers can tell that a leak happened even
  when the payload text survives; reset at the start of every parse.
- both non-streaming JSON paths strip as well.

Verified with 27 unit assertions on the real module: known variants, chunk-split markers,
SSE end-to-end, and healthy tool-call regressions.

Co-Authored-By: GenericAgent <bot@gaagent.ai>
ZhulongNT pushed a commit to ZhulongNT/GenericAgent-Remote that referenced this pull request Oct 27, 2026
Complements f7e4cc5, which only retried when the content ended with the exact
literal `</|DSML| calls>`. That check misses

* a leak anywhere except the very end of the turn,
* markers that use `||` (double full-width) or full-width/heavy pipes,
* a leaked closing tag of a non-`calls` marker such as
  `</||DSML|| parameter>` - the marker that actually stalls the loop:
  the raw text is fed back into the next request and the model keeps
  regenerating broken DSML instead of calling a tool.

`llmcore` now latches whether the DSML filter had to remove anything while
parsing a response (`response.dsml_leak`), so `do_no_tool` can retry on a
positive signal instead of guessing from the text tail. The cheap tail check
is kept as a fallback for responses produced by other code paths.

Refs lsdefine#804 (closed), lsdefine#805 (closed unmerged), lsdefine#836 (open).

Co-Authored-By: GenericAgent <bot@gaagent.ai>
ZhulongNT pushed a commit to ZhulongNT/GenericAgent-Remote that referenced this pull request Oct 27, 2026
Regression tests for lsdefine#804 / lsdefine#805 / lsdefine#836: variant marker spelling, closing
tags of non-`calls` markers, markers split across stream chunks (the exact
`</||DSML|| parameter>` case), the non-streaming JSON path, and the latch
that `ga.do_no_tool` relies on.

Co-Authored-By: GenericAgent <bot@gaagent.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] DeepSeek DSML tool-call markers leak into assistant content and streamlit UI

3 participants