Prakersh Maheshwari Claude Opus 4.5 commited on
Commit
142a6dd
·
1 Parent(s): 63ca881

fix: Handle Anthropic format tool_use/tool_result as atomic units

Browse files

Root 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 CHANGED
@@ -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
- # Find assistant messages with tool_calls
 
 
 
 
 
 
 
 
 
 
262
  for i, msg in enumerate(messages):
263
- if msg.get("role") == "assistant" and msg.get("tool_calls"):
264
- tool_calls = msg["tool_calls"]
265
- response_indices: list[int] = []
 
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
- if response_indices:
273
- units.append((i, sorted(response_indices)))
 
 
 
 
 
 
 
 
 
 
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
 
headroom/transforms/rolling_window.py CHANGED
@@ -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" and msg.get("tool_calls"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  # Find and protect corresponding tool responses
230
- tool_call_ids = {tc.get("id") for tc in msg.get("tool_calls", [])}
231
- for j, other_msg in enumerate(messages):
232
- if other_msg.get("role") == "tool":
233
- if other_msg.get("tool_call_id") in tool_call_ids:
234
- protected.add(j)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
tests/test_parser.py CHANGED
@@ -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