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
68 changes: 59 additions & 9 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,12 @@ def create(self, **kwargs) -> Any:
timed_out = threading.Event()
timeout_timer: Optional[threading.Timer] = None

def _item_get(obj: Any, key: str, default: Any = None) -> Any:
val = getattr(obj, key, None)
if val is None and isinstance(obj, dict):
val = obj.get(key, default)
return val if val is not None else default

def _timeout_message() -> str:
return f"Codex auxiliary Responses stream exceeded {float(total_timeout):.1f}s total timeout"

Expand Down Expand Up @@ -813,9 +819,9 @@ def _check_cancelled() -> None:
_check_cancelled()
final = stream.get_final_response()

# Backfill empty output from collected stream events
# Backfill missing/empty output from collected stream events
_output = getattr(final, "output", None)
if isinstance(_output, list) and not _output:
if _output is None or (isinstance(_output, list) and not _output):
if collected_output_items:
final.output = list(collected_output_items)
logger.debug(
Expand All @@ -839,13 +845,7 @@ def _check_cancelled() -> None:
# Extract text and tool calls from the Responses output.
# Items may be SDK objects (attrs) or dicts (raw/fallback paths),
# so use a helper that handles both shapes.
def _item_get(obj: Any, key: str, default: Any = None) -> Any:
val = getattr(obj, key, None)
if val is None and isinstance(obj, dict):
val = obj.get(key, default)
return val if val is not None else default

for item in getattr(final, "output", []):
for item in (getattr(final, "output", None) or []):
item_type = _item_get(item, "type")
if item_type == "message":
for part in (_item_get(item, "content") or []):
Expand All @@ -869,6 +869,56 @@ def _item_get(obj: Any, key: str, default: Any = None) -> Any:
completion_tokens=getattr(resp_usage, "output_tokens", 0),
total_tokens=getattr(resp_usage, "total_tokens", 0),
)
except TypeError as exc:
err_text = str(exc)
if "NoneType" in err_text and "iterable" in err_text:
# OpenAI SDK 2.24.0 tries to iterate response.output while
# parsing response.completed. The Codex backend can send
# output=null there, after it already streamed the useful
# deltas/items. Recover from those already-collected events.
recovered_output = None
if collected_output_items:
recovered_output = list(collected_output_items)
elif collected_text_deltas and not has_function_calls:
recovered_output = [SimpleNamespace(
type="message", role="assistant", status="completed",
content=[SimpleNamespace(
type="output_text",
text="".join(collected_text_deltas),
)],
)]
if recovered_output is not None:
final = SimpleNamespace(
status="completed",
output=recovered_output,
usage=None,
)
logger.info(
"Codex auxiliary stream terminal event had null output; "
"recovered from streamed events (%d items, %d chars).",
len(collected_output_items),
sum(len(p) for p in collected_text_deltas),
)
for item in (getattr(final, "output", None) or []):
item_type = _item_get(item, "type")
if item_type == "message":
for part in (_item_get(item, "content") or []):
ptype = _item_get(part, "type")
if ptype in {"output_text", "text"}:
text_parts.append(_item_get(part, "text", ""))
elif item_type == "function_call":
tool_calls_raw.append(SimpleNamespace(
id=_item_get(item, "call_id", ""),
type="function",
function=SimpleNamespace(
name=_item_get(item, "name", ""),
arguments=_item_get(item, "arguments", "{}"),
),
))
else:
raise
else:
raise
except Exception as exc:
if timed_out.is_set():
raise TimeoutError(_timeout_message()) from exc
Expand Down
66 changes: 61 additions & 5 deletions agent/codex_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,43 @@
logger = logging.getLogger(__name__)


def _synthesize_codex_stream_response(
agent,
*,
collected_output_items: list,
has_tool_calls: bool,
) -> Any:
"""Build a minimal Responses object from stream events already received.

The chatgpt.com Codex backend can emit a final ``response.completed`` event
whose ``response.output`` is null. OpenAI SDK 2.24.0 then raises
``TypeError: 'NoneType' object is not iterable`` while parsing that terminal
event, even though text deltas or output_item.done events were already
delivered. Returning a small response object lets the normal Hermes
normalization path continue.
"""
if collected_output_items:
return SimpleNamespace(
status="completed",
output=list(collected_output_items),
usage=None,
)
text_parts = getattr(agent, "_codex_streamed_text_parts", None) or []
if text_parts and not has_tool_calls:
assembled = "".join(text_parts)
return SimpleNamespace(
status="completed",
output=[SimpleNamespace(
type="message",
role="assistant",
status="completed",
content=[SimpleNamespace(type="output_text", text=assembled)],
)],
usage=None,
)
return None


def run_codex_app_server_turn(
agent,
*,
Expand Down Expand Up @@ -248,10 +285,11 @@ 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.
# Backfill from collected items or synthesize from deltas.
# but get_final_response() can return output=None or an
# empty output list. Backfill from collected items or
# synthesize from deltas.
_out = getattr(final_response, "output", None)
if isinstance(_out, list) and not _out:
if _out is None or (isinstance(_out, list) and not _out):
if collected_output_items:
final_response.output = list(collected_output_items)
logger.debug(
Expand Down Expand Up @@ -335,6 +373,24 @@ 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)
if "NoneType" in err_text and "iterable" in err_text:
recovered = _synthesize_codex_stream_response(
agent,
collected_output_items=collected_output_items,
has_tool_calls=has_tool_calls,
)
if recovered is not None:
logger.info(
"Codex Responses stream terminal event had null output; "
"recovered response from streamed events (%d items, %d chars). %s",
len(collected_output_items),
sum(len(p) for p in getattr(agent, "_codex_streamed_text_parts", []) or []),
agent._client_log_context(),
)
return recovered
raise



Expand Down Expand Up @@ -412,9 +468,9 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
if terminal_response is None and isinstance(event, dict):
terminal_response = event.get("response")
if terminal_response is not None:
# Backfill empty output from collected stream events
# Backfill missing/empty output from collected stream events
_out = getattr(terminal_response, "output", None)
if isinstance(_out, list) and not _out:
if _out is None or (isinstance(_out, list) and not _out):
if collected_output_items:
terminal_response.output = list(collected_output_items)
logger.debug(
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@

# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"mioimotoai-lgtm@users.noreply.github.com": "mioimotoai-lgtm",
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
"45688690+fujinice@users.noreply.github.com": "fujinice",
# teknium (multiple emails)
Expand Down
94 changes: 94 additions & 0 deletions tests/agent/test_codex_runtime_null_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from types import SimpleNamespace

from agent.codex_runtime import run_codex_stream
from agent.auxiliary_client import _CodexCompletionsAdapter


class _NullOutputCompletedStream:
def __enter__(self):
return self

def __exit__(self, exc_type, exc, tb):
return False

def __iter__(self):
yield SimpleNamespace(type="response.output_text.delta", delta="hello ")
yield SimpleNamespace(type="response.output_text.delta", delta="world")
raise TypeError("'NoneType' object is not iterable")

def get_final_response(self):
raise AssertionError("stream iteration should fail before final response")


class _DirectNullOutputCompletedStream:
def __enter__(self):
return self

def __exit__(self, exc_type, exc, tb):
return False

def __iter__(self):
yield SimpleNamespace(type="response.output_text.delta", delta="direct ")
yield SimpleNamespace(type="response.output_text.delta", delta="ok")

def get_final_response(self):
return SimpleNamespace(status="completed", output=None, usage=None)


class _FakeResponses:
def stream(self, **kwargs):
return _NullOutputCompletedStream()


class _FakeDirectNullResponses:
def stream(self, **kwargs):
return _DirectNullOutputCompletedStream()


def _fake_agent(responses):
return SimpleNamespace(
_interrupt_requested=False,
_codex_stream_last_event_ts=None,
_codex_streamed_text_parts=[],
_ensure_primary_openai_client=lambda reason: SimpleNamespace(responses=responses),
_touch_activity=lambda message: None,
_fire_stream_delta=lambda text: None,
_fire_reasoning_delta=lambda text: None,
_client_log_context=lambda: "provider=openai-codex model=gpt-5.5",
)


def test_codex_stream_recovers_from_completed_null_output():
agent = _fake_agent(_FakeResponses())

response = run_codex_stream(agent, {"model": "gpt-5.5"})

assert response.status == "completed"
assert response.output[0].content[0].text == "hello world"


def test_codex_stream_backfills_direct_null_final_output():
agent = _fake_agent(_FakeDirectNullResponses())

response = run_codex_stream(agent, {"model": "gpt-5.5"})

assert response.status == "completed"
assert response.output[0].content[0].text == "direct ok"


def test_codex_auxiliary_recovers_from_completed_null_output():
fake_client = SimpleNamespace(responses=_FakeResponses())
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")

response = adapter.create(messages=[{"role": "user", "content": "hi"}])

assert response.choices[0].message.content == "hello world"


def test_codex_auxiliary_backfills_direct_null_final_output():
fake_client = SimpleNamespace(responses=_FakeDirectNullResponses())
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")

response = adapter.create(messages=[{"role": "user", "content": "hi"}])

assert response.choices[0].message.content == "direct ok"
Loading