hermes - 💡(How to fix) Fix Fix Codex stream None output recovery

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

I hit a reproducible Hermes crash while using the OpenAI Codex Responses backend:

TypeError: 'NoneType' object is not iterable

Root cause: the OpenAI SDK Responses stream parser can receive a terminal response with response.output = None after reasoning/text deltas have already streamed. The SDK then crashes while iterating response.output, and Hermes treats it as a non-retryable client error.

This patch makes Hermes resilient to that stream shape by:

  • backfilling missing or empty Responses output from streamed output items, text deltas, or reasoning deltas
  • falling back to responses.create(stream=True) when the SDK stream parser crashes on missing output
  • guarding response.output_text access, since the SDK convenience accessor can also raise on partial/malformed output
  • treating reasoning-only responses as incomplete so the normal continuation path can produce the final answer
  • adding regression coverage for the SDK parser failure and broken output_text accessor

Error Message

TypeError: 'NoneType' object is not iterable

Root Cause

Root cause: the OpenAI SDK Responses stream parser can receive a terminal response with response.output = None after reasoning/text deltas have already streamed. The SDK then crashes while iterating response.output, and Hermes treats it as a non-retryable client error.

Fix Action

Fix / Workaround

This patch makes Hermes resilient to that stream shape by:

I do not currently have write access to NousResearch/hermes-agent from this environment, and no GitHub fork was available to push a branch from Billythek, so I could not open a PR directly. The patch below was generated from a clean branch based on the current official main.

<details> <summary>Patch</summary>

Code Example

TypeError: 'NoneType' object is not iterable

---

venv/bin/python -m pytest tests/agent/transports/test_codex_transport.py tests/run_agent/test_run_agent_codex_responses.py -q -o addopts=
112 passed, 1 warning in 14.15s

---

From 25bf31ad97bdb4a6c8e383c9a44fb21d030a7793 Mon Sep 17 00:00:00 2001
From: Billy Thakid <billy@srv1182948>
Date: Wed, 27 May 2026 01:54:43 +0200
Subject: [PATCH] Fix Codex stream None output recovery


diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py
index 07ae5cc95..89ee0dbfa 100644
--- a/agent/codex_responses_adapter.py
+++ b/agent/codex_responses_adapter.py
@@ -872,6 +872,16 @@ def _extract_responses_reasoning_text(item: Any) -> str:
     return ""
 
 
+def _safe_response_output_text(response: Any) -> str:
+    """Read ``response.output_text`` without letting SDK convenience accessors crash."""
+    try:
+        out_text = getattr(response, "output_text", None)
+    except Exception as exc:
+        logger.debug("Codex response.output_text access failed: %s", exc)
+        return ""
+    return out_text.strip() if isinstance(out_text, str) else ""
+
+
 # ---------------------------------------------------------------------------
 # Full response normalization
 # ---------------------------------------------------------------------------
@@ -883,15 +893,15 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]:
         # The Codex backend can return empty output when the answer was
         # delivered entirely via stream events. Check output_text as a
         # last-resort fallback before raising.
-        out_text = getattr(response, "output_text", None)
-        if isinstance(out_text, str) and out_text.strip():
+        out_text = _safe_response_output_text(response)
+        if out_text:
             logger.debug(
                 "Codex response has empty output but output_text is present (%d chars); "
-                "synthesizing output item.", len(out_text.strip()),
+                "synthesizing output item.", len(out_text),
             )
             output = [SimpleNamespace(
                 type="message", role="assistant", status="completed",
-                content=[SimpleNamespace(type="output_text", text=out_text.strip())],
+                content=[SimpleNamespace(type="output_text", text=out_text)],
             )]
             response.output = output
         else:
@@ -1024,10 +1034,8 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]:
             ))
 
     final_text = "\n".join([p for p in content_parts if p]).strip()
-    if not final_text and hasattr(response, "output_text"):
-        out_text = getattr(response, "output_text", "")
-        if isinstance(out_text, str):
-            final_text = out_text.strip()
+    if not final_text:
+        final_text = _safe_response_output_text(response)
 
     # ── Tool-call leak recovery ──────────────────────────────────
     # gpt-5.x on the Codex Responses API sometimes degenerates and emits
@@ -1076,7 +1084,7 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]:
         finish_reason = "incomplete"
     elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
         finish_reason = "incomplete"
-    elif reasoning_items_raw and not final_text:
+    elif (reasoning_items_raw or reasoning_parts) and not final_text:
         # Response contains only reasoning (encrypted thinking state) with
         # no visible content or tool calls.  The model is still thinking and
         # needs another turn to produce the actual answer.  Marking this as
diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py
index 8c5dff39b..2c3d131d7 100644
--- a/agent/codex_runtime.py
+++ b/agent/codex_runtime.py
@@ -176,6 +176,91 @@ def run_codex_app_server_turn(
 
 
 
+def _backfill_codex_response_output(
+    response: Any,
+    *,
+    collected_output_items: list,
+    text_deltas: list,
+    reasoning_deltas: list,
+    has_tool_calls: bool,
+    log_label: str,
+) -> bool:
+    """Patch missing/empty Responses output from stream events already seen."""
+    _out = getattr(response, "output", None)
+    if isinstance(_out, list) and _out:
+        return False
+
+    if collected_output_items:
+        response.output = list(collected_output_items)
+        logger.debug(
+            "%s: backfilled %d output items from stream events",
+            log_label,
+            len(collected_output_items),
+        )
+        return True
+
+    if text_deltas and not has_tool_calls:
+        assembled = "".join(text_deltas)
+        response.output = [SimpleNamespace(
+            type="message",
+            role="assistant",
+            status="completed",
+            content=[SimpleNamespace(type="output_text", text=assembled)],
+        )]
+        logger.debug(
+            "%s: synthesized output from %d text deltas (%d chars)",
+            log_label,
+            len(text_deltas),
+            len(assembled),
+        )
+        return True
+
+    reasoning_text = "".join(reasoning_deltas).strip()
+    if reasoning_text:
+        response.output = [SimpleNamespace(
+            type="reasoning",
+            status="completed",
+            summary=[SimpleNamespace(type="summary_text", text=reasoning_text)],
+        )]
+        if not getattr(response, "status", None):
+            response.status = "incomplete"
+        logger.debug(
+            "%s: synthesized reasoning-only output from %d deltas (%d chars)",
+            log_label,
+            len(reasoning_deltas),
+            len(reasoning_text),
+        )
+        return True
+
+    return False
+
+
+def _synthesize_codex_response_from_stream(
+    *,
+    collected_output_items: list,
+    text_deltas: list,
+    reasoning_deltas: list,
+    has_tool_calls: bool,
+    reason: str,
+) -> Any | None:
+    response = SimpleNamespace(
+        output=[],
+        status="incomplete",
+        incomplete_details=SimpleNamespace(reason=reason),
+        error=None,
+    )
+    if _backfill_codex_response_output(
+        response,
+        collected_output_items=collected_output_items,
+        text_deltas=text_deltas,
+        reasoning_deltas=reasoning_deltas,
+        has_tool_calls=has_tool_calls,
+        log_label="Codex stream recovery",
+    ):
+        return response
+    return None
+
+
 def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta: callable = None):
     """Execute one streaming Responses API request and return the final response."""
     import httpx as _httpx
@@ -192,6 +277,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
         if agent._interrupt_requested:
             raise InterruptedError("Agent interrupted before Codex stream retry")
         collected_output_items: list = []
+        collected_reasoning_deltas: list = []
         try:
             with active_client.responses.stream(**api_kwargs) as stream:
                 for event in stream:
@@ -225,6 +311,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
                     elif "reasoning" in event_type and "delta" in event_type:
                         reasoning_text = getattr(event, "delta", "")
                         if reasoning_text:
+                            collected_reasoning_deltas.append(reasoning_text)
                             agent._fire_reasoning_delta(reasoning_text)
                     # Collect completed output items — some backends
                     # (chatgpt.com/backend-api/codex) stream valid items
@@ -248,28 +335,16 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
                         )
                 final_response = stream.get_final_response()
                 # PATCH: ChatGPT Codex backend streams valid output items
-                # but get_final_response() can return an empty output list.
+                # but get_final_response() can return missing/empty output.
                 # Backfill from collected items or synthesize from deltas.
-                _out = getattr(final_response, "output", None)
-                if isinstance(_out, list) and not _out:
-                    if collected_output_items:
-                        final_response.output = list(collected_output_items)
-                        logger.debug(
-                            "Codex stream: backfilled %d output items from stream events",
-                            len(collected_output_items),
-                        )
-                    elif agent._codex_streamed_text_parts and not has_tool_calls:
-                        assembled = "".join(agent._codex_streamed_text_parts)
-                        final_response.output = [SimpleNamespace(
-                            type="message",
-                            role="assistant",
-                            status="completed",
-                            content=[SimpleNamespace(type="output_text", text=assembled)],
-                        )]
-                        logger.debug(
-                            "Codex stream: synthesized output from %d text deltas (%d chars)",
-                            len(agent._codex_streamed_text_parts), len(assembled),
-                        )
+                _backfill_codex_response_output(
+                    final_response,
+                    collected_output_items=collected_output_items,
+                    text_deltas=agent._codex_streamed_text_parts,
+                    reasoning_deltas=collected_reasoning_deltas,
+                    has_tool_calls=has_tool_calls,
+                    log_label="Codex stream",
+                )
                 return final_response
         except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
             if attempt < max_stream_retries:
@@ -335,6 +410,35 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
                 )
                 return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)
             raise
+        except TypeError as exc:
+            err_text = str(exc)
+            sdk_none_output = "'NoneType' object is not iterable" in err_text
+            if not sdk_none_output:
+                raise
+            logger.warning(
+                "Codex Responses stream parser failed on missing output; "
+                "falling back to create(stream=True). %s err=%s",
+                agent._client_log_context(),
+                err_text,
+            )
+            try:
+                return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)
+            except Exception:
+                recovered = _synthesize_codex_response_from_stream(
+                    collected_output_items=collected_output_items,
+                    text_deltas=agent._codex_streamed_text_parts,
+                    reasoning_deltas=collected_reasoning_deltas,
+                    has_tool_calls=has_tool_calls,
+                    reason="stream_parser_recovered_none_output",
+                )
+                if recovered is not None:
+                    logger.warning(
+                        "Codex Responses stream parser failed and fallback failed; "
+                        "returning synthesized incomplete response. %s",
+                        agent._client_log_context(),
+                    )
+                    return recovered
+                raise
 
 
 
@@ -355,6 +459,8 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
     terminal_response = None
     collected_output_items: list = []
     collected_text_deltas: list = []
+    collected_reasoning_deltas: list = []
+    has_tool_calls = False
     try:
         for event in stream_or_response:
             agent._touch_activity("receiving stream response")
@@ -404,6 +510,14 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
                     delta = event.get("delta", "")
                 if delta:
                     collected_text_deltas.append(delta)
+            elif event_type and "function_call" in event_type:
+                has_tool_calls = True
+            elif event_type and "reasoning" in event_type and "delta" in event_type:
+                delta = getattr(event, "delta", "")
+                if not delta and isinstance(event, dict):
+                    delta = event.get("delta", "")
+                if delta:
+                    collected_reasoning_deltas.append(delta)
 
             if event_type not in {"response.completed", "response.incomplete", "response.failed"}:
                 continue
@@ -413,25 +527,14 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
                 terminal_response = event.get("response")
             if terminal_response is not None:
                 # Backfill empty output from collected stream events
-                _out = getattr(terminal_response, "output", None)
-                if isinstance(_out, list) and not _out:
-                    if collected_output_items:
-                        terminal_response.output = list(collected_output_items)
-                        logger.debug(
-                            "Codex fallback stream: backfilled %d output items",
-                            len(collected_output_items),
-                        )
-                    elif collected_text_deltas:
-                        assembled = "".join(collected_text_deltas)
-                        terminal_response.output = [SimpleNamespace(
-                            type="message", role="assistant",
-                            status="completed",
-                            content=[SimpleNamespace(type="output_text", text=assembled)],
-                        )]
-                        logger.debug(
-                            "Codex fallback stream: synthesized from %d deltas (%d chars)",
-                            len(collected_text_deltas), len(assembled),
-                        )
+                _backfill_codex_response_output(
+                    terminal_response,
+                    collected_output_items=collected_output_items,
+                    text_deltas=collected_text_deltas,
+                    reasoning_deltas=collected_reasoning_deltas,
+                    has_tool_calls=has_tool_calls,
+                    log_label="Codex fallback stream",
+                )
                 return terminal_response
     finally:
         close_fn = getattr(stream_or_response, "close", None)
@@ -443,6 +546,15 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
 
     if terminal_response is not None:
         return terminal_response
+    recovered = _synthesize_codex_response_from_stream(
+        collected_output_items=collected_output_items,
+        text_deltas=collected_text_deltas,
+        reasoning_deltas=collected_reasoning_deltas,
+        has_tool_calls=has_tool_calls,
+        reason="stream_ended_without_terminal_response",
+    )
+    if recovered is not None:
+        return recovered
     raise RuntimeError("Responses create(stream=True) fallback did not emit a terminal response.")
 
 
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index 35a64df48..dc860898a 100644
--- a/agent/conversation_loop.py
+++ b/agent/conversation_loop.py
@@ -1224,7 +1224,14 @@ def run_conversation(
                             else:
                                 # output_text fallback: stream backfill may have failed
                                 # but normalize can still recover from output_text
-                                _out_text = getattr(response, "output_text", None)
+                                try:
+                                    _out_text = getattr(response, "output_text", None)
+                                except Exception as exc:
+                                    logger.debug(
+                                        "Codex response.output_text access failed during validation: %s",
+                                        exc,
+                                    )
+                                    _out_text = None
                                 _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else ""
                                 if _out_text_stripped:
                                     logger.debug(
diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py
index 96a808272..b8a5ae5e5 100644
--- a/tests/agent/transports/test_codex_transport.py
+++ b/tests/agent/transports/test_codex_transport.py
@@ -453,6 +453,34 @@ class TestCodexNormalizeResponse:
         assert tc.name == "terminal"
         assert '"command"' in tc.arguments
 
+    def test_reasoning_only_response_with_broken_output_text_is_incomplete(self, transport):
+        """SDK output_text access can fail when output contains no final message."""
+
+        class BrokenOutputTextResponse(SimpleNamespace):
+            @property
+            def output_text(self):
+                raise TypeError("'NoneType' object is not iterable")
+
+        r = BrokenOutputTextResponse(
+            output=[
+                SimpleNamespace(
+                    type="reasoning",
+                    summary=[SimpleNamespace(type="summary_text", text="Still thinking")],
+                    status="completed",
+                ),
+            ],
+            status="completed",
+            incomplete_details=None,
+            usage=SimpleNamespace(input_tokens=10, output_tokens=5,
+                                  input_tokens_details=None, output_tokens_details=None),
+        )
+
+        nr = transport.normalize_response(r)
+
+        assert nr.content == ""
+        assert nr.reasoning == "Still thinking"
+        assert nr.finish_reason == "incomplete"
+
 
 
 class TestCodexTransportTimeout:
diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py
index bc575cc67..df6ea0d60 100644
--- a/tests/run_agent/test_run_agent_codex_responses.py
+++ b/tests/run_agent/test_run_agent_codex_responses.py
@@ -484,6 +484,58 @@ def test_run_codex_stream_fallback_parses_create_stream_events(monkeypatch):
     assert response.output[0].content[0].text == "streamed create ok"
 
 
+def test_run_codex_stream_parser_none_output_falls_back_to_create_stream(monkeypatch):
+    agent = _build_agent(monkeypatch)
+    calls = {"stream": 0, "create": 0}
+
+    class _BrokenParseStream:
+        def __enter__(self):
+            return self
+
+        def __exit__(self, exc_type, exc, tb):
+            return False
+
+        def __iter__(self):
+            yield SimpleNamespace(type="response.reasoning.delta", delta="Primary reasoning")
+            raise TypeError("'NoneType' object is not iterable")
+
+        def get_final_response(self):
+            raise AssertionError("stream parser failure should happen during iteration")
+
+    create_stream = _FakeCreateStream(
+        [
+            SimpleNamespace(type="response.created"),
+            SimpleNamespace(type="response.reasoning.delta", delta="Fallback reasoning"),
+            SimpleNamespace(
+                type="response.completed",
+                response=SimpleNamespace(output=None, status="completed"),
+            ),
+        ]
+    )
+
+    def _fake_stream(**kwargs):
+        calls["stream"] += 1
+        return _BrokenParseStream()
+
+    def _fake_create(**kwargs):
+        calls["create"] += 1
+        assert kwargs.get("stream") is True
+        return create_stream
+
+    agent.client = SimpleNamespace(
+        responses=SimpleNamespace(
+            stream=_fake_stream,
+            create=_fake_create,
+        )
+    )
+
+    response = agent._run_codex_stream(_codex_request_kwargs())
+
+    assert calls == {"stream": 1, "create": 1}
+    assert response.output[0].type == "reasoning"
+    assert response.output[0].summary[0].text == "Fallback reasoning"
+
+
 def test_run_conversation_codex_plain_text(monkeypatch):
     agent = _build_agent(monkeypatch)
     monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: _codex_message_response("OK"))
-- 
2.43.0
RAW_BUFFERClick to expand / collapse

Summary

I hit a reproducible Hermes crash while using the OpenAI Codex Responses backend:

TypeError: 'NoneType' object is not iterable

Root cause: the OpenAI SDK Responses stream parser can receive a terminal response with response.output = None after reasoning/text deltas have already streamed. The SDK then crashes while iterating response.output, and Hermes treats it as a non-retryable client error.

This patch makes Hermes resilient to that stream shape by:

  • backfilling missing or empty Responses output from streamed output items, text deltas, or reasoning deltas
  • falling back to responses.create(stream=True) when the SDK stream parser crashes on missing output
  • guarding response.output_text access, since the SDK convenience accessor can also raise on partial/malformed output
  • treating reasoning-only responses as incomplete so the normal continuation path can produce the final answer
  • adding regression coverage for the SDK parser failure and broken output_text accessor

Validation

Ran against current upstream/main (bb4703c761ea6687b6399aa2e61e0a08fabd3ca3):

venv/bin/python -m pytest tests/agent/transports/test_codex_transport.py tests/run_agent/test_run_agent_codex_responses.py -q -o addopts=
112 passed, 1 warning in 14.15s

Also verified locally with live Hermes/Codex calls on gpt-5.4, default gpt-5.5, and the billythakid profile after restarting both gateway services.

Note

I do not currently have write access to NousResearch/hermes-agent from this environment, and no GitHub fork was available to push a branch from Billythek, so I could not open a PR directly. The patch below was generated from a clean branch based on the current official main.

<details> <summary>Patch</summary>
From 25bf31ad97bdb4a6c8e383c9a44fb21d030a7793 Mon Sep 17 00:00:00 2001
From: Billy Thakid <billy@srv1182948>
Date: Wed, 27 May 2026 01:54:43 +0200
Subject: [PATCH] Fix Codex stream None output recovery


diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py
index 07ae5cc95..89ee0dbfa 100644
--- a/agent/codex_responses_adapter.py
+++ b/agent/codex_responses_adapter.py
@@ -872,6 +872,16 @@ def _extract_responses_reasoning_text(item: Any) -> str:
     return ""
 
 
+def _safe_response_output_text(response: Any) -> str:
+    """Read ``response.output_text`` without letting SDK convenience accessors crash."""
+    try:
+        out_text = getattr(response, "output_text", None)
+    except Exception as exc:
+        logger.debug("Codex response.output_text access failed: %s", exc)
+        return ""
+    return out_text.strip() if isinstance(out_text, str) else ""
+
+
 # ---------------------------------------------------------------------------
 # Full response normalization
 # ---------------------------------------------------------------------------
@@ -883,15 +893,15 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]:
         # The Codex backend can return empty output when the answer was
         # delivered entirely via stream events. Check output_text as a
         # last-resort fallback before raising.
-        out_text = getattr(response, "output_text", None)
-        if isinstance(out_text, str) and out_text.strip():
+        out_text = _safe_response_output_text(response)
+        if out_text:
             logger.debug(
                 "Codex response has empty output but output_text is present (%d chars); "
-                "synthesizing output item.", len(out_text.strip()),
+                "synthesizing output item.", len(out_text),
             )
             output = [SimpleNamespace(
                 type="message", role="assistant", status="completed",
-                content=[SimpleNamespace(type="output_text", text=out_text.strip())],
+                content=[SimpleNamespace(type="output_text", text=out_text)],
             )]
             response.output = output
         else:
@@ -1024,10 +1034,8 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]:
             ))
 
     final_text = "\n".join([p for p in content_parts if p]).strip()
-    if not final_text and hasattr(response, "output_text"):
-        out_text = getattr(response, "output_text", "")
-        if isinstance(out_text, str):
-            final_text = out_text.strip()
+    if not final_text:
+        final_text = _safe_response_output_text(response)
 
     # ── Tool-call leak recovery ──────────────────────────────────
     # gpt-5.x on the Codex Responses API sometimes degenerates and emits
@@ -1076,7 +1084,7 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]:
         finish_reason = "incomplete"
     elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
         finish_reason = "incomplete"
-    elif reasoning_items_raw and not final_text:
+    elif (reasoning_items_raw or reasoning_parts) and not final_text:
         # Response contains only reasoning (encrypted thinking state) with
         # no visible content or tool calls.  The model is still thinking and
         # needs another turn to produce the actual answer.  Marking this as
diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py
index 8c5dff39b..2c3d131d7 100644
--- a/agent/codex_runtime.py
+++ b/agent/codex_runtime.py
@@ -176,6 +176,91 @@ def run_codex_app_server_turn(
 
 
 
+def _backfill_codex_response_output(
+    response: Any,
+    *,
+    collected_output_items: list,
+    text_deltas: list,
+    reasoning_deltas: list,
+    has_tool_calls: bool,
+    log_label: str,
+) -> bool:
+    """Patch missing/empty Responses output from stream events already seen."""
+    _out = getattr(response, "output", None)
+    if isinstance(_out, list) and _out:
+        return False
+
+    if collected_output_items:
+        response.output = list(collected_output_items)
+        logger.debug(
+            "%s: backfilled %d output items from stream events",
+            log_label,
+            len(collected_output_items),
+        )
+        return True
+
+    if text_deltas and not has_tool_calls:
+        assembled = "".join(text_deltas)
+        response.output = [SimpleNamespace(
+            type="message",
+            role="assistant",
+            status="completed",
+            content=[SimpleNamespace(type="output_text", text=assembled)],
+        )]
+        logger.debug(
+            "%s: synthesized output from %d text deltas (%d chars)",
+            log_label,
+            len(text_deltas),
+            len(assembled),
+        )
+        return True
+
+    reasoning_text = "".join(reasoning_deltas).strip()
+    if reasoning_text:
+        response.output = [SimpleNamespace(
+            type="reasoning",
+            status="completed",
+            summary=[SimpleNamespace(type="summary_text", text=reasoning_text)],
+        )]
+        if not getattr(response, "status", None):
+            response.status = "incomplete"
+        logger.debug(
+            "%s: synthesized reasoning-only output from %d deltas (%d chars)",
+            log_label,
+            len(reasoning_deltas),
+            len(reasoning_text),
+        )
+        return True
+
+    return False
+
+
+def _synthesize_codex_response_from_stream(
+    *,
+    collected_output_items: list,
+    text_deltas: list,
+    reasoning_deltas: list,
+    has_tool_calls: bool,
+    reason: str,
+) -> Any | None:
+    response = SimpleNamespace(
+        output=[],
+        status="incomplete",
+        incomplete_details=SimpleNamespace(reason=reason),
+        error=None,
+    )
+    if _backfill_codex_response_output(
+        response,
+        collected_output_items=collected_output_items,
+        text_deltas=text_deltas,
+        reasoning_deltas=reasoning_deltas,
+        has_tool_calls=has_tool_calls,
+        log_label="Codex stream recovery",
+    ):
+        return response
+    return None
+
+
 def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta: callable = None):
     """Execute one streaming Responses API request and return the final response."""
     import httpx as _httpx
@@ -192,6 +277,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
         if agent._interrupt_requested:
             raise InterruptedError("Agent interrupted before Codex stream retry")
         collected_output_items: list = []
+        collected_reasoning_deltas: list = []
         try:
             with active_client.responses.stream(**api_kwargs) as stream:
                 for event in stream:
@@ -225,6 +311,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
                     elif "reasoning" in event_type and "delta" in event_type:
                         reasoning_text = getattr(event, "delta", "")
                         if reasoning_text:
+                            collected_reasoning_deltas.append(reasoning_text)
                             agent._fire_reasoning_delta(reasoning_text)
                     # Collect completed output items — some backends
                     # (chatgpt.com/backend-api/codex) stream valid items
@@ -248,28 +335,16 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
                         )
                 final_response = stream.get_final_response()
                 # PATCH: ChatGPT Codex backend streams valid output items
-                # but get_final_response() can return an empty output list.
+                # but get_final_response() can return missing/empty output.
                 # Backfill from collected items or synthesize from deltas.
-                _out = getattr(final_response, "output", None)
-                if isinstance(_out, list) and not _out:
-                    if collected_output_items:
-                        final_response.output = list(collected_output_items)
-                        logger.debug(
-                            "Codex stream: backfilled %d output items from stream events",
-                            len(collected_output_items),
-                        )
-                    elif agent._codex_streamed_text_parts and not has_tool_calls:
-                        assembled = "".join(agent._codex_streamed_text_parts)
-                        final_response.output = [SimpleNamespace(
-                            type="message",
-                            role="assistant",
-                            status="completed",
-                            content=[SimpleNamespace(type="output_text", text=assembled)],
-                        )]
-                        logger.debug(
-                            "Codex stream: synthesized output from %d text deltas (%d chars)",
-                            len(agent._codex_streamed_text_parts), len(assembled),
-                        )
+                _backfill_codex_response_output(
+                    final_response,
+                    collected_output_items=collected_output_items,
+                    text_deltas=agent._codex_streamed_text_parts,
+                    reasoning_deltas=collected_reasoning_deltas,
+                    has_tool_calls=has_tool_calls,
+                    log_label="Codex stream",
+                )
                 return final_response
         except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
             if attempt < max_stream_retries:
@@ -335,6 +410,35 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
                 )
                 return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)
             raise
+        except TypeError as exc:
+            err_text = str(exc)
+            sdk_none_output = "'NoneType' object is not iterable" in err_text
+            if not sdk_none_output:
+                raise
+            logger.warning(
+                "Codex Responses stream parser failed on missing output; "
+                "falling back to create(stream=True). %s err=%s",
+                agent._client_log_context(),
+                err_text,
+            )
+            try:
+                return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)
+            except Exception:
+                recovered = _synthesize_codex_response_from_stream(
+                    collected_output_items=collected_output_items,
+                    text_deltas=agent._codex_streamed_text_parts,
+                    reasoning_deltas=collected_reasoning_deltas,
+                    has_tool_calls=has_tool_calls,
+                    reason="stream_parser_recovered_none_output",
+                )
+                if recovered is not None:
+                    logger.warning(
+                        "Codex Responses stream parser failed and fallback failed; "
+                        "returning synthesized incomplete response. %s",
+                        agent._client_log_context(),
+                    )
+                    return recovered
+                raise
 
 
 
@@ -355,6 +459,8 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
     terminal_response = None
     collected_output_items: list = []
     collected_text_deltas: list = []
+    collected_reasoning_deltas: list = []
+    has_tool_calls = False
     try:
         for event in stream_or_response:
             agent._touch_activity("receiving stream response")
@@ -404,6 +510,14 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
                     delta = event.get("delta", "")
                 if delta:
                     collected_text_deltas.append(delta)
+            elif event_type and "function_call" in event_type:
+                has_tool_calls = True
+            elif event_type and "reasoning" in event_type and "delta" in event_type:
+                delta = getattr(event, "delta", "")
+                if not delta and isinstance(event, dict):
+                    delta = event.get("delta", "")
+                if delta:
+                    collected_reasoning_deltas.append(delta)
 
             if event_type not in {"response.completed", "response.incomplete", "response.failed"}:
                 continue
@@ -413,25 +527,14 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
                 terminal_response = event.get("response")
             if terminal_response is not None:
                 # Backfill empty output from collected stream events
-                _out = getattr(terminal_response, "output", None)
-                if isinstance(_out, list) and not _out:
-                    if collected_output_items:
-                        terminal_response.output = list(collected_output_items)
-                        logger.debug(
-                            "Codex fallback stream: backfilled %d output items",
-                            len(collected_output_items),
-                        )
-                    elif collected_text_deltas:
-                        assembled = "".join(collected_text_deltas)
-                        terminal_response.output = [SimpleNamespace(
-                            type="message", role="assistant",
-                            status="completed",
-                            content=[SimpleNamespace(type="output_text", text=assembled)],
-                        )]
-                        logger.debug(
-                            "Codex fallback stream: synthesized from %d deltas (%d chars)",
-                            len(collected_text_deltas), len(assembled),
-                        )
+                _backfill_codex_response_output(
+                    terminal_response,
+                    collected_output_items=collected_output_items,
+                    text_deltas=collected_text_deltas,
+                    reasoning_deltas=collected_reasoning_deltas,
+                    has_tool_calls=has_tool_calls,
+                    log_label="Codex fallback stream",
+                )
                 return terminal_response
     finally:
         close_fn = getattr(stream_or_response, "close", None)
@@ -443,6 +546,15 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
 
     if terminal_response is not None:
         return terminal_response
+    recovered = _synthesize_codex_response_from_stream(
+        collected_output_items=collected_output_items,
+        text_deltas=collected_text_deltas,
+        reasoning_deltas=collected_reasoning_deltas,
+        has_tool_calls=has_tool_calls,
+        reason="stream_ended_without_terminal_response",
+    )
+    if recovered is not None:
+        return recovered
     raise RuntimeError("Responses create(stream=True) fallback did not emit a terminal response.")
 
 
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index 35a64df48..dc860898a 100644
--- a/agent/conversation_loop.py
+++ b/agent/conversation_loop.py
@@ -1224,7 +1224,14 @@ def run_conversation(
                             else:
                                 # output_text fallback: stream backfill may have failed
                                 # but normalize can still recover from output_text
-                                _out_text = getattr(response, "output_text", None)
+                                try:
+                                    _out_text = getattr(response, "output_text", None)
+                                except Exception as exc:
+                                    logger.debug(
+                                        "Codex response.output_text access failed during validation: %s",
+                                        exc,
+                                    )
+                                    _out_text = None
                                 _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else ""
                                 if _out_text_stripped:
                                     logger.debug(
diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py
index 96a808272..b8a5ae5e5 100644
--- a/tests/agent/transports/test_codex_transport.py
+++ b/tests/agent/transports/test_codex_transport.py
@@ -453,6 +453,34 @@ class TestCodexNormalizeResponse:
         assert tc.name == "terminal"
         assert '"command"' in tc.arguments
 
+    def test_reasoning_only_response_with_broken_output_text_is_incomplete(self, transport):
+        """SDK output_text access can fail when output contains no final message."""
+
+        class BrokenOutputTextResponse(SimpleNamespace):
+            @property
+            def output_text(self):
+                raise TypeError("'NoneType' object is not iterable")
+
+        r = BrokenOutputTextResponse(
+            output=[
+                SimpleNamespace(
+                    type="reasoning",
+                    summary=[SimpleNamespace(type="summary_text", text="Still thinking")],
+                    status="completed",
+                ),
+            ],
+            status="completed",
+            incomplete_details=None,
+            usage=SimpleNamespace(input_tokens=10, output_tokens=5,
+                                  input_tokens_details=None, output_tokens_details=None),
+        )
+
+        nr = transport.normalize_response(r)
+
+        assert nr.content == ""
+        assert nr.reasoning == "Still thinking"
+        assert nr.finish_reason == "incomplete"
+
 
 
 class TestCodexTransportTimeout:
diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py
index bc575cc67..df6ea0d60 100644
--- a/tests/run_agent/test_run_agent_codex_responses.py
+++ b/tests/run_agent/test_run_agent_codex_responses.py
@@ -484,6 +484,58 @@ def test_run_codex_stream_fallback_parses_create_stream_events(monkeypatch):
     assert response.output[0].content[0].text == "streamed create ok"
 
 
+def test_run_codex_stream_parser_none_output_falls_back_to_create_stream(monkeypatch):
+    agent = _build_agent(monkeypatch)
+    calls = {"stream": 0, "create": 0}
+
+    class _BrokenParseStream:
+        def __enter__(self):
+            return self
+
+        def __exit__(self, exc_type, exc, tb):
+            return False
+
+        def __iter__(self):
+            yield SimpleNamespace(type="response.reasoning.delta", delta="Primary reasoning")
+            raise TypeError("'NoneType' object is not iterable")
+
+        def get_final_response(self):
+            raise AssertionError("stream parser failure should happen during iteration")
+
+    create_stream = _FakeCreateStream(
+        [
+            SimpleNamespace(type="response.created"),
+            SimpleNamespace(type="response.reasoning.delta", delta="Fallback reasoning"),
+            SimpleNamespace(
+                type="response.completed",
+                response=SimpleNamespace(output=None, status="completed"),
+            ),
+        ]
+    )
+
+    def _fake_stream(**kwargs):
+        calls["stream"] += 1
+        return _BrokenParseStream()
+
+    def _fake_create(**kwargs):
+        calls["create"] += 1
+        assert kwargs.get("stream") is True
+        return create_stream
+
+    agent.client = SimpleNamespace(
+        responses=SimpleNamespace(
+            stream=_fake_stream,
+            create=_fake_create,
+        )
+    )
+
+    response = agent._run_codex_stream(_codex_request_kwargs())
+
+    assert calls == {"stream": 1, "create": 1}
+    assert response.output[0].type == "reasoning"
+    assert response.output[0].summary[0].text == "Fallback reasoning"
+
+
 def test_run_conversation_codex_plain_text(monkeypatch):
     agent = _build_agent(monkeypatch)
     monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: _codex_message_response("OK"))
-- 
2.43.0
</details>

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING