diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 09c4aec5cb06..d1803048babf 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -672,6 +672,39 @@ def strip_think_blocks(agent, content: str) -> str: """ if not content: 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 # mixed-case tags (, ) don't slip through to # the unterminated-tag pass and take trailing content with them. diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 2b0db24f6b73..24c1030dc089 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -445,6 +445,40 @@ class TestStripThinkBlocks: def test_none_returns_empty(self, agent): 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): assert agent._strip_think_blocks("hello world") == "hello world"