fix(agno): tolerate streaming tool-call SDK objects in parser (#1312) (#1336)

## Description

`HeadroomAgnoModel` blows up as soon as you stream a response that
includes a tool call:

```
ERROR    Error in Agent run: 'ChoiceDeltaToolCall' object has no attribute 'get'
```

When streaming, Agno hands us `Message.tool_calls` as the raw OpenAI SDK
objects (`ChoiceDeltaToolCall`), not the OpenAI-style dicts we get on
the non-streaming path. Those objects are pydantic models — attribute
access only, no `.get()`. Our shared parser in `headroom/parser.py`
walks `tool_calls` and calls `tc.get("function", {})` / `tc.get("id")`,
so it throws `AttributeError`, and the Agno wrapper surfaces that as a
`RunErrorEvent` that kills the run.

I reproduced the exact error against `parse_message_to_blocks` with a
stand-in object before writing the fix.

Closes #1312

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `parser.py`: added a small `_coerce_tool_call_to_dict()` helper that
takes a tool_call which might be a dict or a provider SDK object and
returns the canonical OpenAI dict (reading `.function.name` /
`.function.arguments` via `getattr`). Wired it into both `.get()` sites,
`parse_message_to_blocks` and `find_tool_units`. Dicts pass straight
through (same object, no copy); `None` or anything unexpected degrades
to `{}` instead of raising. The proxy, langchain, and strands
integrations go through this same parser, so they get the same
hardening.
- `integrations/agno/model.py`: normalize `tool_calls` to dicts in
`_convert_messages_to_openai`, so the Agno `Message` objects we rebuild
and hand back also carry clean dicts and Agno's own re-serialization
can't trip over the same thing.

## Testing

- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_parser.py -q
93 passed
# 87 existing + 6 new regression tests in TestStreamingToolCallObjects.

$ python -m pytest tests/test_integrations/agno/test_model.py -q
59 skipped
# These skip locally because agno isn't installed here
# (pytestmark = skipif(not AGNO_AVAILABLE)); they run in CI. The new
# test_convert_messages_normalizes_streaming_tool_call_objects is in this file.
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10, local clone. agno and the Rust
`headroom._core` extension aren't installed/built in this checkout.
- Exact command / steps: built a stand-in `ChoiceDeltaToolCall`
(attribute access, no `.get()`, nested `.function.name`/`.arguments`)
matching the OpenAI SDK streaming type, ran it through
`parse_message_to_blocks` and `find_tool_units` before and after the
change, then ran the parser suite.
- Observed result: before the fix I got `AttributeError:
'ChoiceDeltaToolCall' object has no attribute 'get'` — the exact error
from the issue. After the fix the same input produces a proper
`tool_call` block (correct `tool_call_id` / `function_name`) and
`find_tool_units` pairs the assistant call with its tool response.
Parser suite is green at 93 passed.
- Not tested: a full live `agent.run(stream=True)` against a real
OpenAI-compatible backend, since agno isn't installed here. That path is
covered by the Agno test in CI. I reproduced the failure at the parser
boundary instead, which is where the actual crash happens.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- No docs change — this is an internal robustness fix at the parsing
boundary, no user-facing API.
- CHANGELOG.md is generated from the Conventional Commit subject via
release-please, so the `fix(agno):` commit gets picked up on its own.
- I went with two layers (parser + the Agno boundary) on purpose so
neither our pipeline nor Agno's re-serialization can hit it. Since the
parser helper is shared, the proxy/langchain/strands paths are covered
too.
This commit is contained in:
Lakshya Sharma 2026-06-24 20:22:15 +05:30 committed by GitHub
parent 52068dd650
commit 5986c2260f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 190 additions and 4 deletions

View file

@ -30,6 +30,7 @@ except ImportError:
ModelResponse = dict # type: ignore[misc,assignment]
from headroom import HeadroomConfig, HeadroomMode
from headroom.parser import _coerce_tool_call_to_dict
from headroom.providers import OpenAIProvider
from headroom.transforms import TransformPipeline
@ -320,9 +321,16 @@ class HeadroomAgnoModel(Model): # type: ignore[misc]
else:
entry["content"] = content
# Handle tool calls
# Handle tool calls. During streaming, Agno may surface
# tool_calls as raw provider SDK objects (OpenAI's
# `ChoiceDeltaToolCall`) rather than plain dicts. The
# Headroom pipeline + Agno's own re-serialization both call
# `.get()` on each entry, which raises
# `'ChoiceDeltaToolCall' object has no attribute 'get'`
# (issue #1312). Normalize to OpenAI-format dicts here so
# every downstream consumer sees a uniform shape.
if hasattr(msg, "tool_calls") and msg.tool_calls:
entry["tool_calls"] = msg.tool_calls
entry["tool_calls"] = [_coerce_tool_call_to_dict(tc) for tc in msg.tool_calls]
# Handle tool call ID for tool responses
if hasattr(msg, "tool_call_id") and msg.tool_call_id:
entry["tool_call_id"] = msg.tool_call_id

View file

@ -53,6 +53,52 @@ def compute_hash(text: str) -> str:
return hashlib.md5(text.encode()).hexdigest()[:16] # nosec B324
def _coerce_tool_call_to_dict(tc: Any) -> dict[str, Any]:
"""Normalize a single tool_call into the canonical OpenAI dict shape.
`tc` is usually already an OpenAI-format dict
(``{"id": ..., "function": {"name": ..., "arguments": ...}}``), but
streaming integrations can hand us the raw provider SDK object instead.
The OpenAI Python SDK's streaming path yields ``ChoiceDeltaToolCall``
objects (and the non-streaming path ``ChatCompletionMessageToolCall``),
which are Pydantic models with attribute access and NO ``.get()`` so
calling ``tc.get("function")`` blows up with
``'ChoiceDeltaToolCall' object has no attribute 'get'`` (issue #1312,
seen via the Agno wrapper streaming tool calls).
Accept both. Dicts pass through untouched; attribute-style objects are
read via ``getattr`` and flattened to a dict with the same keys the
parser expects (``id`` + nested ``function.name`` / ``function.arguments``).
A nested ``function`` may itself be a dict or an SDK object, so it gets
the same treatment. Anything unrecognized degrades to an empty dict
rather than raising over-compression of a malformed tool call is far
cheaper than crashing the whole agent run.
"""
if isinstance(tc, dict):
return tc
# Attribute-style provider object (e.g. OpenAI ChoiceDeltaToolCall).
if tc is None:
return {}
func = getattr(tc, "function", None)
if isinstance(func, dict):
func_dict = func
elif func is not None:
func_dict = {
"name": getattr(func, "name", None),
"arguments": getattr(func, "arguments", ""),
}
else:
func_dict = {}
return {
"id": getattr(tc, "id", None),
"type": getattr(tc, "type", "function"),
"function": func_dict,
}
def _canonical_call_key(name: str, arguments: Any) -> str:
"""Canonical identity for a tool invocation: name + arguments with JSON
key order normalized, so semantically identical calls hash equal even
@ -301,7 +347,8 @@ def parse_message_to_blocks(
# Handle tool calls (assistant messages with tool_calls)
tool_calls = message.get("tool_calls")
if tool_calls:
for tc in tool_calls:
for raw_tc in tool_calls:
tc = _coerce_tool_call_to_dict(raw_tc)
func = tc.get("function", {})
tc_text = f"{func.get('name', 'unknown')}({func.get('arguments', '')})"
@ -527,7 +574,8 @@ def find_tool_units(messages: list[dict[str, Any]]) -> list[tuple[int, list[int]
# OpenAI format: tool_calls array
tool_calls = msg.get("tool_calls")
if tool_calls:
for tc in tool_calls:
for raw_tc in tool_calls:
tc = _coerce_tool_call_to_dict(raw_tc)
tc_id = tc.get("id")
if tc_id and tc_id in tool_response_map:
response_indices.append(tool_response_map[tc_id])

View file

@ -270,6 +270,46 @@ class TestHeadroomAgnoModel:
assert "tool_calls" in openai_msgs[0]
assert openai_msgs[1]["tool_call_id"] == "call_123"
def test_convert_messages_normalizes_streaming_tool_call_objects(self, mock_agno_model):
"""Regression for issue #1312: in streaming mode Agno can surface
tool_calls as raw OpenAI SDK objects (`ChoiceDeltaToolCall`) with
attribute access and no `.get()`. `_convert_messages_to_openai`
must flatten them to OpenAI-format dicts so neither the Headroom
pipeline nor Agno's re-serialization hits
`'ChoiceDeltaToolCall' object has no attribute 'get'`."""
from headroom.integrations.agno import HeadroomAgnoModel
# Mimic the OpenAI SDK streaming object: attribute access, no .get().
class _Fn:
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
class _ChoiceDeltaToolCall:
def __init__(self, id, name, arguments):
self.id = id
self.index = 0
self.type = "function"
self.function = _Fn(name, arguments)
assistant_msg = MagicMock()
assistant_msg.role = "assistant"
assistant_msg.content = ""
assistant_msg.tool_calls = [
_ChoiceDeltaToolCall("call_999", "dummy_tool", '{"query": "test"}')
]
assistant_msg.tool_call_id = None
model = HeadroomAgnoModel(wrapped_model=mock_agno_model)
openai_msgs = model._convert_messages_to_openai([assistant_msg])
tool_calls = openai_msgs[0]["tool_calls"]
# Every entry must now be a plain dict, not the SDK object.
assert all(isinstance(tc, dict) for tc in tool_calls)
assert tool_calls[0]["id"] == "call_999"
assert tool_calls[0]["function"]["name"] == "dummy_tool"
assert tool_calls[0]["function"]["arguments"] == '{"query": "test"}'
def test_response_applies_optimization(self, mock_agno_model, sample_messages):
"""response() applies Headroom optimization."""
from headroom.integrations.agno import HeadroomAgnoModel

View file

@ -15,6 +15,7 @@ from unittest.mock import Mock
import pytest
from headroom.parser import (
_coerce_tool_call_to_dict,
compute_hash,
detect_waste_signals,
find_tool_units,
@ -24,6 +25,31 @@ from headroom.parser import (
parse_messages,
)
# --- Streaming SDK tool-call objects (issue #1312) ---
class _FakeDeltaToolCallFunction:
"""Mimics openai.types...ChoiceDeltaToolCallFunction: attribute access,
no `.get()`."""
def __init__(self, name: str, arguments: str) -> None:
self.name = name
self.arguments = arguments
class _FakeChoiceDeltaToolCall:
"""Mimics the OpenAI SDK streaming tool-call object that the Agno
wrapper surfaces. It is a Pydantic-style model attribute access only,
crucially with NO `.get()` which is exactly what triggered issue
#1312 (`'ChoiceDeltaToolCall' object has no attribute 'get'`)."""
def __init__(self, id: str, name: str, arguments: str, index: int = 0) -> None:
self.id = id
self.index = index
self.type = "function"
self.function = _FakeDeltaToolCallFunction(name, arguments)
# --- Fixtures ---
@ -363,6 +389,70 @@ class TestParseMessageToBlocks:
assert blocks[0].tokens_est > 0
class TestStreamingToolCallObjects:
"""Regression coverage for issue #1312: streaming integrations (Agno
over OpenAILike) can hand the parser raw OpenAI SDK `ChoiceDeltaToolCall`
objects instead of OpenAI-format dicts. The parser called `.get()` on
them and crashed the whole agent run with
`'ChoiceDeltaToolCall' object has no attribute 'get'`. Both the parser
call sites must now tolerate attribute-style tool-call objects."""
def test_coerce_dict_is_passthrough_identity(self):
d = {"id": "call_1", "function": {"name": "f", "arguments": "{}"}}
# A dict must be returned untouched (same object) — no needless copy.
assert _coerce_tool_call_to_dict(d) is d
def test_coerce_sdk_object_flattens_to_openai_dict(self):
tc = _FakeChoiceDeltaToolCall("call_1", "search", '{"q": "x"}')
out = _coerce_tool_call_to_dict(tc)
assert out == {
"id": "call_1",
"type": "function",
"function": {"name": "search", "arguments": '{"q": "x"}'},
}
def test_coerce_object_with_dict_function(self):
# Some providers nest a dict `function` on an attribute-style object.
class _TC:
id = "call_2"
type = "function"
function = {"name": "g", "arguments": "1"}
out = _coerce_tool_call_to_dict(_TC())
assert out["function"] == {"name": "g", "arguments": "1"}
def test_coerce_none_degrades_to_empty_dict(self):
assert _coerce_tool_call_to_dict(None) == {}
def test_parse_message_to_blocks_with_sdk_tool_call(self, mock_tokenizer):
"""The original crash site: parsing an assistant message whose
tool_calls are SDK objects must produce a tool_call block, not
raise AttributeError."""
tc = _FakeChoiceDeltaToolCall("call_abc", "dummy_tool", '{"query": "test"}')
msg = {"role": "assistant", "content": "", "tool_calls": [tc]}
blocks = parse_message_to_blocks(msg, 0, mock_tokenizer)
tool_call_blocks = [b for b in blocks if b.kind == "tool_call"]
assert len(tool_call_blocks) == 1
assert tool_call_blocks[0].flags.get("tool_call_id") == "call_abc"
assert tool_call_blocks[0].flags.get("function_name") == "dummy_tool"
assert "dummy_tool" in tool_call_blocks[0].text
def test_find_tool_units_with_sdk_tool_call(self):
"""The second `.get()` site: find_tool_units must still pair an
SDK-object tool_call with its tool response message."""
tc = _FakeChoiceDeltaToolCall("call_abc", "dummy_tool", "{}")
messages = [
{"role": "assistant", "content": "", "tool_calls": [tc]},
{"role": "tool", "content": "result", "tool_call_id": "call_abc"},
]
units = find_tool_units(messages)
assert units == [(0, [1])]
# --- TestParseMessages ---