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
59 changes: 42 additions & 17 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,26 +796,51 @@ def _check_cancelled() -> None:
timeout_timer.daemon = True
timeout_timer.start()
_check_cancelled()
with self._client.responses.stream(**resp_kwargs) as stream:
for _event in stream:
try:
with self._client.responses.stream(**resp_kwargs) as stream:
for _event in stream:
_check_cancelled()
_etype = getattr(_event, "type", "")
if _etype == "response.output_item.done":
_done = getattr(_event, "item", None)
if _done is not None:
collected_output_items.append(_done)
elif "output_text.delta" in _etype:
_delta = getattr(_event, "delta", "")
if _delta:
collected_text_deltas.append(_delta)
elif "function_call" in _etype:
has_function_calls = True
_check_cancelled()
_etype = getattr(_event, "type", "")
if _etype == "response.output_item.done":
_done = getattr(_event, "item", None)
if _done is not None:
collected_output_items.append(_done)
elif "output_text.delta" in _etype:
_delta = getattr(_event, "delta", "")
if _delta:
collected_text_deltas.append(_delta)
elif "function_call" in _etype:
has_function_calls = True
_check_cancelled()
final = stream.get_final_response()
final = stream.get_final_response()
except TypeError as exc:
if "'NoneType' object is not iterable" not in str(exc):
raise
if collected_output_items:
final = SimpleNamespace(output=list(collected_output_items), usage=None)
logger.debug(
"Codex auxiliary: recovered %d output items after stream parser None output",
len(collected_output_items),
)
elif collected_text_deltas and not has_function_calls:
assembled = "".join(collected_text_deltas)
final = SimpleNamespace(
output=[SimpleNamespace(
type="message", role="assistant", status="completed",
content=[SimpleNamespace(type="output_text", text=assembled)],
)],
usage=None,
)
logger.debug(
"Codex auxiliary: recovered from stream parser None output using %d deltas (%d chars)",
len(collected_text_deltas), len(assembled),
)
else:
raise

# Backfill 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 Down Expand Up @@ -845,7 +870,7 @@ def _item_get(obj: Any, key: str, default: Any = None) -> Any:
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 Down
13 changes: 11 additions & 2 deletions agent/codex_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
# but get_final_response() can return 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 +335,15 @@ 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:
if "'NoneType' object is not iterable" not in str(exc):
raise
logger.debug(
"Responses stream parser hit None output; falling back to create(stream=True). %s err=%s",
agent._client_log_context(),
exc,
)
return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)



Expand Down Expand Up @@ -414,7 +423,7 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None
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 _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
2 changes: 1 addition & 1 deletion agent/transports/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@ def build_kwargs(
payload_messages,
is_xai_responses=is_xai_responses,
),
"tools": response_tools,
"store": False,
}
if response_tools:
kwargs["tools"] = response_tools
kwargs["tool_choice"] = "auto"
kwargs["parallel_tool_calls"] = True

Expand Down
57 changes: 57 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2476,6 +2476,63 @@ def test_skip_set_covers_exactly_known_entries(self):


class TestCodexAuxiliaryAdapterTimeout:
def test_recovers_stream_parser_none_output_error_after_text_delta(self):
class FakeStream:
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="Short title")
raise TypeError("'NoneType' object is not iterable")

def get_final_response(self):
raise AssertionError("stream parser error should bypass final response")

class FakeResponses:
def stream(self, **kwargs):
return FakeStream()

fake_client = SimpleNamespace(responses=FakeResponses())
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")

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

assert response.choices[0].message.content == "Short title"

def test_backfills_none_output_from_streamed_text_delta(self):
class FakeStream:
def __enter__(self):
return self

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

def __iter__(self):
return iter((
SimpleNamespace(type="response.output_text.delta", delta="summary"),
))

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

class FakeResponses:
def stream(self, **kwargs):
return FakeStream()

fake_client = SimpleNamespace(responses=FakeResponses())
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")

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

assert response.choices[0].message.content == "summary"

def test_forwards_timeout_to_responses_stream(self):
class FakeStream:
def __enter__(self):
Expand Down
12 changes: 12 additions & 0 deletions tests/agent/transports/test_codex_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ def test_basic_kwargs(self, transport):
assert "input" in kw
assert kw["store"] is False

def test_no_tools_omits_tools_key(self, transport):
"""OpenAI SDK 2.24 treats tools=None as iterable and raises TypeError."""
kw = transport.build_kwargs(
model="gpt-5.5",
messages=[{"role": "user", "content": "Hello"}],
tools=[],
)

assert "tools" not in kw
assert "tool_choice" not in kw
assert "parallel_tool_calls" not in kw

def test_system_extracted_from_messages(self, transport):
messages = [
{"role": "system", "content": "Custom system prompt"},
Expand Down
62 changes: 62 additions & 0 deletions tests/run_agent/test_run_agent_codex_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,33 @@ def _fake_create(**kwargs):
assert response.output[0].content[0].text == "create fallback ok"


def test_run_codex_stream_falls_back_when_sdk_parser_hits_none_output(monkeypatch):
agent = _build_agent(monkeypatch)
calls = {"stream": 0, "create": 0}

def _fake_stream(**kwargs):
calls["stream"] += 1
return _FakeResponsesStream(
final_error=TypeError("'NoneType' object is not iterable")
)

def _fake_create(**kwargs):
calls["create"] += 1
return _codex_message_response("none-output fallback ok")

agent.client = SimpleNamespace(
responses=SimpleNamespace(
stream=_fake_stream,
create=_fake_create,
)
)

response = agent._run_codex_stream(_codex_request_kwargs())
assert calls["stream"] == 1
assert calls["create"] == 1
assert response.output[0].content[0].text == "none-output fallback ok"


def test_run_codex_stream_fallback_parses_create_stream_events(monkeypatch):
agent = _build_agent(monkeypatch)
calls = {"stream": 0, "create": 0}
Expand Down Expand Up @@ -484,6 +511,41 @@ def _fake_create(**kwargs):
assert response.output[0].content[0].text == "streamed create ok"


def test_run_codex_create_stream_fallback_backfills_terminal_none_output(monkeypatch):
agent = _build_agent(monkeypatch)
create_stream = _FakeCreateStream(
[
SimpleNamespace(type="response.created"),
SimpleNamespace(
type="response.output_item.done",
item=SimpleNamespace(
type="message",
status="completed",
content=[SimpleNamespace(type="output_text", text="backfilled item")],
),
),
SimpleNamespace(
type="response.completed",
response=SimpleNamespace(
output=None,
usage=SimpleNamespace(input_tokens=5, output_tokens=3, total_tokens=8),
status="completed",
model="gpt-5-codex",
),
),
]
)
agent.client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **kwargs: create_stream,
)
)

response = agent._run_codex_create_stream_fallback(_codex_request_kwargs())
assert create_stream.closed is True
assert response.output[0].content[0].text == "backfilled item"


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