From 5309501521bd01f58175ff6150b295c51f46225a Mon Sep 17 00:00:00 2001 From: quant Date: Fri, 24 Jul 2026 12:14:47 +0000 Subject: [PATCH] fix(agents): tolerate output_text parts with None text in the event mapper gemini-3.6-flash (via the LiteLLM Responses translation) emits output_text parts with no text field on tool-calls-only turns; the SDK materializes them leniently with text=None and the mapper's join raised TypeError: sequence item 0: expected str instance, NoneType found, failing the whole HALO run. --- engine/agents/openai_event_mapper.py | 8 ++++- tests/unit/agents/test_openai_event_mapper.py | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/engine/agents/openai_event_mapper.py b/engine/agents/openai_event_mapper.py index e69008fa..b0e0ba1a 100644 --- a/engine/agents/openai_event_mapper.py +++ b/engine/agents/openai_event_mapper.py @@ -93,7 +93,13 @@ def _map_assistant_message( raw_item = item.raw_item item_id = raw_item.id parts = raw_item.content - text = "".join(part.text for part in parts if isinstance(part, ResponseOutputText)) + # ``part.text`` can be ``None`` on leniently-parsed provider payloads: + # Gemini (via the LiteLLM Responses translation) emits ``output_text`` + # parts with no ``text`` field when a turn is tool-calls-only, and the + # SDK materializes those without validation. Joining ``None`` raises + # ``TypeError: sequence item 0: expected str instance, NoneType found`` + # and kills the run (INF: analysis-smoke gemini-3.6-flash failures). + text = "".join(part.text or "" for part in parts if isinstance(part, ResponseOutputText)) refusal_text = _extract_refusal_text(parts=parts, text=text) if refusal_text is not None: return MappedEvent(refusal_text=refusal_text) diff --git a/tests/unit/agents/test_openai_event_mapper.py b/tests/unit/agents/test_openai_event_mapper.py index 5e71082c..59f00947 100644 --- a/tests/unit/agents/test_openai_event_mapper.py +++ b/tests/unit/agents/test_openai_event_mapper.py @@ -196,3 +196,38 @@ def test_tool_call_and_output_have_distinct_item_ids() -> None: assert output_mapped.context_item is not None assert call_mapped.context_item.item_id == "tool-call-call_xyz" assert output_mapped.context_item.item_id == "tool-result-call_xyz" + + +def test_assistant_text_part_with_none_text_maps_as_empty() -> None: + """Gemini via the LiteLLM Responses translation emits ``output_text`` parts + with no ``text`` field on tool-calls-only turns; the SDK materializes them + leniently with ``text=None``. The mapper must treat those as empty instead + of raising ``TypeError: sequence item 0: expected str instance, NoneType + found`` (which failed whole HALO runs on gemini-3.6-flash). + """ + from agents.items import MessageOutputItem + from agents.stream_events import RunItemStreamEvent + from openai.types.responses import ResponseOutputMessage, ResponseOutputText + + from tests._sdk_events import SHARED_AGENT + + raw = ResponseOutputMessage.model_construct( + id="msg_none_text", + type="message", + role="assistant", + status="completed", + content=[ResponseOutputText.model_construct(type="output_text", text=None, annotations=[])], + ) + event = RunItemStreamEvent( + name="message_output_created", + item=MessageOutputItem(agent=SHARED_AGENT, raw_item=raw), + ) + + mapper = OpenAiEventMapper() + mapped = mapper.to_mapped_event(event, execution=_exec(), is_root=True) + assert mapped.refusal_text is None + assert mapped.context_item is not None + assert mapped.context_item.content is None + assert mapped.output_item is not None + assert mapped.output_item.final is False + assert mapped.output_item.item.content is None