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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user