fix(display): strip standalone tool-call XML tags from visible text (port openclaw#67318) - #14251
Merged
Merged
Conversation
Port from openclaw/openclaw#67318. Some open models (notably Gemma variants served via OpenRouter) emit tool calls as XML blocks inside assistant content instead of via the structured tool_calls field: <function name="read_file"><parameter name="path">/tmp/x</parameter></function> <tool_call>{"name":"x"}</tool_call> <function_calls>[{...}]</function_calls> Left unstripped, this raw XML leaked to gateway users (Discord, Telegram, Matrix, Feishu, Signal, WhatsApp, etc.) and the CLI, since hermes-agent's existing reasoning-tag stripper handled only <think>/<thinking>/<thought> variants. Extend _strip_think_blocks (run_agent.py) and _strip_reasoning_tags (cli.py) to cover: * <tool_call>, <tool_calls>, <tool_result> * <function_call>, <function_calls> * <function name="..."> ... </function> (Gemma-style) The <function> variant is boundary-gated (only strips when the tag sits at start-of-line or after sentence punctuation AND carries a name="..." attribute) so prose mentions like 'Use <function> declarations in JS' are preserved. Dangling <function name="..."> with no close is intentionally left visible — matches OpenClaw's asymmetry so a truncated streaming tail still reaches the user. Tests: 9 new cases in TestStripThinkBlocks (run_agent) + 9 in new file tests/run_agent/test_strip_reasoning_tags_cli.py. Covers Qwen-style <tool_call>, Gemma-style <function name="...">, multi-line payloads, prose preservation, stray close tags, dangling open tags, and mixed reasoning+tool_call content. Note: this port covers the post-streaming final-text path, which is what gateway adapters and CLI display consume. Extending the per-delta stream filter in gateway/stream_consumer.py to hide these tags live as they stream is a separate follow-up; for now users may see raw XML briefly during a stream before the final cleaned text replaces it. Refs: openclaw/openclaw#67318
davidgut1982
added a commit
to davidgut1982/hermes-agent
that referenced
this pull request
Jun 2, 2026
…aths Qwen3-class models sometimes emit a partial <tool_call> XML opener as text content before switching to native tool_calls, leaving a stray delta like 'ool_call>' that leaks to user-facing output. Neither live streaming branch scrubbed these openers. Add a shared strip_partial_toolcall_fragments() helper and wire both the primary content path (_fire_stream_delta) and the suppressed-content path (stream_delta_callback branch) through it, with an empty-string guard so a pure-fragment delta fires no callback. This is the live per-delta follow-up explicitly deferred by NousResearch#14251 (which covered only the post-streaming final-text path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
davidgut1982
added a commit
to davidgut1982/hermes-agent
that referenced
this pull request
Jul 18, 2026
… fragments Rework of NousResearch#33355 per maintainer review. Qwen3 models emit partial <tool_call>/<function_call> XML opener fragments as text deltas before switching to native tool_calls, leaking stray chars (ool_call>, l_call>, _call>) into streamed output. Prior stateless per-delta regex could not match l_call> (it required a leading 'o') and could not repair fragments split across deltas. Follow-up to NousResearch#14251 (final-text path). Adds StreamingToolCallFragmentScrubber (modeled on StreamingThinkScrubber): - opener suffixes derived programmatically from the opener strings, so every split point is covered by construction (fixes the l_call> gap) - stateful _buf hold carries partial fragments across feed() boundaries so a <too + l_call> split reconstructs and resolves - context-scoped: stripping happens ONLY in tool-call context (Path B, the suppressed-content bypass where tool_calls are already accumulating). In prose (Path A, _fire_stream_delta) feed() is pure passthrough, so ordinary '>' and HTML a model emits (<ul>, <ol>, <html>, <small>, <details>) are never corrupted. Leaks only occur at the tool-call transition, so scoping to Path B removes the entire false-positive surface while still scrubbing real leaks. Wires the shared per-turn scrubber into both live streaming paths exactly as StreamingThinkScrubber is wired: instantiated in agent_init, reset per turn in turn_context, fed in _fire_stream_delta (Path A) and the suppressed-content bypass in chat_completion_helpers (Path B, in_toolcall_context=True), and flushed at end-of-stream with de-duplicated callbacks. A held buffer is never dropped on an empty prose delta. Documented limitation: a leaked opener whose prefix arrives as the final prose delta immediately before the tool-call transition (split across the Path A -> Path B boundary) may not be fully scrubbed; a rare, accepted edge case with an explicit regression test. Tests: every opener suffix stripped in tool-call context (incl. l_call>, _call>, call>); split-delta strip + reconstruction; prose passthrough / HTML round-trip lockdown; empty-delta buffer-flush; Path A/B integration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased #12746 onto current main. Closes #12746.
Summary
Open models (notably Gemma via OpenRouter) no longer leak raw tool-call XML into visible assistant content. Gemma emits tool calls as
<function name="read_file"><parameter name="path">...</parameter></function>insidecontentinstead of via structuredtool_calls. That XML was passed verbatim to every gateway + CLI.Ported from openclaw/openclaw#67318.
Changes
run_agent.py::_strip_think_blocks— extended to strip<tool_call>,<tool_calls>,<tool_result>,<function_call>,<function_calls>,<function name="...">...</function>.<function>is boundary-gated (must be at start-of-line or after sentence punctuation AND carryname="...") so prose mentions survive. Dangling opens intentionally preserved per OpenClaw spec.cli.py::_strip_reasoning_tags— mirror coverage on CLI final-display path.TestStripThinkBlocks, 9 new intest_strip_reasoning_tags_cli.py.Validation
test_run_agent.py+test_strip_reasoning_tags_cli.py: 302/302 passing.py_compileclean onrun_agent.py+cli.py.<function name>/ Qwen<tool_call>/<function_calls>/<tool_result>all stripped; inline prose with backticks preserved; dangling opens preserved (intentional); existing<thinking>still stripped.Scope note
Covers post-streaming final-text path (what gateway adapters + CLI
/copyconsume). Live per-delta stripping ingateway/stream_consumer.pyis a separate follow-up.