Codex reasoning-only turns causing empty assistant prefill issues

NousResearch/hermes-agent · cluster 2128 · agent

Data as of

Nothing open on GitHub. Fix merged in #2466. Check it covers your case.

The short answer

0open issues
0open PRs
8items grouped
Known?
Yes. 8 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #2466. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #5278, #45822. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #2466 fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill

    merged to main

    files: gateway/platforms/email.py, hermes_state.py, run_agent.py, tools/voice_mode.py

    Show the diff
    • gateway/platforms/email.py
      @@ -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()
      @@ -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
      @@ -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
      @@ -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
      @@ -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",)
      @@ -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):
      @@ -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):
      @@ -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
      @@ -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

    files: model_tools.py, run_agent.py, tests/test_run_agent.py, tests/test_tool_arg_coercion.py

    Show the diff
    • model_tools.py
      @@ -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],
      @@ -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
      @@ -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
      @@ -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"),
      @@ -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
      @@ -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"),
      @@ -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
      @@ -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

    files: agent/agent_runtime_helpers.py, agent/conversation_loop.py, run_agent.py, tests/run_agent/test_thinking_only_sanitizer.py

3 PRs closed without merging

From the close comments: merged via another change 2 · PR volume cleanup 1

  • #2129 fix(agent): strip trailing empty assistant messages before API calls to prevent prefill rejection · merged via another change → #2466
  • #29225 fix(agent): treat Codex codex_reasoning_items as thinking-only on empty turns (#29205) · merged via another change → 1d584a3, #45822
  • #36787 fix(agent): drop codex reasoning-only turns before anthropic fallback · PR volume cleanup
Closed issues · 2
  • Closed#2128Bug: Empty assistant message from reasoning-only fix leaks into Chat Completions API, causing prefill rejection
  • Closed#29205Anthropic fallback can fail after Codex reasoning-only empty turns due to trailing assistant prefill
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#2466, #5278, #45822
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
13 Jun 2026

For your agent

The same facts, typed.