chopratejas commited on
Commit
7223939
·
2 Parent(s): d7c70e45751341

Merge pull request #20 from chopratejas/fix/intelligent-context-anthropic-format

Browse files
headroom/transforms/intelligent_context.py CHANGED
@@ -683,15 +683,47 @@ class IntelligentContextManager(Transform):
683
 
684
  i -= 1
685
 
686
- # Protect tool responses for protected assistant messages
687
  for i in list(protected):
688
  msg = messages[i]
689
- if msg.get("role") == "assistant" and msg.get("tool_calls"):
690
- tool_call_ids = {tc.get("id") for tc in msg.get("tool_calls", [])}
691
- for j, other_msg in enumerate(messages):
692
- if other_msg.get("role") == "tool":
693
- if other_msg.get("tool_call_id") in tool_call_ids:
694
- protected.add(j)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695
 
696
  return protected
697
 
 
683
 
684
  i -= 1
685
 
686
+ # Also protect any tool responses that belong to protected assistant messages
687
  for i in list(protected):
688
  msg = messages[i]
689
+ if msg.get("role") == "assistant":
690
+ tool_call_ids: set[str] = set()
691
+
692
+ # OpenAI format: tool_calls array
693
+ if msg.get("tool_calls"):
694
+ tool_call_ids.update(
695
+ tc.get("id") for tc in msg.get("tool_calls", []) if tc.get("id")
696
+ )
697
+
698
+ # Anthropic format: content blocks with type=tool_use
699
+ content = msg.get("content")
700
+ if isinstance(content, list):
701
+ for block in content:
702
+ if isinstance(block, dict) and block.get("type") == "tool_use":
703
+ tc_id = block.get("id")
704
+ if tc_id:
705
+ tool_call_ids.add(tc_id)
706
+
707
+ # Find and protect corresponding tool responses
708
+ if tool_call_ids:
709
+ for j, other_msg in enumerate(messages):
710
+ # OpenAI format: role="tool"
711
+ if other_msg.get("role") == "tool":
712
+ if other_msg.get("tool_call_id") in tool_call_ids:
713
+ protected.add(j)
714
+
715
+ # Anthropic format: role="user" with tool_result blocks
716
+ if other_msg.get("role") == "user":
717
+ other_content = other_msg.get("content")
718
+ if isinstance(other_content, list):
719
+ for block in other_content:
720
+ if (
721
+ isinstance(block, dict)
722
+ and block.get("type") == "tool_result"
723
+ and block.get("tool_use_id") in tool_call_ids
724
+ ):
725
+ protected.add(j)
726
+ break
727
 
728
  return protected
729
 
tests/test_transforms/test_intelligent_context.py CHANGED
@@ -1775,3 +1775,374 @@ class TestSummarizeEdgeCases:
1775
  assert result.messages is not None
1776
  # No summaries created due to exception
1777
  assert len(result.summaries_created) == 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1775
  assert result.messages is not None
1776
  # No summaries created due to exception
1777
  assert len(result.summaries_created) == 0
1778
+
1779
+
1780
+ # =============================================================================
1781
+ # Test Anthropic Format Tool Protection
1782
+ # =============================================================================
1783
+
1784
+
1785
+ class TestAnthropicFormatToolProtection:
1786
+ """Tests for Anthropic format tool_use/tool_result protection.
1787
+
1788
+ These tests verify that IntelligentContextManager correctly handles
1789
+ Anthropic's native format where:
1790
+ - tool_use blocks appear in assistant.content[]
1791
+ - tool_result blocks appear in user.content[]
1792
+
1793
+ This is critical for Claude Code integration.
1794
+ """
1795
+
1796
+ @pytest.fixture
1797
+ def anthropic_tool_conversation(self) -> list[dict[str, Any]]:
1798
+ """Conversation with Anthropic format tool_use/tool_result."""
1799
+ return [
1800
+ {"role": "system", "content": "You are a helpful assistant."},
1801
+ {"role": "user", "content": "Take a screenshot of the page."},
1802
+ {
1803
+ "role": "assistant",
1804
+ "content": [
1805
+ {"type": "text", "text": "I'll take a screenshot for you."},
1806
+ {
1807
+ "type": "tool_use",
1808
+ "id": "toolu_screenshot_1",
1809
+ "name": "browser_screenshot",
1810
+ "input": {},
1811
+ },
1812
+ ],
1813
+ },
1814
+ {
1815
+ "role": "user",
1816
+ "content": [
1817
+ {
1818
+ "type": "tool_result",
1819
+ "tool_use_id": "toolu_screenshot_1",
1820
+ "content": "Screenshot captured successfully: [base64 image data]",
1821
+ }
1822
+ ],
1823
+ },
1824
+ {
1825
+ "role": "assistant",
1826
+ "content": "I've captured the screenshot. The page shows a login form.",
1827
+ },
1828
+ {"role": "user", "content": "Now click the submit button."},
1829
+ {
1830
+ "role": "assistant",
1831
+ "content": [
1832
+ {"type": "text", "text": "Clicking the submit button."},
1833
+ {
1834
+ "type": "tool_use",
1835
+ "id": "toolu_click_1",
1836
+ "name": "browser_click",
1837
+ "input": {"selector": "#submit"},
1838
+ },
1839
+ ],
1840
+ },
1841
+ {
1842
+ "role": "user",
1843
+ "content": [
1844
+ {
1845
+ "type": "tool_result",
1846
+ "tool_use_id": "toolu_click_1",
1847
+ "content": "Clicked element #submit",
1848
+ }
1849
+ ],
1850
+ },
1851
+ {"role": "assistant", "content": "Done! The form has been submitted."},
1852
+ {"role": "user", "content": "Thanks!"},
1853
+ ]
1854
+
1855
+ @pytest.fixture
1856
+ def anthropic_multiple_tools_same_message(self) -> list[dict[str, Any]]:
1857
+ """Multiple Anthropic tool_use blocks in same assistant message."""
1858
+ return [
1859
+ {"role": "system", "content": "You are a code assistant."},
1860
+ {"role": "user", "content": "Read both config files."},
1861
+ {
1862
+ "role": "assistant",
1863
+ "content": [
1864
+ {"type": "text", "text": "I'll read both files."},
1865
+ {
1866
+ "type": "tool_use",
1867
+ "id": "toolu_read_1",
1868
+ "name": "Read",
1869
+ "input": {"file_path": "/etc/config1.json"},
1870
+ },
1871
+ {
1872
+ "type": "tool_use",
1873
+ "id": "toolu_read_2",
1874
+ "name": "Read",
1875
+ "input": {"file_path": "/etc/config2.json"},
1876
+ },
1877
+ ],
1878
+ },
1879
+ {
1880
+ "role": "user",
1881
+ "content": [
1882
+ {
1883
+ "type": "tool_result",
1884
+ "tool_use_id": "toolu_read_1",
1885
+ "content": '{"setting1": "value1"}',
1886
+ },
1887
+ {
1888
+ "type": "tool_result",
1889
+ "tool_use_id": "toolu_read_2",
1890
+ "content": '{"setting2": "value2"}',
1891
+ },
1892
+ ],
1893
+ },
1894
+ {"role": "assistant", "content": "Both config files have been read."},
1895
+ {"role": "user", "content": "Great, thanks!"},
1896
+ ]
1897
+
1898
+ def test_anthropic_tool_result_protected_when_tool_use_protected(
1899
+ self,
1900
+ anthropic_tool_conversation: list[dict[str, Any]],
1901
+ tokenizer: Tokenizer,
1902
+ ):
1903
+ """Tool_result user messages should be protected when their tool_use is protected."""
1904
+ config = IntelligentContextConfig(keep_last_turns=2)
1905
+ manager = IntelligentContextManager(config=config)
1906
+
1907
+ protected = manager._get_protected_indices(anthropic_tool_conversation)
1908
+
1909
+ # The last tool_use is at index 6, tool_result at index 7
1910
+ # With keep_last_turns=2, indices 6-9 should be protected
1911
+ # The tool_result at 7 should be protected
1912
+
1913
+ # Check that if assistant with tool_use is protected, its tool_result is too
1914
+ for i in protected:
1915
+ msg = anthropic_tool_conversation[i]
1916
+ if msg.get("role") == "assistant":
1917
+ content = msg.get("content")
1918
+ if isinstance(content, list):
1919
+ tool_use_ids = set()
1920
+ for block in content:
1921
+ if isinstance(block, dict) and block.get("type") == "tool_use":
1922
+ tool_use_ids.add(block.get("id"))
1923
+
1924
+ # Find the corresponding tool_result message
1925
+ if tool_use_ids:
1926
+ for j, other_msg in enumerate(anthropic_tool_conversation):
1927
+ if other_msg.get("role") == "user":
1928
+ other_content = other_msg.get("content")
1929
+ if isinstance(other_content, list):
1930
+ for block in other_content:
1931
+ if (
1932
+ isinstance(block, dict)
1933
+ and block.get("type") == "tool_result"
1934
+ and block.get("tool_use_id") in tool_use_ids
1935
+ ):
1936
+ assert j in protected, (
1937
+ f"Tool_result at {j} should be protected "
1938
+ f"because tool_use at {i} is protected"
1939
+ )
1940
+
1941
+ def test_anthropic_tool_units_dropped_atomically(
1942
+ self,
1943
+ anthropic_tool_conversation: list[dict[str, Any]],
1944
+ tokenizer: Tokenizer,
1945
+ ):
1946
+ """Anthropic tool_use and tool_result should be dropped together."""
1947
+ config = IntelligentContextConfig(keep_last_turns=1)
1948
+ manager = IntelligentContextManager(config=config)
1949
+
1950
+ # Force dropping by using small limit
1951
+ result = manager.apply(
1952
+ anthropic_tool_conversation,
1953
+ tokenizer,
1954
+ model_limit=300,
1955
+ output_buffer=50,
1956
+ )
1957
+
1958
+ # Verify no orphaned tool_results
1959
+ tool_use_ids_present = set()
1960
+ tool_result_ids_present = set()
1961
+
1962
+ for msg in result.messages:
1963
+ content = msg.get("content")
1964
+ if isinstance(content, list):
1965
+ for block in content:
1966
+ if isinstance(block, dict):
1967
+ if block.get("type") == "tool_use":
1968
+ tool_use_ids_present.add(block.get("id"))
1969
+ elif block.get("type") == "tool_result":
1970
+ tool_result_ids_present.add(block.get("tool_use_id"))
1971
+
1972
+ # Every tool_result should have its tool_use present
1973
+ for result_id in tool_result_ids_present:
1974
+ assert result_id in tool_use_ids_present, (
1975
+ f"Orphaned tool_result with tool_use_id={result_id}"
1976
+ )
1977
+
1978
+ def test_anthropic_multiple_tools_same_message_atomic(
1979
+ self,
1980
+ anthropic_multiple_tools_same_message: list[dict[str, Any]],
1981
+ tokenizer: Tokenizer,
1982
+ ):
1983
+ """Multiple tool_use blocks in same message should be handled atomically."""
1984
+ config = IntelligentContextConfig(keep_last_turns=1)
1985
+ manager = IntelligentContextManager(config=config)
1986
+
1987
+ result = manager.apply(
1988
+ anthropic_multiple_tools_same_message,
1989
+ tokenizer,
1990
+ model_limit=200,
1991
+ output_buffer=50,
1992
+ )
1993
+
1994
+ # Check atomicity
1995
+ tool_use_ids = set()
1996
+ tool_result_ids = set()
1997
+
1998
+ for msg in result.messages:
1999
+ content = msg.get("content")
2000
+ if isinstance(content, list):
2001
+ for block in content:
2002
+ if isinstance(block, dict):
2003
+ if block.get("type") == "tool_use":
2004
+ tool_use_ids.add(block.get("id"))
2005
+ elif block.get("type") == "tool_result":
2006
+ tool_result_ids.add(block.get("tool_use_id"))
2007
+
2008
+ # All tool_results should have their tool_use
2009
+ for result_id in tool_result_ids:
2010
+ assert result_id in tool_use_ids, f"Orphaned tool_result: {result_id}"
2011
+
2012
+ def test_anthropic_format_no_api_error_scenario(
2013
+ self,
2014
+ anthropic_tool_conversation: list[dict[str, Any]],
2015
+ tokenizer: Tokenizer,
2016
+ ):
2017
+ """Verify the specific scenario that causes 'unexpected tool_use_id' error is fixed."""
2018
+ # This test specifically verifies the bug fix for:
2019
+ # "unexpected tool_use_id found in tool_result blocks"
2020
+
2021
+ config = IntelligentContextConfig(
2022
+ keep_last_turns=1,
2023
+ keep_system=True,
2024
+ )
2025
+ manager = IntelligentContextManager(config=config)
2026
+
2027
+ # Use a limit that would cause dropping
2028
+ result = manager.apply(
2029
+ anthropic_tool_conversation,
2030
+ tokenizer,
2031
+ model_limit=250,
2032
+ output_buffer=50,
2033
+ )
2034
+
2035
+ # Simulate what the API would check
2036
+ tool_use_ids_in_conversation = set()
2037
+ tool_result_ids_in_conversation = set()
2038
+
2039
+ for msg in result.messages:
2040
+ content = msg.get("content")
2041
+ if isinstance(content, list):
2042
+ for block in content:
2043
+ if isinstance(block, dict):
2044
+ if block.get("type") == "tool_use":
2045
+ tool_use_ids_in_conversation.add(block.get("id"))
2046
+ elif block.get("type") == "tool_result":
2047
+ tool_result_ids_in_conversation.add(block.get("tool_use_id"))
2048
+
2049
+ # API error condition: tool_result references a tool_use_id that doesn't exist
2050
+ orphaned_results = tool_result_ids_in_conversation - tool_use_ids_in_conversation
2051
+
2052
+ assert len(orphaned_results) == 0, (
2053
+ f"Would cause API error! Orphaned tool_result ids: {orphaned_results}"
2054
+ )
2055
+
2056
+ def test_mixed_openai_and_anthropic_formats(
2057
+ self,
2058
+ tokenizer: Tokenizer,
2059
+ ):
2060
+ """Both OpenAI and Anthropic formats should work together."""
2061
+ messages = [
2062
+ {"role": "system", "content": "You are helpful."},
2063
+ {"role": "user", "content": "Do things."},
2064
+ # OpenAI format tool call
2065
+ {
2066
+ "role": "assistant",
2067
+ "tool_calls": [
2068
+ {
2069
+ "id": "call_openai_1",
2070
+ "type": "function",
2071
+ "function": {"name": "openai_tool", "arguments": "{}"},
2072
+ }
2073
+ ],
2074
+ },
2075
+ {
2076
+ "role": "tool",
2077
+ "tool_call_id": "call_openai_1",
2078
+ "content": "OpenAI tool result",
2079
+ },
2080
+ {"role": "assistant", "content": "OpenAI tool done."},
2081
+ {"role": "user", "content": "Now use Anthropic format."},
2082
+ # Anthropic format tool call
2083
+ {
2084
+ "role": "assistant",
2085
+ "content": [
2086
+ {
2087
+ "type": "tool_use",
2088
+ "id": "toolu_anthropic_1",
2089
+ "name": "anthropic_tool",
2090
+ "input": {},
2091
+ }
2092
+ ],
2093
+ },
2094
+ {
2095
+ "role": "user",
2096
+ "content": [
2097
+ {
2098
+ "type": "tool_result",
2099
+ "tool_use_id": "toolu_anthropic_1",
2100
+ "content": "Anthropic tool result",
2101
+ }
2102
+ ],
2103
+ },
2104
+ {"role": "assistant", "content": "All done!"},
2105
+ {"role": "user", "content": "Thanks!"},
2106
+ ]
2107
+
2108
+ config = IntelligentContextConfig(keep_last_turns=1)
2109
+ manager = IntelligentContextManager(config=config)
2110
+
2111
+ result = manager.apply(
2112
+ messages,
2113
+ tokenizer,
2114
+ model_limit=200,
2115
+ output_buffer=50,
2116
+ )
2117
+
2118
+ # Verify no orphaned tools of either format
2119
+ openai_call_ids = set()
2120
+ openai_result_ids = set()
2121
+ anthropic_use_ids = set()
2122
+ anthropic_result_ids = set()
2123
+
2124
+ for msg in result.messages:
2125
+ # OpenAI format
2126
+ if msg.get("tool_calls"):
2127
+ for tc in msg["tool_calls"]:
2128
+ openai_call_ids.add(tc.get("id"))
2129
+ if msg.get("role") == "tool":
2130
+ openai_result_ids.add(msg.get("tool_call_id"))
2131
+
2132
+ # Anthropic format
2133
+ content = msg.get("content")
2134
+ if isinstance(content, list):
2135
+ for block in content:
2136
+ if isinstance(block, dict):
2137
+ if block.get("type") == "tool_use":
2138
+ anthropic_use_ids.add(block.get("id"))
2139
+ elif block.get("type") == "tool_result":
2140
+ anthropic_result_ids.add(block.get("tool_use_id"))
2141
+
2142
+ # Check OpenAI format
2143
+ for result_id in openai_result_ids:
2144
+ assert result_id in openai_call_ids, f"Orphaned OpenAI tool: {result_id}"
2145
+
2146
+ # Check Anthropic format
2147
+ for result_id in anthropic_result_ids:
2148
+ assert result_id in anthropic_use_ids, f"Orphaned Anthropic tool: {result_id}"
tests/test_transforms/test_rolling_window.py CHANGED
@@ -1,11 +1,20 @@
1
  """Tests for rolling window transform."""
2
 
 
 
 
3
  import pytest
4
 
5
  from headroom import OpenAIProvider, RollingWindowConfig, Tokenizer
6
  from headroom.parser import find_tool_units
7
  from headroom.transforms import RollingWindow
8
 
 
 
 
 
 
 
9
  # Create a shared provider for tests
10
  _provider = OpenAIProvider()
11
 
@@ -846,3 +855,364 @@ class TestTransformResult:
846
 
847
  # warnings should be a list (possibly empty)
848
  assert isinstance(result.warnings, list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Tests for rolling window transform."""
2
 
3
+ import os
4
+ from typing import Any
5
+
6
  import pytest
7
 
8
  from headroom import OpenAIProvider, RollingWindowConfig, Tokenizer
9
  from headroom.parser import find_tool_units
10
  from headroom.transforms import RollingWindow
11
 
12
+ # Skip all tests in this module if OPENAI_API_KEY is not set
13
+ pytestmark = pytest.mark.skipif(
14
+ not os.environ.get("OPENAI_API_KEY"),
15
+ reason="OPENAI_API_KEY not set - skipping tests that require API access",
16
+ )
17
+
18
  # Create a shared provider for tests
19
  _provider = OpenAIProvider()
20
 
 
855
 
856
  # warnings should be a list (possibly empty)
857
  assert isinstance(result.warnings, list)
858
+
859
+
860
+ # =============================================================================
861
+ # Test Anthropic Format Tool Protection
862
+ # =============================================================================
863
+
864
+
865
+ class TestAnthropicFormatToolProtection:
866
+ """Tests for Anthropic format tool_use/tool_result protection in RollingWindow.
867
+
868
+ These tests verify that RollingWindow correctly handles Anthropic's native format
869
+ where:
870
+ - tool_use blocks appear in assistant.content[]
871
+ - tool_result blocks appear in user.content[]
872
+
873
+ This is critical for Claude Code integration.
874
+ """
875
+
876
+ @pytest.fixture
877
+ def anthropic_tool_conversation(self) -> list[dict[str, Any]]:
878
+ """Conversation with Anthropic format tool_use/tool_result."""
879
+ return [
880
+ {"role": "system", "content": "You are a helpful assistant."},
881
+ {"role": "user", "content": "Take a screenshot of the page."},
882
+ {
883
+ "role": "assistant",
884
+ "content": [
885
+ {"type": "text", "text": "I'll take a screenshot for you."},
886
+ {
887
+ "type": "tool_use",
888
+ "id": "toolu_screenshot_1",
889
+ "name": "browser_screenshot",
890
+ "input": {},
891
+ },
892
+ ],
893
+ },
894
+ {
895
+ "role": "user",
896
+ "content": [
897
+ {
898
+ "type": "tool_result",
899
+ "tool_use_id": "toolu_screenshot_1",
900
+ "content": "Screenshot captured successfully: [base64 image data]",
901
+ }
902
+ ],
903
+ },
904
+ {
905
+ "role": "assistant",
906
+ "content": "I've captured the screenshot. The page shows a login form.",
907
+ },
908
+ {"role": "user", "content": "Now click the submit button."},
909
+ {
910
+ "role": "assistant",
911
+ "content": [
912
+ {"type": "text", "text": "Clicking the submit button."},
913
+ {
914
+ "type": "tool_use",
915
+ "id": "toolu_click_1",
916
+ "name": "browser_click",
917
+ "input": {"selector": "#submit"},
918
+ },
919
+ ],
920
+ },
921
+ {
922
+ "role": "user",
923
+ "content": [
924
+ {
925
+ "type": "tool_result",
926
+ "tool_use_id": "toolu_click_1",
927
+ "content": "Clicked element #submit",
928
+ }
929
+ ],
930
+ },
931
+ {"role": "assistant", "content": "Done! The form has been submitted."},
932
+ {"role": "user", "content": "Thanks!"},
933
+ ]
934
+
935
+ @pytest.fixture
936
+ def anthropic_multiple_tools_same_message(self) -> list[dict[str, Any]]:
937
+ """Multiple Anthropic tool_use blocks in same assistant message."""
938
+ return [
939
+ {"role": "system", "content": "You are a code assistant."},
940
+ {"role": "user", "content": "Read both config files."},
941
+ {
942
+ "role": "assistant",
943
+ "content": [
944
+ {"type": "text", "text": "I'll read both files."},
945
+ {
946
+ "type": "tool_use",
947
+ "id": "toolu_read_1",
948
+ "name": "Read",
949
+ "input": {"file_path": "/etc/config1.json"},
950
+ },
951
+ {
952
+ "type": "tool_use",
953
+ "id": "toolu_read_2",
954
+ "name": "Read",
955
+ "input": {"file_path": "/etc/config2.json"},
956
+ },
957
+ ],
958
+ },
959
+ {
960
+ "role": "user",
961
+ "content": [
962
+ {
963
+ "type": "tool_result",
964
+ "tool_use_id": "toolu_read_1",
965
+ "content": '{"setting1": "value1"}',
966
+ },
967
+ {
968
+ "type": "tool_result",
969
+ "tool_use_id": "toolu_read_2",
970
+ "content": '{"setting2": "value2"}',
971
+ },
972
+ ],
973
+ },
974
+ {"role": "assistant", "content": "Both config files have been read."},
975
+ {"role": "user", "content": "Great, thanks!"},
976
+ ]
977
+
978
+ def test_anthropic_tool_result_protected_when_tool_use_protected(
979
+ self,
980
+ anthropic_tool_conversation: list[dict[str, Any]],
981
+ ):
982
+ """Tool_result user messages should be protected when their tool_use is protected."""
983
+ config = RollingWindowConfig(keep_last_turns=2)
984
+ window = RollingWindow(config)
985
+
986
+ protected = window._get_protected_indices(anthropic_tool_conversation)
987
+
988
+ # Check that if assistant with tool_use is protected, its tool_result is too
989
+ for i in protected:
990
+ msg = anthropic_tool_conversation[i]
991
+ if msg.get("role") == "assistant":
992
+ content = msg.get("content")
993
+ if isinstance(content, list):
994
+ tool_use_ids = set()
995
+ for block in content:
996
+ if isinstance(block, dict) and block.get("type") == "tool_use":
997
+ tool_use_ids.add(block.get("id"))
998
+
999
+ # Find the corresponding tool_result message
1000
+ if tool_use_ids:
1001
+ for j, other_msg in enumerate(anthropic_tool_conversation):
1002
+ if other_msg.get("role") == "user":
1003
+ other_content = other_msg.get("content")
1004
+ if isinstance(other_content, list):
1005
+ for block in other_content:
1006
+ if (
1007
+ isinstance(block, dict)
1008
+ and block.get("type") == "tool_result"
1009
+ and block.get("tool_use_id") in tool_use_ids
1010
+ ):
1011
+ assert j in protected, (
1012
+ f"Tool_result at {j} should be protected "
1013
+ f"because tool_use at {i} is protected"
1014
+ )
1015
+
1016
+ def test_anthropic_tool_units_dropped_atomically(
1017
+ self,
1018
+ anthropic_tool_conversation: list[dict[str, Any]],
1019
+ ):
1020
+ """Anthropic tool_use and tool_result should be dropped together."""
1021
+ config = RollingWindowConfig(keep_last_turns=1)
1022
+ window = RollingWindow(config)
1023
+ tokenizer = get_tokenizer()
1024
+
1025
+ # Force dropping by using small limit
1026
+ result = window.apply(
1027
+ anthropic_tool_conversation,
1028
+ tokenizer,
1029
+ model_limit=300,
1030
+ output_buffer=50,
1031
+ )
1032
+
1033
+ # Verify no orphaned tool_results
1034
+ tool_use_ids_present = set()
1035
+ tool_result_ids_present = set()
1036
+
1037
+ for msg in result.messages:
1038
+ content = msg.get("content")
1039
+ if isinstance(content, list):
1040
+ for block in content:
1041
+ if isinstance(block, dict):
1042
+ if block.get("type") == "tool_use":
1043
+ tool_use_ids_present.add(block.get("id"))
1044
+ elif block.get("type") == "tool_result":
1045
+ tool_result_ids_present.add(block.get("tool_use_id"))
1046
+
1047
+ # Every tool_result should have its tool_use present
1048
+ for result_id in tool_result_ids_present:
1049
+ assert result_id in tool_use_ids_present, (
1050
+ f"Orphaned tool_result with tool_use_id={result_id}"
1051
+ )
1052
+
1053
+ def test_anthropic_multiple_tools_same_message_atomic(
1054
+ self,
1055
+ anthropic_multiple_tools_same_message: list[dict[str, Any]],
1056
+ ):
1057
+ """Multiple tool_use blocks in same message should be handled atomically."""
1058
+ config = RollingWindowConfig(keep_last_turns=1)
1059
+ window = RollingWindow(config)
1060
+ tokenizer = get_tokenizer()
1061
+
1062
+ result = window.apply(
1063
+ anthropic_multiple_tools_same_message,
1064
+ tokenizer,
1065
+ model_limit=200,
1066
+ output_buffer=50,
1067
+ )
1068
+
1069
+ # Check atomicity
1070
+ tool_use_ids = set()
1071
+ tool_result_ids = set()
1072
+
1073
+ for msg in result.messages:
1074
+ content = msg.get("content")
1075
+ if isinstance(content, list):
1076
+ for block in content:
1077
+ if isinstance(block, dict):
1078
+ if block.get("type") == "tool_use":
1079
+ tool_use_ids.add(block.get("id"))
1080
+ elif block.get("type") == "tool_result":
1081
+ tool_result_ids.add(block.get("tool_use_id"))
1082
+
1083
+ # All tool_results should have their tool_use
1084
+ for result_id in tool_result_ids:
1085
+ assert result_id in tool_use_ids, f"Orphaned tool_result: {result_id}"
1086
+
1087
+ def test_anthropic_format_no_api_error_scenario(
1088
+ self,
1089
+ anthropic_tool_conversation: list[dict[str, Any]],
1090
+ ):
1091
+ """Verify the specific scenario that causes 'unexpected tool_use_id' error is fixed."""
1092
+ config = RollingWindowConfig(
1093
+ keep_last_turns=1,
1094
+ keep_system=True,
1095
+ )
1096
+ window = RollingWindow(config)
1097
+ tokenizer = get_tokenizer()
1098
+
1099
+ # Use a limit that would cause dropping
1100
+ result = window.apply(
1101
+ anthropic_tool_conversation,
1102
+ tokenizer,
1103
+ model_limit=250,
1104
+ output_buffer=50,
1105
+ )
1106
+
1107
+ # Simulate what the API would check
1108
+ tool_use_ids_in_conversation = set()
1109
+ tool_result_ids_in_conversation = set()
1110
+
1111
+ for msg in result.messages:
1112
+ content = msg.get("content")
1113
+ if isinstance(content, list):
1114
+ for block in content:
1115
+ if isinstance(block, dict):
1116
+ if block.get("type") == "tool_use":
1117
+ tool_use_ids_in_conversation.add(block.get("id"))
1118
+ elif block.get("type") == "tool_result":
1119
+ tool_result_ids_in_conversation.add(block.get("tool_use_id"))
1120
+
1121
+ # API error condition: tool_result references a tool_use_id that doesn't exist
1122
+ orphaned_results = tool_result_ids_in_conversation - tool_use_ids_in_conversation
1123
+
1124
+ assert len(orphaned_results) == 0, (
1125
+ f"Would cause API error! Orphaned tool_result ids: {orphaned_results}"
1126
+ )
1127
+
1128
+ def test_mixed_openai_and_anthropic_formats(self):
1129
+ """Both OpenAI and Anthropic formats should work together."""
1130
+ messages = [
1131
+ {"role": "system", "content": "You are helpful."},
1132
+ {"role": "user", "content": "Do things."},
1133
+ # OpenAI format tool call
1134
+ {
1135
+ "role": "assistant",
1136
+ "tool_calls": [
1137
+ {
1138
+ "id": "call_openai_1",
1139
+ "type": "function",
1140
+ "function": {"name": "openai_tool", "arguments": "{}"},
1141
+ }
1142
+ ],
1143
+ },
1144
+ {
1145
+ "role": "tool",
1146
+ "tool_call_id": "call_openai_1",
1147
+ "content": "OpenAI tool result",
1148
+ },
1149
+ {"role": "assistant", "content": "OpenAI tool done."},
1150
+ {"role": "user", "content": "Now use Anthropic format."},
1151
+ # Anthropic format tool call
1152
+ {
1153
+ "role": "assistant",
1154
+ "content": [
1155
+ {
1156
+ "type": "tool_use",
1157
+ "id": "toolu_anthropic_1",
1158
+ "name": "anthropic_tool",
1159
+ "input": {},
1160
+ }
1161
+ ],
1162
+ },
1163
+ {
1164
+ "role": "user",
1165
+ "content": [
1166
+ {
1167
+ "type": "tool_result",
1168
+ "tool_use_id": "toolu_anthropic_1",
1169
+ "content": "Anthropic tool result",
1170
+ }
1171
+ ],
1172
+ },
1173
+ {"role": "assistant", "content": "All done!"},
1174
+ {"role": "user", "content": "Thanks!"},
1175
+ ]
1176
+
1177
+ config = RollingWindowConfig(keep_last_turns=1)
1178
+ window = RollingWindow(config)
1179
+ tokenizer = get_tokenizer()
1180
+
1181
+ result = window.apply(
1182
+ messages,
1183
+ tokenizer,
1184
+ model_limit=200,
1185
+ output_buffer=50,
1186
+ )
1187
+
1188
+ # Verify no orphaned tools of either format
1189
+ openai_call_ids = set()
1190
+ openai_result_ids = set()
1191
+ anthropic_use_ids = set()
1192
+ anthropic_result_ids = set()
1193
+
1194
+ for msg in result.messages:
1195
+ # OpenAI format
1196
+ if msg.get("tool_calls"):
1197
+ for tc in msg["tool_calls"]:
1198
+ openai_call_ids.add(tc.get("id"))
1199
+ if msg.get("role") == "tool":
1200
+ openai_result_ids.add(msg.get("tool_call_id"))
1201
+
1202
+ # Anthropic format
1203
+ content = msg.get("content")
1204
+ if isinstance(content, list):
1205
+ for block in content:
1206
+ if isinstance(block, dict):
1207
+ if block.get("type") == "tool_use":
1208
+ anthropic_use_ids.add(block.get("id"))
1209
+ elif block.get("type") == "tool_result":
1210
+ anthropic_result_ids.add(block.get("tool_use_id"))
1211
+
1212
+ # Check OpenAI format
1213
+ for result_id in openai_result_ids:
1214
+ assert result_id in openai_call_ids, f"Orphaned OpenAI tool: {result_id}"
1215
+
1216
+ # Check Anthropic format
1217
+ for result_id in anthropic_result_ids:
1218
+ assert result_id in anthropic_use_ids, f"Orphaned Anthropic tool: {result_id}"