{"repo":"NousResearch/hermes-agent","public_id":"2128","display_label":"Codex reasoning-only turns causing empty assistant prefill issues","summary":"These issues and fixes center on how Codex reasoning-only turns produce empty assistant messages that leak into subsequent API calls, causing prefill rejection errors, particularly when falling back to Anthropic. The fixes involve treating such turns as thinking-only and stripping or neutralizing trailing empty assistant messages before API calls.","subsystem":"agent","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":3,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2466},"resolution":{"state":"resolved","by":"pr-5278,pr-45822"},"freshness":{"state":"verified","last_verified_at":"2026-06-13T21:35:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":2128,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2128,"pr_number":5278,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":2130,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2137,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2428,"pr_number":2466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":29205,"pr_number":45822,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":2128,"title":"Bug: Empty assistant message from reasoning-only fix leaks into Chat Completions API, causing prefill rejection","url":null,"state":"closed"},{"kind":"issue","number":29205,"title":"Anthropic fallback can fail after Codex reasoning-only empty turns due to trailing assistant prefill","url":null,"state":"closed"},{"kind":"pr","number":2129,"title":"fix(agent): strip trailing empty assistant messages before API calls to prevent prefill rejection","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2466","number":2466}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2466,"title":"fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/email.py","hermes_state.py","run_agent.py","tools/voice_mode.py"],"fix_hunks":[{"file":"gateway/platforms/email.py","hunks":["@@ -230,7 +230,7 @@ async def connect(self) -> bool:\n             # Mark all existing messages as seen so we only process new ones\n             imap.select(\"INBOX\")\n             status, data = imap.uid(\"search\", None, \"ALL\")\n-            if status == \"OK\" and data[0]:\n+            if status == \"OK\" and data and data[0]:\n                 for uid in data[0].split():\n                     self._seen_uids.add(uid)\n             imap.logout()","@@ -295,7 +295,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:\n             imap.select(\"INBOX\")\n \n             status, data = imap.uid(\"search\", None, \"UNSEEN\")\n-            if status != \"OK\" or not data[0]:\n+            if status != \"OK\" or not data or not data[0]:\n                 imap.logout()\n                 return results\n "]},{"file":"hermes_state.py","hunks":["@@ -855,23 +855,25 @@ def search_sessions(\n \n     def session_count(self, source: str = None) -> int:\n         \"\"\"Count sessions, optionally filtered by source.\"\"\"\n-        if source:\n-            cursor = self._conn.execute(\n-                \"SELECT COUNT(*) FROM sessions WHERE source = ?\", (source,)\n-            )\n-        else:\n-            cursor = self._conn.execute(\"SELECT COUNT(*) FROM sessions\")\n-        return cursor.fetchone()[0]\n+        with self._lock:\n+            if source:\n+                cursor = self._conn.execute(\n+                    \"SELECT COUNT(*) FROM sessions WHERE source = ?\", (source,)\n+                )\n+            else:\n+                cursor = self._conn.execute(\"SELECT COUNT(*) FROM sessions\")\n+            return cursor.fetchone()[0]\n \n     def message_count(self, session_id: str = None) -> int:\n         \"\"\"Count messages, optionally for a specific session.\"\"\"\n-        if session_id:\n-            cursor = self._conn.execute(\n-                \"SELECT COUNT(*) FROM messages WHERE session_id = ?\", (session_id,)\n-            )\n-        else:\n-            cursor = self._conn.execute(\"SELECT COUNT(*) FROM messages\")\n-        return cursor.fetchone()[0]\n+        with self._lock:\n+            if session_id:\n+                cursor = self._conn.execute(\n+                    \"SELECT COUNT(*) FROM messages WHERE session_id = ?\", (session_id,)\n+                )\n+            else:\n+                cursor = self._conn.execute(\"SELECT COUNT(*) FROM messages\")\n+            return cursor.fetchone()[0]\n \n     # =========================================================================\n     # Export and cleanup"]},{"file":"run_agent.py","hunks":["@@ -108,7 +108,7 @@\n \n \n class _SafeWriter:\n-    \"\"\"Transparent stdio wrapper that catches OSError from broken pipes.\n+    \"\"\"Transparent stdio wrapper that catches OSError/ValueError from broken pipes.\n \n     When hermes-agent runs as a systemd service, Docker container, or headless\n     daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer","@@ -117,8 +117,13 @@ class _SafeWriter:\n     run_conversation() — especially via double-fault when an except handler\n     also tries to print.\n \n+    Additionally, when subagents run in ThreadPoolExecutor threads, the shared\n+    stdout handle can close between thread teardown and cleanup, raising\n+    ``ValueError: I/O operation on closed file`` instead of OSError.\n+\n     This wrapper delegates all writes to the underlying stream and silently\n-    catches OSError. It is transparent when the wrapped stream is healthy.\n+    catches both OSError and ValueError. It is transparent when the wrapped\n+    stream is healthy.\n     \"\"\"\n \n     __slots__ = (\"_inner\",)","@@ -129,13 +134,13 @@ def __init__(self, inner):\n     def write(self, data):\n         try:\n             return self._inner.write(data)\n-        except OSError:\n+        except (OSError, ValueError):\n             return len(data) if isinstance(data, str) else 0\n \n     def flush(self):\n         try:\n             self._inner.flush()\n-        except OSError:\n+        except (OSError, ValueError):\n             pass\n \n     def fileno(self):","@@ -144,7 +149,7 @@ def fileno(self):\n     def isatty(self):\n         try:\n             return self._inner.isatty()\n-        except OSError:\n+        except (OSError, ValueError):\n             return False\n \n     def __getattr__(self, name):","@@ -2438,7 +2443,18 @@ def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any\n                 \"Pre-call sanitizer: added %d stub tool result(s)\",\n                 len(missing_results),\n             )\n-\n+        # 3. Strip trailing empty assistant messages to prevent prefill rejection.\n+        # These can leak from Responses API reasoning-only turns (Codex/MiniMax)\n+        # where an empty assistant message is required by the Responses API but\n+        # must NOT be sent to Chat Completions or Anthropic Messages API providers.\n+        while (\n+            messages\n+            and messages[-1].get(\"role\") == \"assistant\"\n+            and not (messages[-1].get(\"content\") or \"\").strip()\n+            and not messages[-1].get(\"tool_calls\")\n+        ):\n+            logger.debug(\"Pre-call sanitizer: removed trailing empty assistant message\")\n+            messages = messages[:-1]\n         return messages\n \n     @staticmethod"]},{"file":"tools/voice_mode.py","hunks":["@@ -81,8 +81,15 @@ def detect_audio_environment() -> dict:\n                 warnings.append(\"No audio input/output devices detected\")\n         except Exception:\n             warnings.append(\"Audio subsystem error (PortAudio cannot query devices)\")\n-    except (ImportError, OSError):\n+    except ImportError:\n         warnings.append(\"Audio libraries not installed (pip install sounddevice numpy)\")\n+    except OSError:\n+        warnings.append(\n+            \"PortAudio system library not found -- install it first:\\n\"\n+            \"  Linux:  sudo apt-get install libportaudio2\\n\"\n+            \"  macOS:  brew install portaudio\\n\"\n+            \"Then retry /voice on.\"\n+        )\n \n     return {\n         \"available\": len(warnings) == 0,\n"]}]},{"kind":"pr","number":5278,"title":"fix: accept reasoning-only responses without retries — set content to \"(empty)\"","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["model_tools.py","run_agent.py","tests/test_run_agent.py","tests/test_tool_arg_coercion.py"],"fix_hunks":[{"file":"model_tools.py","hunks":["@@ -365,6 +365,97 @@ def get_tool_definitions(\n _READ_SEARCH_TOOLS = {\"read_file\", \"search_files\"}\n \n \n+# =========================================================================\n+# Tool argument type coercion\n+# =========================================================================\n+\n+def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:\n+    \"\"\"Coerce tool call arguments to match their JSON Schema types.\n+\n+    LLMs frequently return numbers as strings (``\"42\"`` instead of ``42``)\n+    and booleans as strings (``\"true\"`` instead of ``true``).  This compares\n+    each argument value against the tool's registered JSON Schema and attempts\n+    safe coercion when the value is a string but the schema expects a different\n+    type.  Original values are preserved when coercion fails.\n+\n+    Handles ``\"type\": \"integer\"``, ``\"type\": \"number\"``, ``\"type\": \"boolean\"``,\n+    and union types (``\"type\": [\"integer\", \"string\"]``).\n+    \"\"\"\n+    if not args or not isinstance(args, dict):\n+        return args\n+\n+    schema = registry.get_schema(tool_name)\n+    if not schema:\n+        return args\n+\n+    properties = (schema.get(\"parameters\") or {}).get(\"properties\")\n+    if not properties:\n+        return args\n+\n+    for key, value in args.items():\n+        if not isinstance(value, str):\n+            continue\n+        prop_schema = properties.get(key)\n+        if not prop_schema:\n+            continue\n+        expected = prop_schema.get(\"type\")\n+        if not expected:\n+            continue\n+        coerced = _coerce_value(value, expected)\n+        if coerced is not value:\n+            args[key] = coerced\n+\n+    return args\n+\n+\n+def _coerce_value(value: str, expected_type):\n+    \"\"\"Attempt to coerce a string *value* to *expected_type*.\n+\n+    Returns the original string when coercion is not applicable or fails.\n+    \"\"\"\n+    if isinstance(expected_type, list):\n+        # Union type — try each in order, return first successful coercion\n+        for t in expected_type:\n+            result = _coerce_value(value, t)\n+            if result is not value:\n+                return result\n+        return value\n+\n+    if expected_type in (\"integer\", \"number\"):\n+        return _coerce_number(value, integer_only=(expected_type == \"integer\"))\n+    if expected_type == \"boolean\":\n+        return _coerce_boolean(value)\n+    return value\n+\n+\n+def _coerce_number(value: str, integer_only: bool = False):\n+    \"\"\"Try to parse *value* as a number.  Returns original string on failure.\"\"\"\n+    try:\n+        f = float(value)\n+    except (ValueError, OverflowError):\n+        return value\n+    # Guard against inf/nan before int() conversion\n+    if f != f or f == float(\"inf\") or f == float(\"-inf\"):\n+        return f\n+    # If it looks like an integer (no fractional part), return int\n+    if f == int(f):\n+        return int(f)\n+    if integer_only:\n+        # Schema wants an integer but value has decimals — keep as string\n+        return value\n+    return f\n+\n+\n+def _coerce_boolean(value: str):\n+    \"\"\"Try to parse *value* as a boolean.  Returns original string on failure.\"\"\"\n+    low = value.strip().lower()\n+    if low == \"true\":\n+        return True\n+    if low == \"false\":\n+        return False\n+    return value\n+\n+\n def handle_function_call(\n     function_name: str,\n     function_args: Dict[str, Any],","@@ -388,6 +479,9 @@ def handle_function_call(\n     Returns:\n         Function result as a JSON string.\n     \"\"\"\n+    # Coerce string arguments to their schema-declared types (e.g. \"42\"→42)\n+    function_args = coerce_tool_args(function_name, function_args)\n+\n     # Notify the read-loop tracker when a non-read/search tool runs,\n     # so the *consecutive* counter resets (reads after other work are fine).\n     if function_name not in _READ_SEARCH_TOOLS:"]},{"file":"run_agent.py","hunks":["@@ -8609,140 +8609,24 @@ def _stop_spinner():\n                             self._response_was_previewed = True\n                             break\n \n-                        # No fallback available — classify the empty response before\n-                        # blindly spending retries. Some local/custom backends surface\n-                        # implicit context pressure as reasoning-only output rather than\n-                        # an explicit overflow error.\n-                        if not hasattr(self, '_empty_content_retries'):\n-                            self._empty_content_retries = 0\n-                        self._empty_content_retries += 1\n-\n-                        empty_response_info = self._classify_empty_content_response(\n-                            assistant_message,\n-                            finish_reason=finish_reason,\n-                            approx_tokens=[redacted:secret-assignment]\n-                            api_messages=api_messages,\n-                            conversation_history=conversation_history,\n-                        )\n-                        reasoning_text = empty_response_info[\"reasoning_text\"]\n-                        self._vprint(f\"{self.log_prefix}⚠️  Response only contains think block with no content after it\")\n+                        # Reasoning-only response: the model produced thinking\n+                        # but no visible content.  This is a valid response —\n+                        # keep reasoning in its own field and set content to\n+                        # \"(empty)\" so every provider accepts the message.\n+                        # No retries needed.\n+                        reasoning_text = self._extract_reasoning(assistant_message)\n+                        assistant_msg = self._build_assistant_message(assistant_message, finish_reason)\n+                        assistant_msg[\"content\"] = \"(empty)\"\n+                        messages.append(assistant_msg)\n+\n                         if reasoning_text:\n                             reasoning_preview = reasoning_text[:500] + \"...\" if len(reasoning_text) > 500 else reasoning_text\n-                            self._vprint(f\"{self.log_prefix}   Reasoning: {reasoning_preview}\")\n+                            self._vprint(f\"{self.log_prefix}ℹ️  Reasoning-only response (no visible content). Reasoning: {reasoning_preview}\")\n                         else:\n-                            content_preview = final_response[:80] + \"...\" if len(final_response) > 80 else final_response\n-                            self._vprint(f\"{self.log_prefix}   Content: '{content_preview}'\")\n-\n-                        if empty_response_info[\"should_compress\"]:\n-                            compression_attempts += 1\n-                            if compression_attempts > max_compression_attempts:\n-                                self._vprint(f\"{self.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.\", force=True)\n-                                self._vprint(f\"{self.log_prefix}   💡 Local/custom backend returned reasoning-only output with no visible content. This often means the resumed/large session exceeds the runtime context window. Try /new or lower model.context_length to the actual runtime limit.\", force=True)\n-                            else:\n-                                self._vprint(f\"{self.log_prefix}🗜️  Reasoning-only response looks like implicit context pressure — attempting compression ({compression_attempts}/{max_compression_attempts})...\", force=True)\n-                                original_len = len(messages)\n-                                messages, active_system_prompt = self._compress_context(\n-                                    messages, system_message, approx_tokens=[redacted:secret-assignment]\n-                                    task_id=effective_task_id,\n-                                )\n-                                if len(messages) < original_len:\n-                                    conversation_history = None\n-                                    self._emit_status(f\"🗜️ Compressed {original_len} → {len(messages)} messages after reasoning-only response, retrying...\")\n-                                    time.sleep(2)\n-                                    api_call_count -= 1\n-                                    self.iteration_budget.refund()\n-                                    retry_count += 1\n-                                    continue\n-                                self._vprint(f\"{self.log_prefix}   Compression could not shrink the session; falling back to retry/salvage logic.\")\n+                            self._vprint(f\"{self.log_prefix}ℹ️  Empty response (no content or reasoning).\")\n \n-                        if (\n-                            reasoning_text\n-                            and empty_response_info[\"repeated_signature\"]\n-                            and empty_response_info[\"has_structured_reasoning\"]\n-                        ):\n-                            self._vprint(f\"{self.log_prefix}ℹ️  Structured reasoning-only response repeated unchanged — using reasoning text directly.\", force=True)\n-                            self._empty_content_retries = 0\n-                            final_response = reasoning_text\n-                            empty_msg = {\n-                                \"role\": \"assistant\",\n-                                \"content\": final_response,\n-                                \"reasoning\": reasoning_text,\n-                                \"finish_reason\": finish_reason,\n-                            }\n-                            messages.append(empty_msg)\n-                            break\n-                        \n-                        if self._empty_content_retries < 3:\n-                            self._vprint(f\"{self.log_prefix}🔄 Retrying API call ({self._empty_content_retries}/3)...\")\n-                            continue\n-                        else:\n-                            self._vprint(f\"{self.log_prefix}❌ Max retries (3) for empty content exceeded.\", force=True)\n-                            self._empty_content_retries = 0\n-                            \n-                            # If a prior tool_calls turn had real content, salvage it:\n-                            # rewrite that turn's content to a brief tool description,\n-                            # and use the original content as the final response here.\n-                            fallback = getattr(self, '_last_content_with_tools', None)\n-                            if fallback:\n-                                self._last_content_with_tools = None\n-                                # Find the last assistant message with tool_calls and rewrite it\n-                                for i in range(len(messages) - 1, -1, -1):\n-                                    msg = messages[i]\n-                                    if msg.get(\"role\") == \"assistant\" and msg.get(\"tool_calls\"):\n-                                        tool_names = []\n-                                        for tc in msg[\"tool_calls\"]:\n-                                            if not tc or not isinstance(tc, dict): continue\n-                                            fn = tc.get(\"function\", {})\n-                                            tool_names.append(fn.get(\"name\", \"unknown\"))\n-                                        msg[\"content\"] = f\"Calling the {', '.join(tool_names)} tool{'s' if len(tool_names) > 1 else ''}...\"\n-                                        break\n-                                # Strip <think> blocks from fallback content for user display\n-                                final_response = self._strip_think_blocks(fallback).strip()\n-                                self._response_was_previewed = True\n-                                break\n-                            \n-                            # No fallback -- if reasoning_text exists, the model put its\n-                            # entire response inside <think> tags; use that as the content.\n-                            if reasoning_text:\n-                                self._vprint(f\"{self.log_prefix}Using reasoning as response content (model wrapped entire response in think tags).\", force=True)\n-                                final_response = reasoning_text\n-                                empty_msg = {\n-                                    \"role\": \"assistant\",\n-                                    \"content\": final_response,\n-                                    \"reasoning\": reasoning_text,\n-                                    \"finish_reason\": finish_reason,\n-                                }\n-                                messages.append(empty_msg)\n-                                break\n-\n-                            # Truly empty -- no reasoning and no content\n-                            empty_msg = {\n-                                \"role\": \"assistant\",\n-                                \"content\": final_response,\n-                                \"reasoning\": reasoning_text,\n-                                \"finish_reason\": finish_reason,\n-                            }\n-                            messages.append(empty_msg)\n-\n-                            self._cleanup_task_resources(effective_task_id)\n-                            self._persist_session(messages, conversation_history)\n-\n-                            error_message = \"Model generated only think blocks with no actual response after 3 retries\"\n-                            if empty_response_info[\"is_local_custom\"]:\n-                                error_message = (\n-                                    \"Local/custom backend returned reasoning-only output with no visible response after 3 retries. \"\n-                                    \"Likely causes: wrong /v1 endpoint, runtime context window smaller than Hermes expects, \"\n-                                    \"or a resumed/large session exceeding the backend's actual context limit.\"\n-                                )\n-\n-                            return {\n-                                \"final_response\": final_response or None,\n-                                \"messages\": messages,\n-                                \"api_calls\": api_call_count,\n-                                \"completed\": False,\n-                                \"partial\": True,\n-                                \"error\": error_message\n-                            }\n+                        final_response = \"(empty)\"\n+                        break\n                     \n                     # Reset retry counter/signature on successful content\n                     if hasattr(self, '_empty_content_retries'):"]},{"file":"tests/test_run_agent.py","hunks":["@@ -1488,19 +1488,14 @@ def test_invalid_tool_name_retry(self, agent):\n         assert result[\"completed\"] is True\n         assert result[\"api_calls\"] == 2\n \n-    def test_empty_content_retry_uses_inline_reasoning_as_response(self, agent):\n-        \"\"\"Reasoning-only payloads should recover the inline reasoning text.\"\"\"\n+    def test_inline_think_blocks_reasoning_only_accepted(self, agent):\n+        \"\"\"Inline <think> reasoning-only responses accepted with (empty) content, no retries.\"\"\"\n         self._setup_agent(agent)\n         empty_resp = _mock_response(\n             content=\"<think>internal reasoning</think>\",\n             finish_reason=\"stop\",\n         )\n-        # Return empty 3 times to exhaust retries\n-        agent.client.chat.completions.create.side_effect = [\n-            empty_resp,\n-            empty_resp,\n-            empty_resp,\n-        ]\n+        agent.client.chat.completions.create.side_effect = [empty_resp]\n         with (\n             patch.object(agent, \"_persist_session\"),\n             patch.object(agent, \"_save_trajectory\"),","@@ -1508,10 +1503,14 @@ def test_empty_content_retry_uses_inline_reasoning_as_response(self, agent):\n         ):\n             result = agent.run_conversation(\"answer me\")\n         assert result[\"completed\"] is True\n-        assert result[\"final_response\"] == \"internal reasoning\"\n-\n-    def test_empty_content_local_resumed_session_triggers_compression(self, agent):\n-        \"\"\"Local resumed reasoning-only responses should compress before burning retries.\"\"\"\n+        assert result[\"final_response\"] == \"(empty)\"\n+        assert result[\"api_calls\"] == 1  # no retries\n+        # Reasoning should be preserved in the assistant message\n+        assistant_msgs = [m for m in result[\"messages\"] if m.get(\"role\") == \"assistant\"]\n+        assert any(m.get(\"reasoning\") for m in assistant_msgs)\n+\n+    def test_reasoning_only_local_resumed_no_compression_triggered(self, agent):\n+        \"\"\"Reasoning-only responses no longer trigger compression — accepted immediately.\"\"\"\n         self._setup_agent(agent)\n         agent.base_url = \"http://127.0.0.1:1234/v1\"\n         agent.compression_enabled = True","@@ -1520,39 +1519,34 @@ def test_empty_content_local_resumed_session_triggers_compression(self, agent):\n             finish_reason=\"stop\",\n             reasoning_content=\"reasoning only\",\n         )\n-        ok_resp = _mock_response(content=\"Recovered after compression\", finish_reason=\"stop\")\n         prefill = [\n             {\"role\": \"user\", \"content\": \"old question\"},\n             {\"role\": \"assistant\", \"content\": \"old answer\"},\n         ]\n \n         with (\n-            patch.object(agent, \"_interruptible_api_call\", side_effect=[empty_resp, ok_resp]),\n+            patch.object(agent, \"_interruptible_api_call\", side_effect=[empty_resp]),\n             patch.object(agent, \"_compress_context\") as mock_compress,\n             patch.object(agent, \"_persist_session\"),\n             patch.object(agent, \"_save_trajectory\"),\n             patch.object(agent, \"_cleanup_task_resources\"),\n         ):\n-            mock_compress.return_value = (\n-                [{\"role\": \"user\", \"content\": \"compressed user message\"}],\n-                \"compressed system prompt\",\n-            )\n             result = agent.run_conversation(\"hello\", conversation_history=prefill)\n \n-        mock_compress.assert_called_once()\n+        mock_compress.assert_not_called()  # no compression triggered\n         assert result[\"completed\"] is True\n-        assert result[\"final_response\"] == \"Recovered after compression\"\n-        assert result[\"api_calls\"] == 1  # compression retry is refunded, same as explicit overflow path\n+        assert result[\"final_response\"] == \"(empty)\"\n+        assert result[\"api_calls\"] == 1\n \n-    def test_empty_content_repeated_structured_reasoning_salvages_early(self, agent):\n-        \"\"\"Repeated identical structured reasoning-only responses should stop retrying early.\"\"\"\n+    def test_reasoning_only_response_accepted_without_retry(self, agent):\n+        \"\"\"Reasoning-only response should be accepted with (empty) content, no retries.\"\"\"\n         self._setup_agent(agent)\n         empty_resp = _mock_response(\n             content=None,\n             finish_reason=\"stop\",\n             reasoning_content=\"structured reasoning answer\",\n         )\n-        agent.client.chat.completions.create.side_effect = [empty_resp, empty_resp]\n+        agent.client.chat.completions.create.side_effect = [empty_resp]\n         with (\n             patch.object(agent, \"_persist_session\"),\n             patch.object(agent, \"_save_trajectory\"),","@@ -1560,24 +1554,24 @@ def test_empty_content_repeated_structured_reasoning_salvages_early(self, agent)\n         ):\n             result = agent.run_conversation(\"answer me\")\n         assert result[\"completed\"] is True\n-        assert result[\"final_response\"] == \"structured reasoning answer\"\n-        assert result[\"api_calls\"] == 2\n+        assert result[\"final_response\"] == \"(empty)\"\n+        assert result[\"api_calls\"] == 1  # no retries\n \n-    def test_empty_content_local_custom_error_is_actionable(self, agent):\n-        \"\"\"Local/custom retries should return a diagnostic tailored to context/endpoint mismatch.\"\"\"\n+    def test_truly_empty_response_accepted_without_retry(self, agent):\n+        \"\"\"Truly empty response (no content, no reasoning) should still complete with (empty).\"\"\"\n         self._setup_agent(agent)\n         agent.base_url = \"http://127.0.0.1:1234/v1\"\n         empty_resp = _mock_response(content=None, finish_reason=\"stop\")\n-        agent.client.chat.completions.create.side_effect = [empty_resp, empty_resp, empty_resp]\n+        agent.client.chat.completions.create.side_effect = [empty_resp]\n         with (\n             patch.object(agent, \"_persist_session\"),\n             patch.object(agent, \"_save_trajectory\"),\n             patch.object(agent, \"_cleanup_task_resources\"),\n         ):\n             result = agent.run_conversation(\"answer me\")\n-        assert result[\"completed\"] is False\n-        assert \"Local/custom backend returned reasoning-only output\" in result[\"error\"]\n-        assert \"wrong /v1 endpoint\" in result[\"error\"]\n+        assert result[\"completed\"] is True\n+        assert result[\"final_response\"] == \"(empty)\"\n+        assert result[\"api_calls\"] == 1  # no retries\n \n     def test_nous_401_refreshes_after_remint_and_retries(self, agent):\n         self._setup_agent(agent)"]},{"file":"tests/test_tool_arg_coercion.py","hunks":["@@ -0,0 +1,262 @@\n+\"\"\"Tests for tool argument type coercion.\n+\n+When LLMs return tool call arguments, they frequently put numbers as strings\n+(\"42\" instead of 42) and booleans as strings (\"true\" instead of true).\n+coerce_tool_args() fixes these type mismatches by comparing argument values\n+against the tool's JSON Schema before dispatch.\n+\"\"\"\n+\n+import pytest\n+from unittest.mock import patch\n+\n+from model_tools import (\n+    coerce_tool_args,\n+    _coerce_value,\n+    _coerce_number,\n+    _coerce_boolean,\n+)\n+\n+\n+# ── Low-level coercion helpers ────────────────────────────────────────────\n+\n+\n+class TestCoerceNumber:\n+    \"\"\"Unit tests for _coerce_number.\"\"\"\n+\n+    def test_integer_string(self):\n+        assert _coerce_number(\"42\") == 42\n+        assert isinstance(_coerce_number(\"42\"), int)\n+\n+    def test_negative_integer(self):\n+        assert _coerce_number(\"-7\") == -7\n+\n+    def test_zero(self):\n+        assert _coerce_number(\"0\") == 0\n+        assert isinstance(_coerce_number(\"0\"), int)\n+\n+    def test_float_string(self):\n+        assert _coerce_number(\"3.14\") == 3.14\n+        assert isinstance(_coerce_number(\"3.14\"), float)\n+\n+    def test_float_with_zero_fractional(self):\n+        \"\"\"3.0 should become int(3) since there's no fractional part.\"\"\"\n+        assert _coerce_number(\"3.0\") == 3\n+        assert isinstance(_coerce_number(\"3.0\"), int)\n+\n+    def test_integer_only_rejects_float(self):\n+        \"\"\"When integer_only=True, \"3.14\" should stay as string.\"\"\"\n+        result = _coerce_number(\"3.14\", integer_only=True)\n+        assert result == \"3.14\"\n+        assert isinstance(result, str)\n+\n+    def test_integer_only_accepts_whole(self):\n+        assert _coerce_number(\"42\", integer_only=True) == 42\n+\n+    def test_not_a_number(self):\n+        assert _coerce_number(\"hello\") == \"hello\"\n+\n+    def test_empty_string(self):\n+        assert _coerce_number(\"\") == \"\"\n+\n+    def test_large_number(self):\n+        assert _coerce_number(\"1000000\") == 1000000\n+\n+    def test_scientific_notation(self):\n+        assert _coerce_number(\"1e5\") == 100000\n+\n+    def test_inf_stays_string_for_integer_only(self):\n+        \"\"\"Infinity should not be converted to int.\"\"\"\n+        result = _coerce_number(\"inf\")\n+        assert result == float(\"inf\")\n+\n+    def test_negative_float(self):\n+        assert _coerce_number(\"-2.5\") == -2.5\n+\n+\n+class TestCoerceBoolean:\n+    \"\"\"Unit tests for _coerce_boolean.\"\"\"\n+\n+    def test_true_lowercase(self):\n+        assert _coerce_boolean(\"true\") is True\n+\n+    def test_false_lowercase(self):\n+        assert _coerce_boolean(\"false\") is False\n+\n+    def test_true_mixed_case(self):\n+        assert _coerce_boolean(\"True\") is True\n+\n+    def test_false_mixed_case(self):\n+        assert _coerce_boolean(\"False\") is False\n+\n+    def test_true_with_whitespace(self):\n+        assert _coerce_boolean(\"  true  \") is True\n+\n+    def test_not_a_boolean(self):\n+        assert _coerce_boolean(\"yes\") == \"yes\"\n+\n+    def test_one_zero_not_coerced(self):\n+        \"\"\"'1' and '0' are not boolean values.\"\"\"\n+        assert _coerce_boolean(\"1\") == \"1\"\n+        assert _coerce_boolean(\"0\") == \"0\"\n+\n+    def test_empty_string(self):\n+        assert _coerce_boolean(\"\") == \"\"\n+\n+\n+class TestCoerceValue:\n+    \"\"\"Unit tests for _coerce_value.\"\"\"\n+\n+    def test_integer_type(self):\n+        assert _coerce_value(\"5\", \"integer\") == 5\n+\n+    def test_number_type(self):\n+        assert _coerce_value(\"3.14\", \"number\") == 3.14\n+\n+    def test_boolean_type(self):\n+        assert _coerce_value(\"true\", \"boolean\") is True\n+\n+    def test_string_type_passthrough(self):\n+        \"\"\"Strings expected as strings should not be coerced.\"\"\"\n+        assert _coerce_value(\"hello\", \"string\") == \"hello\"\n+\n+    def test_unknown_type_passthrough(self):\n+        assert _coerce_value(\"stuff\", \"object\") == \"stuff\"\n+\n+    def test_union_type_prefers_first_match(self):\n+        \"\"\"Union types try each in order.\"\"\"\n+        assert _coerce_value(\"42\", [\"integer\", \"string\"]) == 42\n+\n+    def test_union_type_falls_through(self):\n+        \"\"\"If no type matches, return original string.\"\"\"\n+        assert _coerce_value(\"hello\", [\"integer\", \"boolean\"]) == \"hello\"\n+\n+    def test_union_with_string_preserves_original(self):\n+        \"\"\"A non-numeric string in [number, string] should stay a string.\"\"\"\n+        assert _coerce_value(\"hello\", [\"number\", \"string\"]) == \"hello\"\n+\n+\n+# ── Full coerce_tool_args with registry ───────────────────────────────────\n+\n+\n+class TestCoerceToolArgs:\n+    \"\"\"Integration tests for coerce_tool_args using the tool registry.\"\"\"\n+\n+    def _mock_schema(self, properties):\n+        \"\"\"Build a minimal tool schema with the given properties.\"\"\"\n+        return {\n+            \"name\": \"test_tool\",\n+            \"description\": \"test\",\n+            \"parameters\": {\n+                \"type\": \"object\",\n+                \"properties\": properties,\n+            },\n+        }\n+\n+    def test_coerces_integer_arg(self):\n+        schema = self._mock_schema({\"limit\": {\"type\": \"integer\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"limit\": \"10\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"limit\"] == 10\n+            assert isinstance(result[\"limit\"], int)\n+\n+    def test_coerces_boolean_arg(self):\n+        schema = self._mock_schema({\"merge\": {\"type\": \"boolean\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"merge\": \"true\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"merge\"] is True\n+\n+    def test_coerces_number_arg(self):\n+        schema = self._mock_schema({\"temperature\": {\"type\": \"number\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"temperature\": \"0.7\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"temperature\"] == 0.7\n+\n+    def test_leaves_string_args_alone(self):\n+        schema = self._mock_schema({\"path\": {\"type\": \"string\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"path\": \"/tmp/file.txt\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"path\"] == \"/tmp/file.txt\"\n+\n+    def test_leaves_already_correct_types(self):\n+        schema = self._mock_schema({\"limit\": {\"type\": \"integer\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"limit\": 10}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"limit\"] == 10\n+\n+    def test_unknown_tool_returns_args_unchanged(self):\n+        with patch(\"model_tools.registry.get_schema\", return_value=None):\n+            args = {\"limit\": \"10\"}\n+            result = coerce_tool_args(\"unknown_tool\", args)\n+            assert result[\"limit\"] == \"10\"\n+\n+    def test_empty_args(self):\n+        assert coerce_tool_args(\"test_tool\", {}) == {}\n+\n+    def test_none_args(self):\n+        assert coerce_tool_args(\"test_tool\", None) is None\n+\n+    def test_preserves_non_string_values(self):\n+        \"\"\"Lists, dicts, and other non-string values are never touched.\"\"\"\n+        schema = self._mock_schema({\n+            \"items\": {\"type\": \"array\"},\n+            \"config\": {\"type\": \"object\"},\n+        })\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"items\": [1, 2, 3], \"config\": {\"key\": \"val\"}}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"items\"] == [1, 2, 3]\n+            assert result[\"config\"] == {\"key\": \"val\"}\n+\n+    def test_extra_args_without_schema_left_alone(self):\n+        \"\"\"Args not in the schema properties are not touched.\"\"\"\n+        schema = self._mock_schema({\"limit\": {\"type\": \"integer\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"limit\": \"10\", \"extra\": \"42\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"limit\"] == 10\n+            assert result[\"extra\"] == \"42\"  # no schema for extra, stays string\n+\n+    def test_mixed_coercion(self):\n+        \"\"\"Multiple args coerced in the same call.\"\"\"\n+        schema = self._mock_schema({\n+            \"offset\": {\"type\": \"integer\"},\n+            \"limit\": {\"type\": \"integer\"},\n+            \"full\": {\"type\": \"boolean\"},\n+            \"path\": {\"type\": \"string\"},\n+        })\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\n+                \"offset\": \"1\",\n+                \"limit\": \"500\",\n+                \"full\": \"false\",\n+                \"path\": \"readme.md\",\n+            }\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"offset\"] == 1\n+            assert result[\"limit\"] == 500\n+            assert result[\"full\"] is False\n+            assert result[\"path\"] == \"readme.md\"\n+\n+    def test_failed_coercion_preserves_original(self):\n+        \"\"\"A non-parseable string stays as string even if schema says integer.\"\"\"\n+        schema = self._mock_schema({\"limit\": {\"type\": \"integer\"}})\n+        with patch(\"model_tools.registry.get_schema\", return_value=schema):\n+            args = {\"limit\": \"not_a_number\"}\n+            result = coerce_tool_args(\"test_tool\", args)\n+            assert result[\"limit\"] == \"not_a_number\"\n+\n+    def test_real_read_file_schema(self):\n+        \"\"\"Test against the actual read_file schema from the registry.\"\"\"\n+        # This uses the real registry — read_file should be registered\n+        args = {\"path\": \"foo.py\", \"offset\": \"10\", \"limit\": \"100\"}\n+        result = coerce_tool_args(\"read_file\", args)\n+        assert result[\"path\"] == \"foo.py\"\n+        assert result[\"offset\"] == 10\n+        assert isinstance(result[\"offset\"], int)\n+        assert result[\"limit\"] == 100\n+        assert isinstance(result[\"limit\"], int)\n"]}]},{"kind":"pr","number":29225,"title":"fix(agent): treat Codex codex_reasoning_items as thinking-only on empty turns (#29205)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1d584a301","number":0},{"kind":"pr","value":"#45822","number":45822}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":36787,"title":"fix(agent): drop codex reasoning-only turns before anthropic fallback","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":45822,"title":"fix(agent): treat Codex reasoning items as thinking-only","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","agent/conversation_loop.py","run_agent.py","tests/run_agent/test_thinking_only_sanitizer.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}