Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions agent/codex_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,36 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
len(agent._codex_streamed_text_parts), len(assembled),
)
return final_response
except TypeError as exc:
# Some Responses-compatible backends (notably chatgpt.com/backend-api/codex
# through OpenAI SDK >= 2.24) can emit a terminal response.completed
# frame whose response.output is null while the useful message has
# already arrived via response.output_item.done / output_text deltas.
# The SDK raises while parsing that terminal frame, before
# get_final_response() can run. Recover from the collected stream
# evidence instead of routing a successful turn to fallback.
if "NoneType" in str(exc) and "iterable" in str(exc):
if collected_output_items:
return SimpleNamespace(
output=list(collected_output_items),
output_text="".join(agent._codex_streamed_text_parts),
status="completed",
model=api_kwargs.get("model"),
)
if agent._codex_streamed_text_parts and not has_tool_calls:
assembled = "".join(agent._codex_streamed_text_parts)
return SimpleNamespace(
output=[SimpleNamespace(
type="message",
role="assistant",
status="completed",
content=[SimpleNamespace(type="output_text", text=assembled)],
)],
output_text=assembled,
status="completed",
model=api_kwargs.get("model"),
)
raise
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
if attempt < max_stream_retries:
logger.debug(
Expand Down
41 changes: 39 additions & 2 deletions tests/run_agent/test_run_agent_codex_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ def _codex_ack_message_response(text: str):


class _FakeResponsesStream:
def __init__(self, *, final_response=None, final_error=None):
def __init__(self, *, final_response=None, final_error=None, events=None):
self._final_response = final_response
self._final_error = final_error
self._events = list(events or [])

def __enter__(self):
return self
Expand All @@ -166,7 +167,7 @@ def __exit__(self, exc_type, exc, tb):
return False

def __iter__(self):
return iter(())
return iter(self._events)

def get_final_response(self):
if self._final_error is not None:
Expand Down Expand Up @@ -484,6 +485,42 @@ def _fake_create(**kwargs):
assert response.output[0].content[0].text == "streamed create ok"


def test_run_codex_stream_recovers_when_sdk_parse_response_sees_null_output(monkeypatch):
"""Regression: chatgpt.com Codex may stream a valid output_item.done,
then OpenAI SDK parsing raises TypeError when terminal response.output is null.
Hermes should return the collected item instead of treating the turn as failed.
"""
agent = _build_agent(monkeypatch)
item = SimpleNamespace(
type="message",
role="assistant",
status="completed",
content=[SimpleNamespace(type="output_text", text="sdk recovered")],
)
events = [
SimpleNamespace(type="response.output_text.delta", delta="sdk recovered"),
SimpleNamespace(type="response.output_item.done", item=item),
]

def _fake_stream(**kwargs):
return _FakeResponsesStream(
events=events,
final_error=TypeError("'NoneType' object is not iterable"),
)

agent.client = SimpleNamespace(
responses=SimpleNamespace(
stream=_fake_stream,
create=lambda **kwargs: _codex_message_response("fallback should not run"),
)
)

response = agent._run_codex_stream(_codex_request_kwargs())
assert response is not None
assert response.output[0].content[0].text == "sdk recovered"
assert response.output_text == "sdk recovered"


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"))
Expand Down