# NousResearch/hermes-agent · Codex reasoning-only turns causing empty assistant prefill issues

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 8 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 3 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #2466. Verify it addresses your case.
- **Recommended:** read PR #2466 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-5278,pr-45822`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.23`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2128: Bug: Empty assistant message from reasoning-only fix leaks into Chat Completions API, causing prefill rejection
- #29205: Anthropic fallback can fail after Codex reasoning-only empty turns due to trailing assistant prefill

## Prior & competing PRs

- **#2466: fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/email.py`, `hermes_state.py`, `run_agent.py`, `tools/voice_mode.py`
  - fix hunks:
  - `gateway/platforms/email.py`

    ```diff
    @@ -230,7 +230,7 @@ async def connect(self) -> bool:
                 # Mark all existing messages as seen so we only process new ones
                 imap.select("INBOX")
                 status, data = imap.uid("search", None, "ALL")
    -            if status == "OK" and data[0]:
    +            if status == "OK" and data and data[0]:
                     for uid in data[0].split():
                         self._seen_uids.add(uid)
                 imap.logout()
    ```

    ```diff
    @@ -295,7 +295,7 @@ def _fetch_new_messages(self) -> List[Dict[str, Any]]:
                 imap.select("INBOX")
     
                 status, data = imap.uid("search", None, "UNSEEN")
    -            if status != "OK" or not data[0]:
    +            if status != "OK" or not data or not data[0]:
                     imap.logout()
                     return results
     
    ```
  - `hermes_state.py`

    ```diff
    @@ -855,23 +855,25 @@ def search_sessions(
     
         def session_count(self, source: str = None) -> int:
             """Count sessions, optionally filtered by source."""
    -        if source:
    -            cursor = self._conn.execute(
    -                "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
    -            )
    -        else:
    -            cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
    -        return cursor.fetchone()[0]
    +        with self._lock:
    +            if source:
    +                cursor = self._conn.execute(
    +                    "SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
    +                )
    +            else:
    +                cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
    +            return cursor.fetchone()[0]
     
         def message_count(self, session_id: str = None) -> int:
             """Count messages, optionally for a specific session."""
    -        if session_id:
    -            cursor = self._conn.execute(
    -                "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
    -            )
    -        else:
    -            cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
    -        return cursor.fetchone()[0]
    +        with self._lock:
    +            if session_id:
    +                cursor = self._conn.execute(
    +                    "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
    +                )
    +            else:
    +                cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
    +            return cursor.fetchone()[0]
     
         # =========================================================================
         # Export and cleanup
    ```
  - `run_agent.py`

    ```diff
    @@ -108,7 +108,7 @@
     
     
     class _SafeWriter:
    -    """Transparent stdio wrapper that catches OSError from broken pipes.
    +    """Transparent stdio wrapper that catches OSError/ValueError from broken pipes.
     
         When hermes-agent runs as a systemd service, Docker container, or headless
         daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer
    ```

    ```diff
    @@ -117,8 +117,13 @@ class _SafeWriter:
         run_conversation() — especially via double-fault when an except handler
         also tries to print.
     
    +    Additionally, when subagents run in ThreadPoolExecutor threads, the shared
    +    stdout handle can close between thread teardown and cleanup, raising
    +    ``ValueError: I/O operation on closed file`` instead of OSError.
    +
         This wrapper delegates all writes to the underlying stream and silently
    -    catches OSError. It is transparent when the wrapped stream is healthy.
    +    catches both OSError and ValueError. It is transparent when the wrapped
    +    stream is healthy.
         """
     
         __slots__ = ("_inner",)
    ```

    ```diff
    @@ -129,13 +134,13 @@ def __init__(self, inner):
         def write(self, data):
             try:
                 return self._inner.write(data)
    -        except OSError:
    +        except (OSError, ValueError):
                 return len(data) if isinstance(data, str) else 0
     
         def flush(self):
             try:
                 self._inner.flush()
    -        except OSError:
    +        except (OSError, ValueError):
                 pass
     
         def fileno(self):
    ```

    ```diff
    @@ -144,7 +149,7 @@ def fileno(self):
         def isatty(self):
             try:
                 return self._inner.isatty()
    -        except OSError:
    +        except (OSError, ValueError):
                 return False
     
         def __getattr__(self, name):
    ```

    ```diff
    @@ -2438,7 +2443,18 @@ def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any
                     "Pre-call sanitizer: added %d stub tool result(s)",
                     len(missing_results),
                 )
    -
    +        # 3. Strip trailing empty assistant messages to prevent prefill rejection.
    +        # These can leak from Responses API reasoning-only turns (Codex/MiniMax)
    +        # where an empty assistant message is required by the Responses API but
    +        # must NOT be sent to Chat Completions or Anthropic Messages API providers.
    +        while (
    +            messages
    +            and messages[-1].get("role") == "assistant"
    +            and not (messages[-1].get("content") or "").strip()
    +            and not messages[-1].get("tool_calls")
    +        ):
    +            logger.debug("Pre-call sanitizer: removed trailing empty assistant message")
    +            messages = messages[:-1]
             return messages
     
         @staticmethod
    ```
  - `tools/voice_mode.py`

    ```diff
    @@ -81,8 +81,15 @@ def detect_audio_environment() -> dict:
                     warnings.append("No audio input/output devices detected")
             except Exception:
                 warnings.append("Audio subsystem error (PortAudio cannot query devices)")
    -    except (ImportError, OSError):
    +    except ImportError:
             warnings.append("Audio libraries not installed (pip install sounddevice numpy)")
    +    except OSError:
    +        warnings.append(
    +            "PortAudio system library not found -- install it first:\n"
    +            "  Linux:  sudo apt-get install libportaudio2\n"
    +            "  macOS:  brew install portaudio\n"
    +            "Then retry /voice on."
    +        )
     
         return {
             "available": len(warnings) == 0,
    ```
- **#5278: fix: accept reasoning-only responses without retries — set content to "(empty)"**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `model_tools.py`, `run_agent.py`, `tests/test_run_agent.py`, `tests/test_tool_arg_coercion.py`
  - fix hunks:
  - `model_tools.py`

    ```diff
    @@ -365,6 +365,97 @@ def get_tool_definitions(
     _READ_SEARCH_TOOLS = {"read_file", "search_files"}
     
     
    +# =========================================================================
    +# Tool argument type coercion
    +# =========================================================================
    +
    +def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
    +    """Coerce tool call arguments to match their JSON Schema types.
    +
    +    LLMs frequently return numbers as strings (``"42"`` instead of ``42``)
    +    and booleans as strings (``"true"`` instead of ``true``).  This compares
    +    each argument value against the tool's registered JSON Schema and attempts
    +    safe coercion when the value is a string but the schema expects a different
    +    type.  Original values are preserved when coercion fails.
    +
    +    Handles ``"type": "integer"``, ``"type": "number"``, ``"type": "boolean"``,
    +    and union types (``"type": ["integer", "string"]``).
    +    """
    +    if not args or not isinstance(args, dict):
    +        return args
    +
    +    schema = registry.get_schema(tool_name)
    +    if not schema:
    +        return args
    +
    +    properties = (schema.get("parameters") or {}).get("properties")
    +    if not properties:
    +        return args
    +
    +    for key, value in args.items():
    +        if not isinstance(value, str):
    +            continue
    +        prop_schema = properties.get(key)
    +        if not prop_schema:
    +            continue
    +        expected = prop_schema.get("type")
    +        if not expected:
    +            continue
    +        coerced = _coerce_value(value, expected)
    +        if coerced is not value:
    +            args[key] = coerced
    +
    +    return args
    +
    +
    +def _coerce_value(value: str, expected_type):
    +    """Attempt to coerce a string *value* to *expected_type*.
    +
    +    Returns the original string when coercion is not applicable or fails.
    +    """
    +    if isinstance(expected_type, list):
    +        # Union type — try each in order, return first successful coercion
    +        for t in expected_type:
    +            result = _coerce_value(value, t)
    +            if result is not value:
    +                return result
    +        return value
    +
    +    if expected_type in ("integer", "number"):
    +        return _coerce_number(value, integer_only=(expected_type == "integer"))
    +    if expected_type == "boolean":
    +        return _coerce_boolean(value)
    +    return value
    +
    +
    +def _coerce_number(value: str, integer_only: bool = False):
    +    """Try to parse *value* as a number.  Returns original string on failure."""
    +    try:
    +        f = float(value)
    +    except (ValueError, OverflowError):
    +        return value
    +    # Guard against inf/nan before int() conversion
    +    if f != f or f == float("inf") or f == float("-inf"):
    +        return f
    +    # If it looks like an integer (no fractional part), return int
    +    if f == int(f):
    +        return int(f)
    +    if integer_only:
    +        # Schema wants an integer but value has decimals — keep as string
    +        return value
    +    return f
    +
    +
    +def _coerce_boolean(value: str):
    +    """Try to parse *value* as a boolean.  Returns original string on failure."""
    +    low = value.strip().lower()
    +    if low == "true":
    +        return True
    +    if low == "false":
    +        return False
    +    return value
    +
    +
     def handle_function_call(
         function_name: str,
         function_args: Dict[str, Any],
    ```

    ```diff
    @@ -388,6 +479,9 @@ def handle_function_call(
         Returns:
             Function result as a JSON string.
         """
    +    # Coerce string arguments to their schema-declared types (e.g. "42"→42)
    +    function_args = coerce_tool_args(function_name, function_args)
    +
         # Notify the read-loop tracker when a non-read/search tool runs,
         # so the *consecutive* counter resets (reads after other work are fine).
         if function_name not in _READ_SEARCH_TOOLS:
    ```
  - `run_agent.py`

    ```diff
    @@ -8609,140 +8609,24 @@ def _stop_spinner():
                                 self._response_was_previewed = True
                                 break
     
    -                        # No fallback available — classify the empty response before
    -                        # blindly spending retries. Some local/custom backends surface
    -                        # implicit context pressure as reasoning-only output rather than
    -                        # an explicit overflow error.
    -                        if not hasattr(self, '_empty_content_retries'):
    -                            self._empty_content_retries = 0
    -                        self._empty_content_retries += 1
    -
    -                        empty_response_info = self._classify_empty_content_response(
    -                            assistant_message,
    -                            finish_reason=finish_reason,
    -                            approx_tokens=[redacted:secret-assignment]
    -                            api_messages=api_messages,
    -                            conversation_history=conversation_history,
    -                        )
    -                        reasoning_text = empty_response_info["reasoning_text"]
    -                        self._vprint(f"{self.log_prefix}⚠️  Response only contains think block with no content after it")
    +                        # Reasoning-only response: the model produced thinking
    +                        # but no visible content.  This is a valid response —
    +                        # keep reasoning in its own field and set content to
    +                        # "(empty)" so every provider accepts the message.
    +                        # No retries needed.
    +                        reasoning_text = self._extract_reasoning(assistant_message)
    +                        assistant_msg = self._build_assistant_message(assistant_message, finish_reason)
    +                        assistant_msg["content"] = "(empty)"
    +                        messages.append(assistant_msg)
    +
                             if reasoning_text:
                                 reasoning_preview = reasoning_text[:500] + "..." if len(reasoning_text) > 500 else reasoning_text
    -                            self._vprint(f"{self.log_prefix}   Reasoning: {reasoning_preview}")
    +                            self._vprint(f"{self.log_prefix}ℹ️  Reasoning-only response (no visible content). Reasoning: {reasoning_preview}")
                             else:
    -                            content_preview = final_response[:80] + "..." if len(final_response) > 80 else final_response
    -                            self._vprint(f"{self.log_prefix}   Content: '{content_preview}'")
    -
    -                        if empty_response_info["should_compress"]:
    -                            compression_attempts += 1
    -                            if compression_attempts > max_compression_attempts:
    -                                self._vprint(f"{self.log_prefix}❌ Max compression attempts ({max_compression_attempts}) reached.", force=True)
    -                                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)
    -                            else:
    -                                self._vprint(f"{self.log_prefix}🗜️  Reasoning-only response looks like implicit context pressure — attempting compression ({compression_attempts}/{max_compression_attempts})...", force=True)
    -                                original_len = len(messages)
    -                                messages, active_system_prompt = self._compress_context(
    -                                    messages, system_message, approx_tokens=[redacted:secret-assignment]
    -                                    task_id=effective_task_id,
    -                                )
    -                                if len(messages) < original_len:
    -                                    conversation_history = None
    -                                    self._emit_status(f"🗜️ Compressed {original_len} → {len(messages)} messages after reasoning-only response, retrying...")
    -                                    time.sleep(2)
    -                                    api_call_count -= 1
    -                                    self.iteration_budget.refund()
    -                                    retry_count += 1
    -                                    continue
    -                                self._vprint(f"{self.log_prefix}   Compression could not shrink the session; falling back to retry/salvage logic.")
    +                            self._vprint(f"{self.log_prefix}ℹ️  Empty response (no content or reasoning).")
     
    -                        if (
    -                            reasoning_text
    -                            and empty_response_info["repeated_signature"]
    -                            and empty_response_info["has_structured_reasoning"]
    -                        ):
    -                            self._vprint(f"{self.log_prefix}ℹ️  Structured reasoning-only response repeated unchanged — using reasoning text directly.", force=True)
    -                            self._empty_content_retries = 0
    -                            final_response = reasoning_text
    -                            empty_msg = {
    -                                "role": "assistant",
    -                                "content": final_response,
    -                                "reasoning": reasoning_text,
    -                                "finish_reason": finish_reason,
    -                            }
    -                            messages.append(empty_msg)
    -                            break
    -                        
    -                        if self._empty_content_retries < 3:
    -                            self._vprint(f"{self.log_prefix}🔄 Retrying API call ({self._empty_content_retries}/3)...")
    -                            continue
    -                        else:
    -                            self._vprint(f"{self.log_prefix}❌ Max retries (3) for empty content exceeded.", force=True)
    -                            self._empty_content_retries = 0
    -                            
    -                            # If a prior tool_calls turn had real content, salvage it:
    -                            # rewrite that turn's content to a brief tool description,
    -                            # and use the original content as the final response here.
    -                            fallback = getattr(self, '_last_content_with_tools', None)
    -                            if fallback:
    -                                self._last_content_with_tools = None
    -                                # Find the last assistant message with tool_calls and rewrite it
    -                                for i in range(len(messages) - 1, -1, -1):
    -                                    msg = messages[i]
    -                                    if msg.get("role") == "assistant" and msg.get("tool_calls"):
    -                                        tool_names = []
    -                                        for tc in msg["tool_calls"]:
    -                                            if not tc or not isinstance(tc, dict): continue
    -                                            fn = tc.get("function", {})
    -                                            tool_names.append(fn.get("name", "unknown"))
    -                                        msg["content"] = f"Calling the {', '.join(tool_names)} tool{'s' if len(tool_names) > 1 else ''}..."
    -                                        break
    -                                # Strip <think> blocks from fallback content for user display
    -                                final_response = self._strip_think_blocks(fallback).strip()
    -                                self._response_was_previewed = True
    -                                break
    -                            
    -                            # No fallback -- if reasoning_text exists, the model put its
    -                            # entire response inside <think> tags; use that as the content.
    -                            if reasoning_text:
    -                                self._vprint(f"{self.log_prefix}Using reasoning as response content (model wrapped entire response in think tags).", force=True)
    -                                final_response = reasoning_text
    -                                empty_msg = {
    -                                    "role": "assistant",
    -                                    "content": final_response,
    -                                    "reasoning": reasoning_text,
    -                                    "finish_reason": finish_reason,
    -                                }
    -                                messages.append(empty_msg)
    -                                break
    -
    -                            # Truly empty -- no reasoning and no content
    -                            empty_msg = {
    -                                "role": "assistant",
    -                                "content": final_response,
    -                                "reasoning": reasoning_text,
    -                                "finish_reason": finish_reason,
    -                            }
    -                            messages.append(empty_msg)
    -
    -                            self._cleanup_task_resources(effective_task_id)
    -                            self._persist_session(messages, conversation_history)
    -
    -                            error_message = "Model generated only think blocks with no actual response after 3 retries"
    -                            if empty_response_info["is_local_custom"]:
    -                                error_message = (
    -                                    "Local/custom backend returned reasoning-only output with no visible response after 3 retries. "
    -                                    "Likely causes: wrong /v1 endpoint, runtime context window smaller than Hermes expects, "
    -                                    "or a resumed/large session exceeding the backend's actual context limit."
    -                                )
    -
    -                            return {
    -                                "final_response": final_response or None,
    -                                "messages": messages,
    -                                "api_calls": api_call_count,
    -                                "completed": False,
    -                                "partial": True,
    -                                "error": error_message
    -                            }
    +                        final_response = "(empty)"
    +                        break
                         
                         # Reset retry counter/signature on successful content
                         if hasattr(self, '_empty_content_retries'):
    ```
  - `tests/test_run_agent.py`

    ```diff
    @@ -1488,19 +1488,14 @@ def test_invalid_tool_name_retry(self, agent):
             assert result["completed"] is True
             assert result["api_calls"] == 2
     
    -    def test_empty_content_retry_uses_inline_reasoning_as_response(self, agent):
    -        """Reasoning-only payloads should recover the inline reasoning text."""
    +    def test_inline_think_blocks_reasoning_only_accepted(self, agent):
    +        """Inline <think> reasoning-only responses accepted with (empty) content, no retries."""
             self._setup_agent(agent)
             empty_resp = _mock_response(
                 content="<think>internal reasoning</think>",
                 finish_reason="stop",
             )
    -        # Return empty 3 times to exhaust retries
    -        agent.client.chat.completions.create.side_effect = [
    -            empty_resp,
    -            empty_resp,
    -            empty_resp,
    -        ]
    +        agent.client.chat.completions.create.side_effect = [empty_resp]
             with (
                 patch.object(agent, "_persist_session"),
                 patch.object(agent, "_save_trajectory"),
    ```

    ```diff
    @@ -1508,10 +1503,14 @@ def test_empty_content_retry_uses_inline_reasoning_as_response(self, agent):
             ):
                 result = agent.run_conversation("answer me")
             assert result["completed"] is True
    -        assert result["final_response"] == "internal reasoning"
    -
    -    def test_empty_content_local_resumed_session_triggers_compression(self, agent):
    -        """Local resumed reasoning-only responses should compress before burning retries."""
    +        assert result["final_response"] == "(empty)"
    +        assert result["api_calls"] == 1  # no retries
    +        # Reasoning should be preserved in the assistant message
    +        assistant_msgs = [m for m in result["messages"] if m.get("role") == "assistant"]
    +        assert any(m.get("reasoning") for m in assistant_msgs)
    +
    +    def test_reasoning_only_local_resumed_no_compression_triggered(self, agent):
    +        """Reasoning-only responses no longer trigger compression — accepted immediately."""
             self._setup_agent(agent)
             agent.base_url = "http://127.0.0.1:1234/v1"
             agent.compression_enabled = True
    ```

    ```diff
    @@ -1520,39 +1519,34 @@ def test_empty_content_local_resumed_session_triggers_compression(self, agent):
                 finish_reason="stop",
                 reasoning_content="reasoning only",
             )
    -        ok_resp = _mock_response(content="Recovered after compression", finish_reason="stop")
             prefill = [
                 {"role": "user", "content": "old question"},
                 {"role": "assistant", "content": "old answer"},
             ]
     
             with (
    -            patch.object(agent, "_interruptible_api_call", side_effect=[empty_resp, ok_resp]),
    +            patch.object(agent, "_interruptible_api_call", side_effect=[empty_resp]),
                 patch.object(agent, "_compress_context") as mock_compress,
                 patch.object(agent, "_persist_session"),
                 patch.object(agent, "_save_trajectory"),
                 patch.object(agent, "_cleanup_task_resources"),
             ):
    -            mock_compress.return_value = (
    -                [{"role": "user", "content": "compressed user message"}],
    -                "compressed system prompt",
    -            )
                 result = agent.run_conversation("hello", conversation_history=prefill)
     
    -        mock_compress.assert_called_once()
    +        mock_compress.assert_not_called()  # no compression triggered
             assert result["completed"] is True
    -        assert result["final_response"] == "Recovered after compression"
    -        assert result["api_calls"] == 1  # compression retry is refunded, same as explicit overflow path
    +        assert result["final_response"] == "(empty)"
    +        assert result["api_calls"] == 1
     
    -    def test_empty_content_repeated_structured_reasoning_salvages_early(self, agent):
    -        """Repeated identical structured reasoning-only responses should stop retrying early."""
    +    def test_reasoning_only_response_accepted_without_retry(self, agent):
    +        """Reasoning-only response should be accepted with (empty) content, no retries."""
             self._setup_agent(agent)
             empty_resp = _mock_response(
                 content=None,
                 finish_reason="stop",
                 reasoning_content="structured reasoning answer",
             )
    -        agent.client.chat.completions.create.side_effect = [empty_resp, empty_resp]
    +        agent.client.chat.completions.create.side_effect = [empty_resp]
             with (
                 patch.object(agent, "_persist_session"),
                 patch.object(agent, "_save_trajectory"),
    ```

    ```diff
    @@ -1560,24 +1554,24 @@ def test_empty_content_repeated_structured_reasoning_salvages_early(self, agent)
             ):
                 result = agent.run_conversation("answer me")
             assert result["completed"] is True
    -        assert result["final_response"] == "structured reasoning answer"
    -        assert result["api_calls"] == 2
    +        assert result["final_response"] == "(empty)"
    +        assert result["api_calls"] == 1  # no retries
     
    -    def test_empty_content_local_custom_error_is_actionable(self, agent):
    -        """Local/custom retries should return a diagnostic tailored to context/endpoint mismatch."""
    +    def test_truly_empty_response_accepted_without_retry(self, agent):
    +        """Truly empty response (no content, no reasoning) should still complete with (empty)."""
             self._setup_agent(agent)
             agent.base_url = "http://127.0.0.1:1234/v1"
             empty_resp = _mock_response(content=None, finish_reason="stop")
    -        agent.client.chat.completions.create.side_effect = [empty_resp, empty_resp, empty_resp]
    +        agent.client.chat.completions.create.side_effect = [empty_resp]
             with (
                 patch.object(agent, "_persist_session"),
                 patch.object(agent, "_save_trajectory"),
                 patch.object(agent, "_cleanup_task_resources"),
             ):
                 result = agent.run_conversation("answer me")
    -        assert result["completed"] is False
    -        assert "Local/custom backend returned reasoning-only output" in result["error"]
    -        assert "wrong /v1 endpoint" in result["error"]
    +        assert result["completed"] is True
    +        assert result["final_response"] == "(empty)"
    +        assert result["api_calls"] == 1  # no retries
     
         def test_nous_401_refreshes_after_remint_and_retries(self, agent):
             self._setup_agent(agent)
    ```
  - `tests/test_tool_arg_coercion.py`

    ```diff
    @@ -0,0 +1,262 @@
    +"""Tests for tool argument type coercion.
    +
    +When LLMs return tool call arguments, they frequently put numbers as strings
    +("42" instead of 42) and booleans as strings ("true" instead of true).
    +coerce_tool_args() fixes these type mismatches by comparing argument values
    +against the tool's JSON Schema before dispatch.
    +"""
    +
    +import pytest
    +from unittest.mock import patch
    +
    +from model_tools import (
    +    coerce_tool_args,
    +    _coerce_value,
    +    _coerce_number,
    +    _coerce_boolean,
    +)
    +
    +
    +# ── Low-level coercion helpers ────────────────────────────────────────────
    +
    +
    +class TestCoerceNumber:
    +    """Unit tests for _coerce_number."""
    +
    +    def test_integer_string(self):
    +        assert _coerce_number("42") == 42
    +        assert isinstance(_coerce_number("42"), int)
    +
    +    def test_negative_integer(self):
    +        assert _coerce_number("-7") == -7
    +
    +    def test_zero(self):
    +        assert _coerce_number("0") == 0
    +        assert isinstance(_coerce_number("0"), int)
    +
    +    def test_float_string(self):
    +        assert _coerce_number("3.14") == 3.14
    +        assert isinstance(_coerce_number("3.14"), float)
    +
    +    def test_float_with_zero_fractional(self):
    +        """3.0 should become int(3) since there's no fractional part."""
    +        assert _coerce_number("3.0") == 3
    +        assert isinstance(_coerce_number("3.0"), int)
    +
    +    def test_integer_only_rejects_float(self):
    +        """When integer_only=True, "3.14" should stay as string."""
    +        result = _coerce_number("3.14", integer_only=True)
    +        assert result == "3.14"
    +        assert isinstance(result, str)
    +
    +    def test_integer_only_accepts_whole(self):
    +        assert _coerce_number("42", integer_only=True) == 42
    +
    +    def test_not_a_number(self):
    +        assert _coerce_number("hello") == "hello"
    +
    +    def test_empty_string(self):
    +        assert _coerce_number("") == ""
    +
    +    def test_large_number(self):
    +        assert _coerce_number("1000000") == 1000000
    +
    +    def test_scientific_notation(self):
    +        assert _coerce_number("1e5") == 100000
    +
    +    def test_inf_stays_string_for_integer_only(self):
    +        """Infinity should not be converted to int."""
    +        result = _coerce_number("inf")
    +        assert result == float("inf")
    +
    +    def test_negative_float(self):
    +        assert _coerce_number("-2.5") == -2.5
    +
    +
    +class TestCoerceBoolean:
    +    """Unit tests for _coerce_boolean."""
    +
    +    def test_true_lowercase(self):
    +        assert _coerce_boolean("true") is True
    +
    +    def test_false_lowercase(self):
    +        assert _coerce_boolean("false") is False
    +
    +    def test_true_mixed_case(self):
    +        assert _coerce_boolean("True") is True
    +
    +    def test_false_mixed_case(self):
    +        assert _coerce_boolean("False") is False
    +
    +    def test_true_with_whitespace(self):
    +        assert _coerce_boolean("  true  ") is True
    +
    +    def test_not_a_boolean(self):
    +        assert _coerce_boolean("yes") == "yes"
    +
    +    def test_one_zero_not_coerced(self):
    +        """'1' and '0' are not boolean values."""
    +        assert _coerce_boolean("1") == "1"
    +        assert _coerce_boolean("0") == "0"
    +
    +    def test_empty_string(self):
    +        assert _coerce_boolean("") == ""
    +
    +
    +class TestCoerceValue:
    +    """Unit tests for _coerce_value."""
    +
    +    def test_integer_type(self):
    +        assert _coerce_value("5", "integer") == 5
    +
    +    def test_number_type(self):
    +        assert _coerce_value("3.14", "number") == 3.14
    +
    +    def test_boolean_type(self):
    +        assert _coerce_value("true", "boolean") is True
    +
    +    def test_string_type_passthrough(self):
    +        """Strings expected as strings should not be coerced."""
    +        assert _coerce_value("hello", "string") == "hello"
    +
    +    def test_unknown_type_passthrough(self):
    +        assert _coerce_value("stuff", "object") == "stuff"
    +
    +    def test_union_type_prefers_first_match(self):
    +        """Union types try each in order."""
    +        assert _coerce_value("42", ["integer", "string"]) == 42
    +
    +    def test_union_type_falls_through(self):
    +        """If no type matches, return original string."""
    +        assert _coerce_value("hello", ["integer", "boolean"]) == "hello"
    +
    +    def test_union_with_string_preserves_original(self):
    +        """A non-numeric string in [number, string] should stay a string."""
    +        assert _coerce_value("hello", ["number", "string"]) == "hello"
    +
    +
    +# ── Full coerce_tool_args with registry ───────────────────────────────────
    +
    +
    +class TestCoerceToolArgs:
    +    """Integration tests for coerce_tool_args using the tool registry."""
    +
    +    def _mock_schema(self, properties):
    +        """Build a minimal tool schema with the given properties."""
    +        return {
    +            "name": "test_tool",
    +            "description": "test",
    +            "parameters": {
    +                "type": "object",
    +                "properties": properties,
    +            },
    +        }
    +
    +    def test_coerces_integer_arg(self):
    +        schema = self._mock_schema({"limit": {"type": "integer"}})
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"limit": "10"}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["limit"] == 10
    +            assert isinstance(result["limit"], int)
    +
    +    def test_coerces_boolean_arg(self):
    +        schema = self._mock_schema({"merge": {"type": "boolean"}})
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"merge": "true"}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["merge"] is True
    +
    +    def test_coerces_number_arg(self):
    +        schema = self._mock_schema({"temperature": {"type": "number"}})
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"temperature": "0.7"}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["temperature"] == 0.7
    +
    +    def test_leaves_string_args_alone(self):
    +        schema = self._mock_schema({"path": {"type": "string"}})
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"path": "/tmp/file.txt"}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["path"] == "/tmp/file.txt"
    +
    +    def test_leaves_already_correct_types(self):
    +        schema = self._mock_schema({"limit": {"type": "integer"}})
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"limit": 10}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["limit"] == 10
    +
    +    def test_unknown_tool_returns_args_unchanged(self):
    +        with patch("model_tools.registry.get_schema", return_value=None):
    +            args = {"limit": "10"}
    +            result = coerce_tool_args("unknown_tool", args)
    +            assert result["limit"] == "10"
    +
    +    def test_empty_args(self):
    +        assert coerce_tool_args("test_tool", {}) == {}
    +
    +    def test_none_args(self):
    +        assert coerce_tool_args("test_tool", None) is None
    +
    +    def test_preserves_non_string_values(self):
    +        """Lists, dicts, and other non-string values are never touched."""
    +        schema = self._mock_schema({
    +            "items": {"type": "array"},
    +            "config": {"type": "object"},
    +        })
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"items": [1, 2, 3], "config": {"key": "val"}}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["items"] == [1, 2, 3]
    +            assert result["config"] == {"key": "val"}
    +
    +    def test_extra_args_without_schema_left_alone(self):
    +        """Args not in the schema properties are not touched."""
    +        schema = self._mock_schema({"limit": {"type": "integer"}})
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"limit": "10", "extra": "42"}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["limit"] == 10
    +            assert result["extra"] == "42"  # no schema for extra, stays string
    +
    +    def test_mixed_coercion(self):
    +        """Multiple args coerced in the same call."""
    +        schema = self._mock_schema({
    +            "offset": {"type": "integer"},
    +            "limit": {"type": "integer"},
    +            "full": {"type": "boolean"},
    +            "path": {"type": "string"},
    +        })
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {
    +                "offset": "1",
    +                "limit": "500",
    +                "full": "false",
    +                "path": "readme.md",
    +            }
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["offset"] == 1
    +            assert result["limit"] == 500
    +            assert result["full"] is False
    +            assert result["path"] == "readme.md"
    +
    +    def test_failed_coercion_preserves_original(self):
    +        """A non-parseable string stays as string even if schema says integer."""
    +        schema = self._mock_schema({"limit": {"type": "integer"}})
    +        with patch("model_tools.registry.get_schema", return_value=schema):
    +            args = {"limit": "not_a_number"}
    +            result = coerce_tool_args("test_tool", args)
    +            assert result["limit"] == "not_a_number"
    +
    +    def test_real_read_file_schema(self):
    +        """Test against the actual read_file schema from the registry."""
    +        # This uses the real registry — read_file should be registered
    +        args = {"path": "foo.py", "offset": "10", "limit": "100"}
    +        result = coerce_tool_args("read_file", args)
    +        assert result["path"] == "foo.py"
    +        assert result["offset"] == 10
    +        assert isinstance(result["offset"], int)
    +        assert result["limit"] == 100
    +        assert isinstance(result["limit"], int)
    ```
- **#45822: fix(agent): treat Codex reasoning items as thinking-only**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/agent_runtime_helpers.py`, `agent/conversation_loop.py`, `run_agent.py`, `tests/run_agent/test_thinking_only_sanitizer.py`
- **#2129: fix(agent): strip trailing empty assistant messages before API calls to prevent prefill rejection**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#2466`
- **#29225: fix(agent): treat Codex codex_reasoning_items as thinking-only on empty turns (#29205)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `1d584a301`, `#45822`
- **#36787: fix(agent): drop codex reasoning-only turns before anthropic fallback**. closed without merge
  - maintainer direction: category `volume_quota`; implication `active`

## Fix status

- Merged to main in PR #2466 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-13`.
- Subsystem: `agent`
