Repository navigation
Conversation
…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>
|
Pushed What it adds on top of the DSML stripping: Execute instead of ignoring —
Retry instead of stopping — a truncated marker at the end of the turn ( Stream observability ( Tests: Note: GitHub did not create a check suite for |
…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>
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 inga.py:<to be immediately followed by the pipe+DSMLsequence (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 theDSMLtoken)."DSML" not in textfast 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._parse_openai_json), and neither were the text blocks of a streamed response in some paths.ga.do_no_toolnow retries whenllmcorelatches 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 ofinvoke/parameter/tool_callsfamilies, cross-chunk splits (the exact</||DSML|| parameter>case), the non-streaming path, the latch contractga.pydepends 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 textcheck). The filter holds at most a trailing<...fragment back between chunks, and flushes at stream end, so no content can be lost.