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
3 changes: 3 additions & 0 deletions agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
that were previously embedded in the 3,600-line run_agent.py. Extracting
them makes run_agent.py focused on the AIAgent orchestrator class.
"""

from . import _openai_compat # noqa: F401 (installs openai SDK NoneType guard)

87 changes: 87 additions & 0 deletions agent/_openai_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Defensive guard for openai SDK ``parse_response`` on null ``response.output``.

The ChatGPT consumer Codex backend (``chatgpt.com/backend-api/codex``)
intermittently emits ``response.completed`` events whose ``response.output``
is ``null`` instead of an empty list — observed on ``gpt-5.5`` in May 2026.
The high-level ``responses.stream(...)`` / ``responses.parse(...)`` helpers
in ``openai==2.24.0`` then invoke
``openai.lib._parsing._responses.parse_response``, whose first line iterates
``for output in response.output:`` without a ``None`` guard, raising
``TypeError: 'NoneType' object is not iterable`` mid-stream.

Hermes's primary codex path (``agent/codex_runtime.py``) is already
structurally immune — it consumes the raw event stream from
``responses.create(stream=True)`` and never reads
``response.completed.response.output`` for content reconstruction. This
shim covers the remaining callers (any provider profile that still uses
the high-level helpers, plus third-party plugins) and any future openai
version that re-introduces the unguarded iteration.

Upstream PRs tracking the fix: openai/openai-python#3322, #3316, #3286.
This shim becomes a no-op once a Hermes release pins an openai version
that ships the upstream guard, since the wrapped function will simply
re-enter a body that already handles ``response.output is None``.
"""

from __future__ import annotations

import logging
import sys

logger = logging.getLogger(__name__)

_GUARD_FLAG = "_hermes_or_empty_guard"


def _install() -> bool:
try:
from openai.lib._parsing import _responses as _src
except ImportError:
return False

original = getattr(_src, "parse_response", None)
if original is None:
return False
if getattr(original, _GUARD_FLAG, False):
return False

def parse_response(*, text_format, input_tools, response):
if response is not None and getattr(response, "output", None) is None:
try:
response.output = []
except Exception:
try:
object.__setattr__(response, "output", [])
except Exception:
pass
return original(
text_format=text_format,
input_tools=input_tools,
response=response,
)

setattr(parse_response, _GUARD_FLAG, True)
parse_response.__wrapped__ = original # type: ignore[attr-defined]
parse_response.__doc__ = (
"Hermes wrapper that coerces ``response.output`` from ``None`` to "
"``[]`` before delegating to the original openai parse_response. "
"See agent/_openai_compat.py."
)

_src.parse_response = parse_response

for modname in (
"openai.lib.streaming.responses._responses",
"openai.resources.responses.responses",
):
mod = sys.modules.get(modname)
if mod is None:
continue
if getattr(mod, "parse_response", None) is original:
mod.parse_response = parse_response

logger.debug("openai parse_response guard installed (hermes _openai_compat)")
return True


_INSTALLED = _install()
56 changes: 56 additions & 0 deletions tests/agent/test_openai_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Tests for the openai parse_response NoneType guard installed in agent/_openai_compat.py."""

from __future__ import annotations

import pytest


def test_compat_module_importable():
import agent._openai_compat as compat # noqa: F401

assert hasattr(compat, "_install")


def test_parse_response_guarded_on_null_output():
pytest.importorskip("openai")
import agent # noqa: F401 (triggers compat install)
from openai.lib._parsing._responses import parse_response

guard_flag = getattr(parse_response, "_hermes_or_empty_guard", False)
assert guard_flag is True, "compat shim did not install on agent import"

class _FakeResponse:
output = None

try:
parse_response(
text_format=None,
input_tools=None,
response=_FakeResponse(),
)
except TypeError as exc:
if "'NoneType' object is not iterable" in str(exc):
pytest.fail(
"guard ineffective — parse_response still iterates a None response.output"
)
except Exception:
# Downstream pydantic / construct_type validation may still raise once
# iteration succeeds; that's outside the bug's scope. We only assert
# the specific NoneType-iteration regression is fixed.
pass


def test_streaming_module_uses_guarded_reference():
pytest.importorskip("openai")
import agent # noqa: F401
from openai.lib._parsing._responses import parse_response as canonical

streaming = pytest.importorskip("openai.lib.streaming.responses._responses")
resource = pytest.importorskip("openai.resources.responses.responses")

assert streaming.parse_response is canonical, (
"openai.lib.streaming.responses._responses.parse_response not rebound to guarded copy"
)
assert resource.parse_response is canonical, (
"openai.resources.responses.responses.parse_response not rebound to guarded copy"
)