mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
fix: stop infinite loop when assistant content is a block list
strip_think_blocks() ran re.sub() directly on content that could be a
list of blocks (Anthropic via OpenRouter returns assistant content as
[{type:text,...},{type:thinking,...}]). A list reaching re.sub raised
'TypeError: expected string or bytes-like object, got list', which the
outer conversation loop swallowed and retried forever — the observed
infinite 'preparing terminal...' loop that re-emitted the same
assistant text every iteration.
The live-turn path normalized list content to a string, but
_interim_assistant_visible_text reads a *stored* history message whose
content was persisted as a list and passes it straight into the shared
strip_think_blocks helper. Fix at the shared choke point: coerce
list/dict content to visible text (dropping reasoning blocks, which is
the function's job) before any regex runs, so every caller is safe.
This commit is contained in:
parent
e7f208fd74
commit
296494db0e
2 changed files with 67 additions and 0 deletions
|
|
@ -672,6 +672,39 @@ def strip_think_blocks(agent, content: str) -> str:
|
||||||
"""
|
"""
|
||||||
if not content:
|
if not content:
|
||||||
return ""
|
return ""
|
||||||
|
# Coerce non-string content to text before any regex runs. Providers
|
||||||
|
# that return assistant ``content`` as a list of blocks (Anthropic via
|
||||||
|
# OpenRouter emits ``[{"type":"text",...}, {"type":"thinking",...}]``) or
|
||||||
|
# as a dict flow into this shared helper from several callers — most
|
||||||
|
# notably ``_interim_assistant_visible_text`` reading a *stored* history
|
||||||
|
# message whose content was persisted as a list. A raw list/dict reaching
|
||||||
|
# ``re.sub`` below raises ``TypeError: expected string or bytes-like
|
||||||
|
# object, got 'list'``, which the outer conversation loop swallows and
|
||||||
|
# retries forever (observed as an infinite "preparing terminal…" loop on
|
||||||
|
# Anthropic models via OpenRouter). Flatten here so every caller is safe.
|
||||||
|
if not isinstance(content, str):
|
||||||
|
if isinstance(content, list):
|
||||||
|
_parts: list[str] = []
|
||||||
|
for _part in content:
|
||||||
|
if isinstance(_part, str):
|
||||||
|
_parts.append(_part)
|
||||||
|
elif isinstance(_part, dict):
|
||||||
|
_ptype = str(_part.get("type") or "").strip().lower()
|
||||||
|
# Drop reasoning/thinking blocks outright — this function's
|
||||||
|
# whole job is to strip them, and their text lives under
|
||||||
|
# different keys ("thinking", "reasoning") per provider.
|
||||||
|
if _ptype in {"thinking", "reasoning", "redacted_thinking"}:
|
||||||
|
continue
|
||||||
|
_text = _part.get("text")
|
||||||
|
if isinstance(_text, str) and _text:
|
||||||
|
_parts.append(_text)
|
||||||
|
content = "".join(_parts)
|
||||||
|
elif isinstance(content, dict):
|
||||||
|
content = str(content.get("text") or content.get("content") or "")
|
||||||
|
else:
|
||||||
|
content = str(content)
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
# 1. Closed tag pairs — case-insensitive for all variants so
|
# 1. Closed tag pairs — case-insensitive for all variants so
|
||||||
# mixed-case tags (<THINK>, <Thinking>) don't slip through to
|
# mixed-case tags (<THINK>, <Thinking>) don't slip through to
|
||||||
# the unterminated-tag pass and take trailing content with them.
|
# the unterminated-tag pass and take trailing content with them.
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,40 @@ class TestStripThinkBlocks:
|
||||||
def test_none_returns_empty(self, agent):
|
def test_none_returns_empty(self, agent):
|
||||||
assert agent._strip_think_blocks(None) == ""
|
assert agent._strip_think_blocks(None) == ""
|
||||||
|
|
||||||
|
def test_list_content_flattened_no_crash(self, agent):
|
||||||
|
"""Anthropic-via-OpenRouter returns content as a block list.
|
||||||
|
|
||||||
|
A raw list reaching ``re.sub`` raised ``TypeError: expected string
|
||||||
|
or bytes-like object, got 'list'``, which the outer conversation
|
||||||
|
loop swallowed and retried forever (infinite "preparing terminal…"
|
||||||
|
loop). ``strip_think_blocks`` must flatten list content to visible
|
||||||
|
text and drop reasoning blocks.
|
||||||
|
"""
|
||||||
|
result = agent._strip_think_blocks(
|
||||||
|
[
|
||||||
|
{"type": "text", "text": "visible answer"},
|
||||||
|
{"type": "thinking", "thinking": "internal reasoning"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "visible answer" in result
|
||||||
|
assert "internal reasoning" not in result
|
||||||
|
|
||||||
|
def test_dict_content_flattened_no_crash(self, agent):
|
||||||
|
"""Some servers return content as a single dict block."""
|
||||||
|
result = agent._strip_think_blocks({"type": "text", "text": "hello world"})
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "hello world" in result
|
||||||
|
|
||||||
|
def test_list_of_only_thinking_returns_empty(self, agent):
|
||||||
|
"""A list carrying only reasoning blocks yields no visible text."""
|
||||||
|
assert (
|
||||||
|
agent._strip_think_blocks([{"type": "thinking", "thinking": "x"}]) == ""
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_list_returns_empty(self, agent):
|
||||||
|
assert agent._strip_think_blocks([]) == ""
|
||||||
|
|
||||||
def test_no_blocks_unchanged(self, agent):
|
def test_no_blocks_unchanged(self, agent):
|
||||||
assert agent._strip_think_blocks("hello world") == "hello world"
|
assert agent._strip_think_blocks("hello world") == "hello world"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue