Skip to content

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

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

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

Conversation

@ZhulongNT

@ZhulongNT ZhulongNT commented Oct 16, 2026 •

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.


Update 2026-10-27 — rebased + two more real-world variants

Rebased onto main@e4d956f (30 commits, incl. a9a36b4), so the PR applies
cleanly again; the one conflict in ga.py was resolved by keeping this PR's
text-channel recovery block and dropping the now-deleted plan_mode guard.

Two new commits, both driven by sessions that still stalled after the
above fixes:

  1. llmcore: recover more text-channel call variants (#837) — a call emitted as
    <code_run><script>...</script><parameter name="type">python</parameter></code_run></invoke></function>
    was rejected because (a) <script> was not accepted as a parameter container,
    (b) anything after the closing tag made it "not a call", (c) a call truncated
    mid-value was not flagged as partial, so no retry happened. Now: container
    names are parsed and mapped to declared parameter names, a tail of stray
    closing tags is tolerated, and a value-less/truncated call returns partial
    so the turn is retried instead of silently ending.
  2. ga: don't treat quoted DSML markers as a leak — the guard matched the marker
    anywhere in the last 200 chars, so a reply that quotes it (writing docs,
    answering "what is this bug", pasting the upstream regex) was rejected as a
    leaked tool call. Code spans/fenced blocks that are followed by prose are now
    stripped before matching; a fence at the very end still counts, because that
    is exactly where a leaked call hides.

Verification: the two dead sessions from 2026-10-27 (model_responses_353300.txt,
17:08 and 17:59 — both followed by the user typing "continue") now parse into a
code_run call, with 6 new tests added (frontends/tests/ 30 passed).
Full frontends/tests/ on this branch: 189 passed, 1 pre-existing failure
(test_conductor_model_routing, also fails on main@e4d956f).

@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.

zhangPinkdolphin added a commit to zhangPinkdolphin/GenericAgent that referenced this pull request Oct 27, 2026
上一版(lsdefine#853)只覆盖 <code_run> 家族且以"检测到即重试"为主,
实际运行中仍有两类问题:①反引号/围栏里被讲解的标签会被误判执行;
②<ask_user><question>..</question></ask_user> 这类裸子标签形态、以及
"调用后跟一段说明"的形态仍被漏检。

本次改为解析优先 + 全文级检测:

llmcore.py
- _strip_dsml_tags:容错 | | ‖ 单/双/三分隔符与破碎开闭标签
- DsmlStreamFilter:流式跨 chunk 剥离(chat_completions / responses 两条路径)
- _code_candidates/_xml_code_calls:属性式、体内参数式、压平单行式、JSON 体
- _bare_tag_calls/_bare_child_args:工具名+裸子标签参数;参数体必须纯净标签对,
  正文 HTML 片段与裸脚本不会被误判
- _looks_like_prose:中文散文判为"被讨论的标签"→否决
- _try_parse_tool_args:arguments 侧同样剥标记(上游 lsdefine#846-B)
- leaked_markup_kind/incomplete + salvaged_call_problem:可完整解析才执行,
  截断/语法错/空参数一律转重试

ga.py
- 尾部 50 字检测改为全文级:能解析就救回执行,残缺则不执行并要求重生成

实测:36 项验收用例全绿;50 条真实泄漏日志逐块对拍改前/改后行为完全一致(零退步);
对照 lsdefine#838 修复其两处误执行与两处漏检。
songzhenrui and others added 7 commits October 27, 2026 18:39
…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>
…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>
Follow-up to the text-channel recovery path: three shapes still fell through
and left the loop stopped with no tool call at all.

* <script>...</script> used as the parameter container instead of
  <parameter name="script">, with the remaining parameters alongside it
* stray closing tags after the call ('</invoke>', '</function>') made the
  call look like prose, so it was neither executed nor retried
* a stream truncated inside a value ('<parameter name="timeout">180') was
  not even reported as partial, so do_no_tool just exited

Also stop treating *quoted* markup as a call: markup inside inline code or a
fenced block that is followed by prose is a sample, not an execution.

Co-Authored-By: GenericAgent <bot@gaagent.ai>
The no-tool guard matched the marker anywhere in the last 200 chars, so a
reply that *quotes* it -- writing docs, answering a question about the bug,
or pasting the regex from an upstream commit -- was rejected as a leaked
tool call and its turn was thrown away.

Strip code spans (fenced blocks followed by prose, inline `code`) before
matching, so only bare markup counts.

Co-Authored-By: GenericAgent <bot@gaagent.ai>
@ZhulongNT
ZhulongNT force-pushed the fix/dsml-marker-hardening branch from bc7cf81 to 2b0e44a Compare October 27, 2026 10:42
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