From e514d3713e125cea52c27a20575b7c40606bf151 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 13 Aug 2026 12:25:30 -0700 Subject: [PATCH 1/3] fix(openai): tolerate sparse response objects Signed-off-by: Bryan Bednarski --- agent/chat_completion_helpers.py | 16 ++++--- agent/transports/chat_completions.py | 5 ++- .../agent/transports/test_chat_completions.py | 17 ++++++++ tests/run_agent/test_streaming.py | 43 ++++++++++++++++++- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 658919a5fd..b87f3d41ed 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -3530,11 +3530,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= agent._fire_reasoning_delta(reasoning_text) # Accumulate text content — fire callback only when no tool calls - if delta and delta.content: - content_parts.append(delta.content) + delta_content = getattr(delta, "content", None) + if delta_content: + content_parts.append(delta_content) if not tool_calls_acc: _fire_first_delta() - agent._fire_stream_delta(delta.content) + agent._fire_stream_delta(delta_content) deltas_were_sent["yes"] = True # Tool calls suppress regular content streaming (avoids # displaying chatty "I'll use the tool..." text alongside @@ -3549,14 +3550,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # box is already closed (tool boundary flush). elif agent.stream_delta_callback: try: - agent.stream_delta_callback(delta.content) - agent._record_streamed_assistant_text(delta.content) + agent.stream_delta_callback(delta_content) + agent._record_streamed_assistant_text(delta_content) except Exception: pass # Accumulate tool call deltas — notify display on first name - if delta and delta.tool_calls: - for tc_delta in delta.tool_calls: + delta_tool_calls = getattr(delta, "tool_calls", None) + if delta_tool_calls: + for tc_delta in delta_tool_calls: raw_idx = tc_delta.index if tc_delta.index is not None else 0 delta_id = tc_delta.id or "" diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 275a66db32..40698dd609 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -764,9 +764,10 @@ class ChatCompletionsTransport(ProviderTransport): finish_reason = _fr or "stop" tool_calls = None - if msg.tool_calls: + message_tool_calls = getattr(msg, "tool_calls", None) + if message_tool_calls: tool_calls = [] - for tc in msg.tool_calls: + for tc in message_tool_calls: # Preserve provider-specific extras on the tool call. # Gemini 3 thinking models attach extra_content with # thought_signature — without replay on the next turn the API diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index d401d6d958..d87aca3276 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -20,6 +20,23 @@ def transport(): class TestChatCompletionsBasic: + def test_normalize_response_allows_missing_optional_tool_calls(self, transport): + response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content="done"), + finish_reason="stop", + ) + ], + usage=None, + ) + + normalized = transport.normalize_response(response) + + assert normalized.content == "done" + assert normalized.tool_calls is None + + @pytest.mark.parametrize("provider", ["nous", "openrouter"]) def test_gpt56_ultra_uses_max_wire_effort(self, transport, provider): diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index 622474839c..bc36450d4c 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -96,6 +96,48 @@ class TestStreamingAccumulator: assert response.usage is not None assert response.usage.completion_tokens == 3 + @patch("run_agent.AIAgent._create_request_openai_client") + @patch("run_agent.AIAgent._close_request_openai_client") + def test_sparse_delta_allows_missing_optional_fields(self, mock_close, mock_create): + """Managed stream deltas may omit both content and tool_calls.""" + from run_agent import AIAgent + + sparse_delta = SimpleNamespace(reasoning_content=None, reasoning=None) + chunks = [ + SimpleNamespace( + choices=[ + SimpleNamespace( + index=0, + delta=sparse_delta, + finish_reason=None, + ) + ], + model="test-model", + usage=None, + ), + _make_stream_chunk( + content="done", finish_reason="stop", model="test-model" + ), + ] + mock_client = MagicMock() + mock_client.chat.completions.create.return_value = iter(chunks) + mock_create.return_value = mock_client + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "chat_completions" + agent._interrupt_requested = False + + response = agent._interruptible_streaming_api_call({}) + + assert response.choices[0].message.content == "done" + assert response.choices[0].message.tool_calls is None + @patch("run_agent.AIAgent._create_request_openai_client") @patch("run_agent.AIAgent._close_request_openai_client") def test_chat_stream_closes_original_provider_resource( @@ -1634,4 +1676,3 @@ class TestBedrockReasoningStaleFloor: from agent.chat_completion_helpers import _bedrock_reasoning_stale_floor assert _bedrock_reasoning_stale_floor(model_id) == expected - From 89c44c0dadd12c79420a1e8ea78d7c7e354fc82e Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 13 Aug 2026 14:42:48 -0700 Subject: [PATCH 2/3] fix(openai): cover nested sparse response fields Signed-off-by: Alex Fournier --- agent/chat_completion_helpers.py | 30 +++++++----- agent/transports/chat_completions.py | 2 +- .../agent/transports/test_chat_completions.py | 10 ++-- tests/run_agent/test_streaming.py | 46 +++++++++++++++++++ 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index b87f3d41ed..b7c37c61af 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -3559,8 +3559,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= delta_tool_calls = getattr(delta, "tool_calls", None) if delta_tool_calls: for tc_delta in delta_tool_calls: - raw_idx = tc_delta.index if tc_delta.index is not None else 0 - delta_id = tc_delta.id or "" + raw_index = getattr(tc_delta, "index", None) + raw_idx = raw_index if raw_index is not None else 0 + delta_id = getattr(tc_delta, "id", None) or "" # Ollama fix: detect a new tool call reusing the same # raw index (different id) and redirect to a fresh slot. @@ -3579,7 +3580,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if idx not in tool_calls_acc: # Poolside may send integer id instead of string - _tc_id = tc_delta.id + _tc_id = getattr(tc_delta, "id", None) if isinstance(_tc_id, int): _tc_id = str(_tc_id) tool_calls_acc[idx] = { @@ -3589,14 +3590,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= "extra_content": None, } entry = tool_calls_acc[idx] - if tc_delta.id is not None: - _new_id = tc_delta.id + tc_id = getattr(tc_delta, "id", None) + if tc_id is not None: + _new_id = tc_id if isinstance(_new_id, int): _new_id = str(_new_id) if _new_id: entry["id"] = _new_id - if tc_delta.function: - if tc_delta.function.name: + tc_function = getattr(tc_delta, "function", None) + if tc_function: + function_name = getattr(tc_function, "name", None) + if function_name: # Use assignment, not +=. Function names are # atomic identifiers delivered complete in the # first chunk (OpenAI spec). Some providers @@ -3605,9 +3609,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # produce "read_fileread_file". Assignment # (matching the OpenAI Node SDK / LiteLLM / # Vercel AI patterns) is immune to this. - entry["function"]["name"] = tc_delta.function.name - if tc_delta.function.arguments: - entry["function"]["arguments"] += tc_delta.function.arguments + entry["function"]["name"] = function_name + function_arguments = getattr(tc_function, "arguments", None) + if function_arguments: + entry["function"]["arguments"] += function_arguments extra = getattr(tc_delta, "extra_content", None) if extra is None and hasattr(tc_delta, "model_extra"): extra = (tc_delta.model_extra if isinstance(tc_delta.model_extra, dict) else {}).get("extra_content") @@ -3633,8 +3638,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # discarding the attempted action. result["partial_tool_names"].append(name) - if chunk.choices[0].finish_reason: - finish_reason = chunk.choices[0].finish_reason + chunk_finish_reason = getattr(chunk.choices[0], "finish_reason", None) + if chunk_finish_reason: + finish_reason = chunk_finish_reason # Usage in the final chunk if hasattr(chunk, "usage") and chunk.usage: diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 40698dd609..c62bcdf607 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -835,7 +835,7 @@ class ChatCompletionsTransport(ProviderTransport): # Promote it to content + a ``content_filter`` finish reason so the # loop's refusal handler surfaces it clearly and stops. ``refusal`` is # ``None`` for normal responses, so this is a no-op in the common case. - content = msg.content + content = getattr(msg, "content", None) refusal = getattr(msg, "refusal", None) if refusal is None and hasattr(msg, "model_extra"): _msg_extra = getattr(msg, "model_extra", None) or {} diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index d87aca3276..d8b9dcce5d 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -18,13 +18,11 @@ def transport(): class TestChatCompletionsBasic: - - - def test_normalize_response_allows_missing_optional_tool_calls(self, transport): + def test_normalize_response_allows_missing_optional_message_fields(self, transport): response = SimpleNamespace( choices=[ SimpleNamespace( - message=SimpleNamespace(content="done"), + message=SimpleNamespace(), finish_reason="stop", ) ], @@ -33,11 +31,9 @@ class TestChatCompletionsBasic: normalized = transport.normalize_response(response) - assert normalized.content == "done" + assert normalized.content is None assert normalized.tool_calls is None - - @pytest.mark.parametrize("provider", ["nous", "openrouter"]) def test_gpt56_ultra_uses_max_wire_effort(self, transport, provider): from providers import get_provider_profile diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index bc36450d4c..b971f9ec82 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -138,6 +138,52 @@ class TestStreamingAccumulator: assert response.choices[0].message.content == "done" assert response.choices[0].message.tool_calls is None + @patch("run_agent.AIAgent._create_request_openai_client") + @patch("run_agent.AIAgent._close_request_openai_client") + def test_sparse_tool_delta_allows_missing_nested_fields( + self, mock_close, mock_create + ): + """A partial tool delta may contain arguments before its other fields.""" + from run_agent import AIAgent + + sparse_tool_delta = SimpleNamespace( + index=0, + function=SimpleNamespace(arguments='{"city":"Paris"}'), + ) + chunks = [ + SimpleNamespace( + choices=[ + SimpleNamespace( + index=0, + delta=SimpleNamespace(tool_calls=[sparse_tool_delta]), + ) + ], + model="test-model", + usage=None, + ), + _make_stream_chunk(finish_reason="tool_calls", model="test-model"), + ] + mock_client = MagicMock() + mock_client.chat.completions.create.return_value = iter(chunks) + mock_create.return_value = mock_client + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "chat_completions" + agent._interrupt_requested = False + + response = agent._interruptible_streaming_api_call({}) + + tool_call = response.choices[0].message.tool_calls[0] + assert tool_call.function.name == "" + assert tool_call.function.arguments == '{"city":"Paris"}' + assert response.choices[0].finish_reason == "tool_calls" + @patch("run_agent.AIAgent._create_request_openai_client") @patch("run_agent.AIAgent._close_request_openai_client") def test_chat_stream_closes_original_provider_resource( From cc3418e069f39511d1f11702aa5de9c3fcb0f033 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 17 Aug 2026 10:17:52 -0700 Subject: [PATCH 3/3] fix(openai): complete sparse response normalization Signed-off-by: Alex Fournier --- agent/transports/chat_completions.py | 21 +++++-- .../agent/transports/test_chat_completions.py | 59 +++++++++++++++++-- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index c62bcdf607..b628e22bac 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -756,9 +756,9 @@ class ChatCompletionsTransport(ProviderTransport): preserved for downstream replay. """ choice = response.choices[0] - msg = choice.message + msg = getattr(choice, "message", None) # Poolside returns integer finish_reason (e.g. 24) instead of string - _fr = choice.finish_reason + _fr = getattr(choice, "finish_reason", None) if isinstance(_fr, int): _fr = str(_fr) finish_reason = _fr or "stop" @@ -768,6 +768,13 @@ class ChatCompletionsTransport(ProviderTransport): if message_tool_calls: tool_calls = [] for tc in message_tool_calls: + tc_function = getattr(tc, "function", None) + function_name = getattr(tc_function, "name", None) + # Match Relay's codec: skip absent function/name fields, but + # preserve an explicit blank name for Hermes's recovery path. + if tc_function is None or function_name is None: + continue + function_arguments = getattr(tc_function, "arguments", None) # Preserve provider-specific extras on the tool call. # Gemini 3 thinking models attach extra_content with # thought_signature — without replay on the next turn the API @@ -790,9 +797,13 @@ class ChatCompletionsTransport(ProviderTransport): tc_provider_data["extra_content"] = extra tool_calls.append( ToolCall( - id=tc.id, - name=tc.function.name, - arguments=tc.function.arguments, + id=getattr(tc, "id", None), + name=function_name, + arguments=( + function_arguments + if function_arguments is not None + else "{}" + ), provider_data=tc_provider_data or None, ) ) diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index d8b9dcce5d..64910d93bb 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -18,12 +18,46 @@ def transport(): class TestChatCompletionsBasic: - def test_normalize_response_allows_missing_optional_message_fields(self, transport): + @pytest.mark.parametrize( + "choice", + [SimpleNamespace(message=SimpleNamespace()), SimpleNamespace()], + ) + def test_normalize_response_allows_missing_optional_message_fields( + self, transport, choice + ): + response = SimpleNamespace(choices=[choice], usage=None) + + normalized = transport.normalize_response(response) + + assert normalized.content is None + assert normalized.tool_calls is None + assert normalized.finish_reason == "stop" + + def test_normalize_response_allows_sparse_tool_call_fields(self, transport): response = SimpleNamespace( choices=[ SimpleNamespace( - message=SimpleNamespace(), - finish_reason="stop", + message=SimpleNamespace( + tool_calls=[ + SimpleNamespace( + function=SimpleNamespace(arguments='{"city":"Paris"}') + ), + SimpleNamespace(), + SimpleNamespace( + id="call-3", + function=SimpleNamespace(name="lookup"), + ), + SimpleNamespace( + id="call-4", + function=SimpleNamespace(name="", arguments="{}"), + ), + SimpleNamespace( + function=SimpleNamespace( + name="weather", arguments="{}" + ) + ), + ] + ) ) ], usage=None, @@ -31,8 +65,23 @@ class TestChatCompletionsBasic: normalized = transport.normalize_response(response) - assert normalized.content is None - assert normalized.tool_calls is None + assert normalized.finish_reason == "stop" + assert normalized.tool_calls is not None + assert [tool.id for tool in normalized.tool_calls] == [ + "call-3", + "call-4", + None, + ] + assert [tool.name for tool in normalized.tool_calls] == [ + "lookup", + "", + "weather", + ] + assert [tool.arguments for tool in normalized.tool_calls] == [ + "{}", + "{}", + "{}", + ] @pytest.mark.parametrize("provider", ["nous", "openrouter"]) def test_gpt56_ultra_uses_max_wire_effort(self, transport, provider):