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
<arg_key>/<arg_value> fragment (or a bare unclosed <tool_call> 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.
This commit is contained in:
Teknium
2026-09-03 02:44:08 -07:00
parent c3e9defd18
commit 7b72fd1247
3 changed files with 49 additions and 0 deletions
+14
View File
@@ -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
# (<arg_key>/<arg_value>) 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<]*</?arg_(?:key|value)\b.*$',
re.DOTALL | re.IGNORECASE,
)
def _ra():
"""Lazy ``run_agent`` reference for test-patch routing."""
@@ -1065,6 +1076,9 @@ def strip_think_blocks(agent, content: str) -> 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
+9
View File
@@ -314,6 +314,15 @@ def _strip_reasoning_tags(text: str) -> str:
cleaned,
flags=re.IGNORECASE,
)
# Unterminated opener / stray <arg_key>/<arg_value> 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<]*</?arg_(?:key|value)\b.*$',
'',
cleaned,
flags=re.DOTALL | re.IGNORECASE,
)
return cleaned.strip()
@@ -7,8 +7,21 @@ AIAgent instance. It must stay in sync with run_agent.py::_strip_think_blocks
for tool-call tag coverage."""
from agent.agent_runtime_helpers import strip_think_blocks
from cli import _strip_reasoning_tags
# GLM text-channel tool call cut mid-serialization by a stream drop (#101899):
# the first key and call name never arrived, only orphan argument markup.
_CUT_FRAGMENT = (
"Both gates started.\n"
"wait</arg_value>\n<arg_key>session_id</arg_key>\n<arg_value>abc</arg_value>\n"
"<arg_key>timeout</arg_key>\n<arg_value>59"
)
_COMPLETE_WITH_PROSE = (
"Use <function> in JS. The arg_key field maps to arg_value.\n"
"<tool_call>x<arg_key>a</arg_key><arg_value>1</arg_value></tool_call>\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.\n<tool_call>process_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 <function> in JS. The arg_key field maps to arg_value." in out
assert out.rstrip().endswith("Done.")
assert "<tool_call>" not in out