Skip to content

fix(display): strip standalone tool-call XML tags from visible text (port openclaw#67318) - #14251

Merged
teknium1 merged 1 commit into
mainfrom
hermes/hermes-d952ed28
Apr 23, 2026
Merged

teknium1 merged 1 commit into
mainfrom
hermes/hermes-d952ed28

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

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> inside content instead of via structured tool_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 carry name="...") so prose mentions survive. Dangling opens intentionally preserved per OpenClaw spec.
  • cli.py::_strip_reasoning_tags — mirror coverage on CLI final-display path.
  • Tests: 9 new in TestStripThinkBlocks, 9 new in test_strip_reasoning_tags_cli.py.

Validation

  • test_run_agent.py + test_strip_reasoning_tags_cli.py: 302/302 passing.
  • py_compile clean on run_agent.py + cli.py.
  • E2E verified: Gemma <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 /copy consume). Live per-delta stripping in gateway/stream_consumer.py is a separate follow-up.

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
@teknium1
teknium1 merged commit c345ec9 into main Apr 23, 2026
11 of 12 checks passed
@teknium1
teknium1 deleted the hermes/hermes-d952ed28 branch April 23, 2026 01:12
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard labels Apr 23, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants