Spaces:
Build error
fix: Handle Anthropic format tool_use/tool_result as atomic units
Browse filesRoot Cause:
The `find_tool_units()` function in `parser.py` only detected OpenAI
format tool calls (assistant.tool_calls + role="tool" messages), not
Anthropic format (assistant.content[type=tool_use] + user.content[type=tool_result]).
This caused RollingWindow and IntelligentContext transforms to treat
Anthropic tool_use and tool_result as separate, independently droppable
messages. When context needed to be trimmed, the assistant message with
tool_use could be dropped while keeping the user message with tool_result,
creating orphaned tool_result blocks.
When sent to the Anthropic API, this produces the error:
"unexpected tool_use_id found in tool_result blocks"
Changes:
1. parser.py: Extended `find_tool_units()` to detect Anthropic format:
- Scan user messages for content blocks with type="tool_result"
- Scan assistant messages for content blocks with type="tool_use"
- Map tool_use_id to corresponding response message indices
2. rolling_window.py: Extended `_get_protected_indices()` to protect
Anthropic format tool pairs:
- Detect tool_use blocks in assistant.content
- Find and protect matching user messages with tool_result blocks
3. tests/test_parser.py: Added 4 new tests for Anthropic format:
- test_anthropic_format_tool_use_and_result
- test_anthropic_format_multiple_tool_uses
- test_anthropic_format_orphaned_tool_result
- test_mixed_openai_and_anthropic_formats
Test Results: 82 passed (including 4 new Anthropic format tests)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- headroom/parser.py +35 -6
- headroom/transforms/rolling_window.py +37 -6
- tests/test_parser.py +106 -0
|
@@ -242,6 +242,10 @@ def find_tool_units(messages: list[dict[str, Any]]) -> list[tuple[int, list[int]
|
|
| 242 |
A tool unit is atomic - if the assistant message is dropped, all its
|
| 243 |
tool responses must also be dropped.
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
Args:
|
| 246 |
messages: List of message dicts.
|
| 247 |
|
|
@@ -253,24 +257,49 @@ def find_tool_units(messages: list[dict[str, Any]]) -> list[tuple[int, list[int]
|
|
| 253 |
# Build map of tool_call_id -> message index for tool responses
|
| 254 |
tool_response_map: dict[str, int] = {}
|
| 255 |
for i, msg in enumerate(messages):
|
|
|
|
| 256 |
if msg.get("role") == "tool":
|
| 257 |
tc_id = msg.get("tool_call_id")
|
| 258 |
if tc_id:
|
| 259 |
tool_response_map[tc_id] = i
|
| 260 |
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
for i, msg in enumerate(messages):
|
| 263 |
-
if msg.get("role") =
|
| 264 |
-
|
| 265 |
-
|
|
|
|
| 266 |
|
|
|
|
|
|
|
|
|
|
| 267 |
for tc in tool_calls:
|
| 268 |
tc_id = tc.get("id")
|
| 269 |
if tc_id and tc_id in tool_response_map:
|
| 270 |
response_indices.append(tool_response_map[tc_id])
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
return units
|
| 276 |
|
|
|
|
| 242 |
A tool unit is atomic - if the assistant message is dropped, all its
|
| 243 |
tool responses must also be dropped.
|
| 244 |
|
| 245 |
+
Supports both OpenAI and Anthropic formats:
|
| 246 |
+
- OpenAI: assistant.tool_calls[] + tool messages with tool_call_id
|
| 247 |
+
- Anthropic: assistant.content[type=tool_use] + user.content[type=tool_result]
|
| 248 |
+
|
| 249 |
Args:
|
| 250 |
messages: List of message dicts.
|
| 251 |
|
|
|
|
| 257 |
# Build map of tool_call_id -> message index for tool responses
|
| 258 |
tool_response_map: dict[str, int] = {}
|
| 259 |
for i, msg in enumerate(messages):
|
| 260 |
+
# OpenAI format: role="tool" with tool_call_id
|
| 261 |
if msg.get("role") == "tool":
|
| 262 |
tc_id = msg.get("tool_call_id")
|
| 263 |
if tc_id:
|
| 264 |
tool_response_map[tc_id] = i
|
| 265 |
|
| 266 |
+
# Anthropic format: role="user" with content blocks containing tool_result
|
| 267 |
+
if msg.get("role") == "user":
|
| 268 |
+
content = msg.get("content")
|
| 269 |
+
if isinstance(content, list):
|
| 270 |
+
for block in content:
|
| 271 |
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
| 272 |
+
tc_id = block.get("tool_use_id")
|
| 273 |
+
if tc_id:
|
| 274 |
+
tool_response_map[tc_id] = i
|
| 275 |
+
|
| 276 |
+
# Find assistant messages with tool calls
|
| 277 |
for i, msg in enumerate(messages):
|
| 278 |
+
if msg.get("role") != "assistant":
|
| 279 |
+
continue
|
| 280 |
+
|
| 281 |
+
response_indices: list[int] = []
|
| 282 |
|
| 283 |
+
# OpenAI format: tool_calls array
|
| 284 |
+
tool_calls = msg.get("tool_calls")
|
| 285 |
+
if tool_calls:
|
| 286 |
for tc in tool_calls:
|
| 287 |
tc_id = tc.get("id")
|
| 288 |
if tc_id and tc_id in tool_response_map:
|
| 289 |
response_indices.append(tool_response_map[tc_id])
|
| 290 |
|
| 291 |
+
# Anthropic format: content blocks with type=tool_use
|
| 292 |
+
content = msg.get("content")
|
| 293 |
+
if isinstance(content, list):
|
| 294 |
+
for block in content:
|
| 295 |
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
| 296 |
+
tc_id = block.get("id")
|
| 297 |
+
if tc_id and tc_id in tool_response_map:
|
| 298 |
+
response_indices.append(tool_response_map[tc_id])
|
| 299 |
+
|
| 300 |
+
if response_indices:
|
| 301 |
+
# Use set to deduplicate in case same message has both formats
|
| 302 |
+
units.append((i, sorted(set(response_indices))))
|
| 303 |
|
| 304 |
return units
|
| 305 |
|
|
@@ -225,13 +225,44 @@ class RollingWindow(Transform):
|
|
| 225 |
# Also protect any tool responses that belong to protected assistant messages
|
| 226 |
for i in list(protected):
|
| 227 |
msg = messages[i]
|
| 228 |
-
if msg.get("role") == "assistant"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
# Find and protect corresponding tool responses
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
if other_msg.get("
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
return protected
|
| 237 |
|
|
|
|
| 225 |
# Also protect any tool responses that belong to protected assistant messages
|
| 226 |
for i in list(protected):
|
| 227 |
msg = messages[i]
|
| 228 |
+
if msg.get("role") == "assistant":
|
| 229 |
+
tool_call_ids: set[str] = set()
|
| 230 |
+
|
| 231 |
+
# OpenAI format: tool_calls array
|
| 232 |
+
if msg.get("tool_calls"):
|
| 233 |
+
tool_call_ids.update(
|
| 234 |
+
tc.get("id") for tc in msg.get("tool_calls", []) if tc.get("id")
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# Anthropic format: content blocks with type=tool_use
|
| 238 |
+
content = msg.get("content")
|
| 239 |
+
if isinstance(content, list):
|
| 240 |
+
for block in content:
|
| 241 |
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
| 242 |
+
tc_id = block.get("id")
|
| 243 |
+
if tc_id:
|
| 244 |
+
tool_call_ids.add(tc_id)
|
| 245 |
+
|
| 246 |
# Find and protect corresponding tool responses
|
| 247 |
+
if tool_call_ids:
|
| 248 |
+
for j, other_msg in enumerate(messages):
|
| 249 |
+
# OpenAI format: role="tool"
|
| 250 |
+
if other_msg.get("role") == "tool":
|
| 251 |
+
if other_msg.get("tool_call_id") in tool_call_ids:
|
| 252 |
+
protected.add(j)
|
| 253 |
+
|
| 254 |
+
# Anthropic format: role="user" with tool_result blocks
|
| 255 |
+
if other_msg.get("role") == "user":
|
| 256 |
+
other_content = other_msg.get("content")
|
| 257 |
+
if isinstance(other_content, list):
|
| 258 |
+
for block in other_content:
|
| 259 |
+
if (
|
| 260 |
+
isinstance(block, dict)
|
| 261 |
+
and block.get("type") == "tool_result"
|
| 262 |
+
and block.get("tool_use_id") in tool_call_ids
|
| 263 |
+
):
|
| 264 |
+
protected.add(j)
|
| 265 |
+
break
|
| 266 |
|
| 267 |
return protected
|
| 268 |
|
|
@@ -483,6 +483,112 @@ class TestFindToolUnits:
|
|
| 483 |
_, response_indices = units[0]
|
| 484 |
assert response_indices == sorted(response_indices)
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
# --- TestGetMessageContentText ---
|
| 488 |
|
|
|
|
| 483 |
_, response_indices = units[0]
|
| 484 |
assert response_indices == sorted(response_indices)
|
| 485 |
|
| 486 |
+
def test_anthropic_format_tool_use_and_result(self):
|
| 487 |
+
"""Finds Anthropic format tool_use/tool_result pairs in content blocks."""
|
| 488 |
+
messages = [
|
| 489 |
+
{"role": "system", "content": "You are helpful."},
|
| 490 |
+
{"role": "user", "content": "Take a screenshot"},
|
| 491 |
+
{
|
| 492 |
+
"role": "assistant",
|
| 493 |
+
"content": [
|
| 494 |
+
{"type": "text", "text": "Let me take a screenshot."},
|
| 495 |
+
{
|
| 496 |
+
"type": "tool_use",
|
| 497 |
+
"id": "toolu_123",
|
| 498 |
+
"name": "browser_screenshot",
|
| 499 |
+
"input": {},
|
| 500 |
+
},
|
| 501 |
+
],
|
| 502 |
+
},
|
| 503 |
+
{
|
| 504 |
+
"role": "user",
|
| 505 |
+
"content": [
|
| 506 |
+
{
|
| 507 |
+
"type": "tool_result",
|
| 508 |
+
"tool_use_id": "toolu_123",
|
| 509 |
+
"content": "Screenshot taken successfully",
|
| 510 |
+
}
|
| 511 |
+
],
|
| 512 |
+
},
|
| 513 |
+
{"role": "user", "content": "Thanks!"},
|
| 514 |
+
]
|
| 515 |
+
units = find_tool_units(messages)
|
| 516 |
+
assert len(units) == 1
|
| 517 |
+
assistant_idx, response_indices = units[0]
|
| 518 |
+
assert assistant_idx == 2
|
| 519 |
+
assert response_indices == [3]
|
| 520 |
+
|
| 521 |
+
def test_anthropic_format_multiple_tool_uses(self):
|
| 522 |
+
"""Finds multiple Anthropic format tool_use blocks from same assistant."""
|
| 523 |
+
messages = [
|
| 524 |
+
{"role": "user", "content": "Do two things"},
|
| 525 |
+
{
|
| 526 |
+
"role": "assistant",
|
| 527 |
+
"content": [
|
| 528 |
+
{"type": "tool_use", "id": "toolu_a", "name": "first", "input": {}},
|
| 529 |
+
{"type": "tool_use", "id": "toolu_b", "name": "second", "input": {}},
|
| 530 |
+
],
|
| 531 |
+
},
|
| 532 |
+
{
|
| 533 |
+
"role": "user",
|
| 534 |
+
"content": [
|
| 535 |
+
{"type": "tool_result", "tool_use_id": "toolu_a", "content": "first done"},
|
| 536 |
+
{"type": "tool_result", "tool_use_id": "toolu_b", "content": "second done"},
|
| 537 |
+
],
|
| 538 |
+
},
|
| 539 |
+
]
|
| 540 |
+
units = find_tool_units(messages)
|
| 541 |
+
assert len(units) == 1
|
| 542 |
+
assistant_idx, response_indices = units[0]
|
| 543 |
+
assert assistant_idx == 1
|
| 544 |
+
assert response_indices == [2]
|
| 545 |
+
|
| 546 |
+
def test_anthropic_format_orphaned_tool_result(self):
|
| 547 |
+
"""Anthropic tool_result without matching tool_use is not included."""
|
| 548 |
+
messages = [
|
| 549 |
+
{"role": "user", "content": "Hi"},
|
| 550 |
+
{
|
| 551 |
+
"role": "user",
|
| 552 |
+
"content": [
|
| 553 |
+
{
|
| 554 |
+
"type": "tool_result",
|
| 555 |
+
"tool_use_id": "orphan_toolu",
|
| 556 |
+
"content": "orphaned result",
|
| 557 |
+
}
|
| 558 |
+
],
|
| 559 |
+
},
|
| 560 |
+
{"role": "assistant", "content": "Hello!"},
|
| 561 |
+
]
|
| 562 |
+
units = find_tool_units(messages)
|
| 563 |
+
assert units == []
|
| 564 |
+
|
| 565 |
+
def test_mixed_openai_and_anthropic_formats(self):
|
| 566 |
+
"""Both OpenAI and Anthropic formats can coexist (edge case)."""
|
| 567 |
+
messages = [
|
| 568 |
+
{"role": "user", "content": "Do things"},
|
| 569 |
+
# OpenAI format
|
| 570 |
+
{
|
| 571 |
+
"role": "assistant",
|
| 572 |
+
"tool_calls": [{"id": "call_1", "function": {"name": "openai_tool", "arguments": "{}"}}],
|
| 573 |
+
},
|
| 574 |
+
{"role": "tool", "tool_call_id": "call_1", "content": "openai result"},
|
| 575 |
+
# Anthropic format
|
| 576 |
+
{
|
| 577 |
+
"role": "assistant",
|
| 578 |
+
"content": [{"type": "tool_use", "id": "toolu_2", "name": "anthropic_tool", "input": {}}],
|
| 579 |
+
},
|
| 580 |
+
{
|
| 581 |
+
"role": "user",
|
| 582 |
+
"content": [{"type": "tool_result", "tool_use_id": "toolu_2", "content": "anthropic result"}],
|
| 583 |
+
},
|
| 584 |
+
]
|
| 585 |
+
units = find_tool_units(messages)
|
| 586 |
+
assert len(units) == 2
|
| 587 |
+
# First unit: OpenAI format (assistant at 1, tool response at 2)
|
| 588 |
+
assert units[0] == (1, [2])
|
| 589 |
+
# Second unit: Anthropic format (assistant at 3, user with tool_result at 4)
|
| 590 |
+
assert units[1] == (3, [4])
|
| 591 |
+
|
| 592 |
|
| 593 |
# --- TestGetMessageContentText ---
|
| 594 |
|