mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-08 08:11:38 +00:00
fix(auxiliary): coerce None final.output to empty list in Codex aux adapter
Closes #33368. `_CodexCompletionsAdapter.create()` iterates `final.output` from the Codex Responses stream. The event-driven consumer (introduced in #33042) always sets `final.output` to a list, so this shape can't come from our own code path. But: - Mocked clients in tests can return a typed Response with `output=None` - Third-party shims / compatibility layers that bypass the consumer can do the same - A future code path that wraps a different consumer could regress The old code `getattr(final, "output", [])` returns `None` (not the default `[]`) when the attribute EXISTS but is `None`. Iterating `None` then raises `TypeError: 'NoneType' object is not iterable` — the exact error logged by title-generation when this fires. Fix: `getattr(final, "output", None) or []` — single-line defensive coerce. Cheap; zero risk. Regression test asserts the auxiliary path handles a final whose `.output` is `None` (via monkey-patched consumer) without raising and returns the expected chat.completions-shaped response. Reporter: @pavegrid-1 (issue #33368).
This commit is contained in:
parent
9919caff46
commit
486d632cc2
2 changed files with 58 additions and 1 deletions
|
|
@ -828,7 +828,7 @@ class _CodexCompletionsAdapter:
|
||||||
val = obj.get(key, default)
|
val = obj.get(key, default)
|
||||||
return val if val is not None else 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")
|
item_type = _item_get(item, "type")
|
||||||
if item_type == "message":
|
if item_type == "message":
|
||||||
for part in (_item_get(item, "content") or []):
|
for part in (_item_get(item, "content") or []):
|
||||||
|
|
|
||||||
|
|
@ -2591,6 +2591,63 @@ class TestCodexAuxiliaryAdapterNullOutputRecovery:
|
||||||
|
|
||||||
assert response.choices[0].message.content == "aux survived"
|
assert response.choices[0].message.content == "aux survived"
|
||||||
|
|
||||||
|
def test_handles_final_output_is_none_after_consumer(self):
|
||||||
|
"""Regression for #33368 — defense against ``final.output`` being ``None``.
|
||||||
|
|
||||||
|
The event-driven consumer always sets ``final.output`` to a list, so this
|
||||||
|
shape can't come from our own path. But a mocked client / compatibility
|
||||||
|
shim that returns a typed Response with ``output=None`` directly (or a
|
||||||
|
future code path that wraps a different consumer) would crash on
|
||||||
|
``for item in getattr(final, "output", [])`` because ``getattr`` returns
|
||||||
|
``None`` (not the default) when the attribute exists but is ``None``.
|
||||||
|
Coerce with ``or []`` to handle this defensively.
|
||||||
|
"""
|
||||||
|
# Stream that returns no items but a terminal with output=None.
|
||||||
|
# The consumer assembles an empty list. We then mock the consumer's
|
||||||
|
# return to simulate a third-party path that returns final.output=None.
|
||||||
|
empty_events = [
|
||||||
|
SimpleNamespace(type="response.completed", response=SimpleNamespace(
|
||||||
|
status="completed", id="r", output=None, usage=None,
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _Stream:
|
||||||
|
def __iter__(self): return iter(empty_events)
|
||||||
|
def close(self): pass
|
||||||
|
|
||||||
|
# Monkey-patch the consumer to return a final whose .output is None
|
||||||
|
# (mimics third-party shim behavior the defensive guard protects against).
|
||||||
|
from agent import codex_runtime
|
||||||
|
original_consume = codex_runtime._consume_codex_event_stream
|
||||||
|
|
||||||
|
def _consume_returning_none_output(*args, **kwargs):
|
||||||
|
return SimpleNamespace(
|
||||||
|
output=None, # the defensive guard target
|
||||||
|
output_text="",
|
||||||
|
usage=None,
|
||||||
|
status="completed",
|
||||||
|
id="r",
|
||||||
|
model=kwargs.get("model"),
|
||||||
|
incomplete_details=None,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
codex_runtime._consume_codex_event_stream = _consume_returning_none_output
|
||||||
|
try:
|
||||||
|
class FakeResponses:
|
||||||
|
def create(self, **kwargs):
|
||||||
|
return _Stream()
|
||||||
|
|
||||||
|
fake_client = SimpleNamespace(responses=FakeResponses())
|
||||||
|
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")
|
||||||
|
|
||||||
|
# Should not raise TypeError: 'NoneType' object is not iterable
|
||||||
|
response = adapter.create(messages=[{"role": "user", "content": "x"}])
|
||||||
|
assert response.choices[0].message.content is None
|
||||||
|
assert response.choices[0].finish_reason == "stop"
|
||||||
|
finally:
|
||||||
|
codex_runtime._consume_codex_event_stream = original_consume
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail
|
# Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue