From 7b72fd12476aedc06a993d92c4337e2ceb214bc7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:44:08 -0700 Subject: [PATCH] fix(agent): stream cut mid tool-call markup no longer persists as assistant prose (#101899) GLM-style models serialize tool calls as XML in the text channel; when the stream drops mid-serialization with finish_reason=stop, the orphan / fragment (or a bare unclosed opener) matched neither the complete-block stripper nor the partial-stream guard and was stored and displayed as ordinary assistant content. strip_think_blocks (storage boundary) and the CLI display copy now strip an unterminated block-boundary tool-call opener, or any line carrying stray argument markup, to end of text. The response then reads as empty and flows through the existing empty-retry path. Complete blocks and inline prose mentions are unchanged. --- agent/agent_runtime_helpers.py | 14 ++++++++++ cli.py | 9 +++++++ .../test_strip_reasoning_tags_cli.py | 26 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 4927938655..1880698e81 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -100,6 +100,17 @@ _STRAY_TOOL_CALL_CLOSER_PATTERN = re.compile( re.IGNORECASE, ) +# A tool-call opener with no closer, or GLM-style argument markup +# (/) outside any closed block, means the stream was +# cut mid-serialization of a text-channel tool call (#101899). The call +# can't be recovered; strip from the block-boundary opener (or the line +# holding the first stray argument tag) to the end of the text. +_UNTERMINATED_TOOL_CALL_PATTERN = re.compile( + rf'(?:^|\n)[ \t]*<(?:{"|".join(_TOOL_CALL_TAG_NAMES)})\b[^>]*>.*$' + r'|(?:^|\n)[^\n<]* str: # during streaming may still be valuable to the user; matches # OpenClaw's intentional asymmetry.) content = _STRAY_TOOL_CALL_CLOSER_PATTERN.sub('', content) + # 3c. Tool-call openers or argument markup surviving 1b belong to a + # block that never closed — a mid-serialization stream cut (#101899). + content = _UNTERMINATED_TOOL_CALL_PATTERN.sub('', content) return content diff --git a/cli.py b/cli.py index 20c160931f..19536ffe8d 100644 --- a/cli.py +++ b/cli.py @@ -314,6 +314,15 @@ def _strip_reasoning_tags(text: str) -> str: cleaned, flags=re.IGNORECASE, ) + # Unterminated opener / stray / markup = stream cut + # mid tool-call serialization (#101899); strip to end of text. + cleaned = re.sub( + r'(?:^|\n)[ \t]*<(?:tool_call|tool_calls|tool_result|function_call|function_calls)\b[^>]*>.*$' + r'|(?:^|\n)[^\n<]*\nsession_id\nabc\n" + "timeout\n59" +) +_COMPLETE_WITH_PROSE = ( + "Use in JS. The arg_key field maps to arg_value.\n" + "xa1\nDone." +) + class TestToolCallStripping: def test_tool_call_block_stripped(self): @@ -26,3 +39,16 @@ class TestToolCallStripping: def test_empty_string(self): assert _strip_reasoning_tags("") == "" + def test_cut_tool_call_stripped_to_visible_prefix(self): + """Both strippers drop the unrecoverable tail; only prose survives.""" + assert _strip_reasoning_tags(_CUT_FRAGMENT) == "Both gates started." + assert strip_think_blocks(None, _CUT_FRAGMENT).strip() == "Both gates started." + assert strip_think_blocks(None, "Waiting.\nprocess_manage").strip() == "Waiting." + + def test_complete_block_and_inline_prose_mentions_untouched(self): + for out in (_strip_reasoning_tags(_COMPLETE_WITH_PROSE), + strip_think_blocks(None, _COMPLETE_WITH_PROSE)): + assert "Use in JS. The arg_key field maps to arg_value." in out + assert out.rstrip().endswith("Done.") + assert "" not in out +