Skip to content

llmcore+ga: strip leaked DSML markers robustly (closing tags, pipe variants, cross-chunk splits) + tests - #838

Open
ZhulongNT wants to merge 5 commits into
lsdefine:mainfrom
ZhulongNT:fix/dsml-marker-hardening
Open

ZhulongNT wants to merge 5 commits into
lsdefine:mainfrom
ZhulongNT:fix/dsml-marker-hardening

Conversation

@ZhulongNT

Copy link
Copy Markdown
Contributor

Fixes #837.

Follow-up to the closed issue #804 (the original "DSML markers leak into assistant content" report) and the closed, unmerged PR #805 (the first per-chunk content filter), and complementary to merged workaround f7e4cc5 and the still-open PR #836. Those cover the symptom end (exact trailer retry / 200-char tail heuristic); this PR fixes the marker matching itself, which is what makes </||DSML|| parameter> survive all of them.

What this fixes

Three holes, all in llmcore.py, plus a guard that is no longer tied to one hardcoded suffix in ga.py:

  1. Closing tags and pipe variants were never matched. The marker regex required < to be immediately followed by the pipe+DSML sequence (so </...> closing tags escaped) and only accepted a single | (U+FF5C), not |, || or ‖. Replaced with one variant-agnostic marker pattern (optional /, [||‖]{1,2}, optional whitespace around the DSML token).
  2. Markers split across stream chunks were never seen. Filtering ran per chunk behind a "DSML" not in text fast path, so "hello<|" + "|DSML||tool_calls>world" matched nothing. Added a small stateful filter (_DsmlStreamFilter) that holds a trailing partial <... back and re-joins it with the next chunk, so a split marker is matched exactly once and in order; the fast path remains for the common case.
  3. Non-streaming responses were never filtered (_parse_openai_json), and neither were the text blocks of a streamed response in some paths.
  4. ga.do_no_tool now retries when llmcore latches a leak (response.dsml_leak) - a positive signal, so a leak anywhere in the turn (not just at the very end) and any marker family triggers a clean regeneration - while the cheap tail check stays as a fallback for responses produced elsewhere.

Tests

frontends/tests/test_dsml_marker_strip.py (11 tests, runs in the existing desktop CI job, no network/API key): marker spelling variants, closing tags of invoke/parameter/tool_calls families, cross-chunk splits (the exact </||DSML|| parameter> case), the non-streaming path, the latch contract ga.py depends on, and non-regression cases (ordinary text untouched; the payload between two leaked markers is preserved).

Risk

Only text content is touched, and only when a DSML-shaped marker is actually present; real tool calls are parsed by the provider adapter before this path. Leak-free text takes the same fast path as before (a single "DSML" in text check). The filter holds at most a trailing <... fragment back between chunks, and flushes at stream end, so no content can be lost.

songzhenrui and others added 3 commits October 16, 2026 16:54
…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>
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>
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>
…define#837)

The model sometimes ends a turn with the call written as literal markup in the
text channel ("<code_run\">" truncated, or a complete "<code_run><parameter ...>"
block). No tool_call comes back, so the loop treats the turn as a finished answer
and the agent stops silently (session 423177, 2026-10-17 15:34).

* llmcore.split_text_tool_calls(): recover trailing markup only -- quoted samples
  earlier in the reply are left alone -- returning (calls, partial)
* agent_loop: execute recovered calls like real tool calls instead of no_tool
* ga.do_no_tool: retry once when the marker is truncated mid-tag
* responses stream: parse the response.completed output snapshot, handle
  output_item.done, and flag incomplete / terminal-event-less streams instead of
  letting a torn turn look like a finished answer

Tests: frontends/tests/test_text_channel_tool_call.py (9 cases)

Co-Authored-By: GenericAgent <bot@gaagent.ai>
@ZhulongNT ZhulongNT closed this Oct 17, 2026
@ZhulongNT ZhulongNT reopened this Oct 17, 2026
@ZhulongNT

Copy link
Copy Markdown
Contributor Author

Pushed 958e2d5 (4th commit): the text-channel rescue + stream-observability half of this fix.

What it adds on top of the DSML stripping:

Execute instead of ignoring — llmcore.split_text_tool_calls(text, known_tools) recognises a call the model wrote as literal markup and GA now runs it:

  • complete <code_run><parameter name="script">…</parameter></code_run> → real call (also handles the attribute form <code_run script="">…</code_run> and raw <code_run>…print(1)</code_run>);
  • <tool_use>{"name":…,"arguments":…}</tool_use> JSON form;
  • a marker in the middle of prose, or text after the closing tag, is not treated as a call, so prose that merely mentions <code_run> is safe.

Retry instead of stopping — a truncated marker at the end of the turn (<code_run">, <tool_use>{"name": …) means the model was about to call a tool and got cut off; do_no_tool now asks it to regenerate the turn once instead of silently ending (this is exactly the 2026-10-17 session where the turn ended on <code_run">).

Stream observability (responses api_mode) — response.completed now also parses the final output snapshot, output_item.done is handled (previously only .added), a stream with no terminal event is flagged ([!!! stream ended without a terminal event]), and response.incomplete warns even when some text already arrived. These turned a torn turn into a normal-looking answer before.

Tests: frontends/tests/test_text_channel_tool_call.py (9 cases) + adjusted the dsml_f.feed count in the existing DSML test → frontends/tests 293 passed, 3 failures are pre-existing Windows symlink/path issues unrelated to this change.

Note: GitHub did not create a check suite for 958e2d5 (the only run on this PR is for c78f0f1); a re-run would need to be triggered from the Actions tab.

…bug)

The lsdefine#837 fix was inert: agent_runner_loop built the known-tool set with
{entry["name"] for entry in tools_schema}, but the shipped
assets/tools_schema.json uses the OpenAI form
{"type": "function", "function": {"name": ...}} -- every lookup returned None,
the set came out empty, and split_text_tool_calls returns ([], False)
immediately for an empty set.  So a leaked block was never recovered and the
raw markup reached the user; only the truncated-marker retry worked, because
that path lives in do_no_tool.

* agent_loop: add tool_names_from_schema(), accepting both the flat and the
  OpenAI nested form, and use it for the recovery call.
* ga.py: do_no_tool's recovered-call branch used to yield
  "already executed" and return without dispatching anything.  It now really
  dispatches each recovered call (it is only reached when the loop did not
  already handle it) and honours should_exit.
* tests: schema name extraction (nested, flat, shipped file) and a leaked block
  whose body ends with a "python" language tag -- the exact 2026-10-17 shape.

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.

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

1 participant