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
33 changes: 25 additions & 8 deletions agent/codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@
logger = logging.getLogger(__name__)


def _safe_response_output_text(response: Any) -> str:
"""Read ``response.output_text`` without trusting SDK convenience accessors.

Some Responses SDK objects build ``output_text`` by iterating
``response.output``. When a provider returns ``output=None``, that property
can raise ``TypeError("'NoneType' object is not iterable")`` before Hermes'
normal empty-output recovery can run.
"""
try:
out_text = getattr(response, "output_text", None)
except TypeError as exc:
err_text = str(exc)
if "NoneType" in err_text and "not iterable" in err_text:
logger.debug("Responses output_text unavailable because output is null")
return ""
raise
return out_text.strip() if isinstance(out_text, str) else ""


def _classify_responses_issuer(
*,
is_xai_responses: bool = False,
Expand Down Expand Up @@ -1001,15 +1020,15 @@ def _normalize_codex_response(
# 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:
Expand Down Expand Up @@ -1156,10 +1175,8 @@ def _normalize_codex_response(
))

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
Expand Down
17 changes: 17 additions & 0 deletions tests/agent/test_codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,20 @@ def test_normalize_codex_response_treats_summary_only_reasoning_as_incomplete():
assert assistant_message.content == ""
assert assistant_message.reasoning == "still thinking"
assert assistant_message.codex_reasoning_items is None


def test_normalize_codex_response_ignores_output_text_property_when_output_is_null():
class ResponseWithNullOutput:
status = "completed"
output = None

@property
def output_text(self):
raise TypeError("'NoneType' object is not iterable")

try:
_normalize_codex_response(ResponseWithNullOutput())
except RuntimeError as exc:
assert str(exc) == "Responses API returned no output items"
else:
raise AssertionError("expected empty null-output response to be rejected")