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
- 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
Open PRs0
Fixes on record
-
#2466 fix: batch of 5 small contributor fixes — PortAudio, SafeWriter, IMAP, thread lock, prefill
files:
gateway/platforms/email.py,hermes_state.py,run_agent.py,tools/voice_mode.pyShow 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)"
files:
model_tools.py,run_agent.py,tests/test_run_agent.py,tests/test_tool_arg_coercion.pyShow 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
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
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.00means 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