fix(headroom): drop null tool_calls before calling /v1/compress

headroom-ai 0.27.0 through 0.30.0 iterate msg.get("tool_calls", []) and fail
with 'NoneType' object is not iterable when an assistant row carries an explicit
tool_calls: null, which the fail-closed guardrail then surfaces as a 502. Strip
the null key from rows sent to the compression service; real tool_calls lists
are forwarded unchanged

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-09-03 22:53:20 +00:00
parent 8699998c9e
commit 737f2dce41
2 changed files with 50 additions and 10 deletions

View file

@ -80,17 +80,22 @@ def _flatten_messages_for_compression(messages: list[dict[str, object]]) -> list
at its part), so merging text across a non-text part would move a later
breakpoint to the other side of it. Rows with non-text parts are sent
unchanged and pass through the service untouched.
An explicit ``"tool_calls": null`` (what OpenAI SDK message objects
serialize to) is dropped: the service iterates the field and older
releases fail on ``None``.
"""
flattened: Final[list[dict[str, object]]] = []
for msg in messages:
content = msg.get("content")
if is_all_text_parts(content):
text = content_to_text(content)
if text:
flattened.append({**msg, "content": text})
continue
flattened.append(msg)
return flattened
return [_compression_row(msg) for msg in messages]
def _compression_row(message: Mapping[str, object]) -> dict[str, object]:
content: Final = message.get("content")
text: Final = content_to_text(content) if is_all_text_parts(content) else ""
return {
key: text if key == "content" and text else value
for key, value in message.items()
if key != "tool_calls" or value is not None
}
def _restore_content_shapes(

View file

@ -1938,6 +1938,41 @@ async def test_apply_guardrail_sends_textless_parts_rows_unflattened(
assert wire_messages[1]["content"] == "D" * 5000
@pytest.mark.asyncio
async def test_apply_guardrail_drops_null_tool_calls_but_keeps_real_ones(
guardrail: HeadroomGuardrail,
):
real_tool_calls = [{"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}]
history = [
{"role": "user", "content": "E" * 5000},
{"role": "assistant", "content": None, "tool_calls": real_tool_calls},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
{"role": "assistant", "content": "summary", "tool_calls": None, "function_call": None},
]
inputs = GenericGuardrailAPIInputs(
texts=["E" * 5000],
structured_messages=json.loads(json.dumps(history))
+ [{"role": "assistant", "content": "last turn"}, {"role": "user", "content": "and now?"}],
)
mock_response = _make_compress_response(json.loads(json.dumps(history)))
with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_post:
await guardrail.apply_guardrail(
inputs=inputs,
request_data={"model": "claude-fable-5"},
input_type="request",
)
wire_messages = mock_post.call_args.kwargs["json"]["messages"]
assert wire_messages[1]["tool_calls"] == real_tool_calls
assert wire_messages[3] == {"role": "assistant", "content": "summary", "function_call": None}
@pytest.mark.asyncio
async def test_fail_open_returns_original_parts_shapes():
guardrail = _make_guardrail(unreachable_fallback="fail_open")