Skip to content

Commit 3f17643

Browse files
committed
fix(tools): use filtered messages list in async compaction
The async _check_and_compact() method was using self._params["messages"] instead of the local `messages` variable when building the compaction request. This caused the filtering logic (which removes tool_use blocks from the last assistant message) to be ignored. When compaction runs and the last message is an assistant with only tool_use blocks, those blocks should be filtered out before sending the summarization request. Without this fix, the API rejects with: "tool_use ids were found without tool_result blocks" The sync version correctly uses `*messages`, the async version was incorrectly using `*self._params["messages"]`. Added regression test that verifies tool_use filtering works correctly.
1 parent 49d639a commit 3f17643

2 files changed

Lines changed: 76 additions & 2 deletions

File tree

src/anthropic/lib/tools/_beta_runner.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[Respon
117117
for message in messages
118118
]
119119
self._messages_modified = True
120-
self.set_messages_params(lambda params: {**params, "messages": [*self._params["messages"], *message_params]})
120+
self.set_messages_params(lambda params: {**params, "messages": [*messages, *message_params]})
121121
self._cached_tool_call_response = None
122122

123123
def _should_stop(self) -> bool:
@@ -469,7 +469,7 @@ async def _check_and_compact(self) -> bool:
469469
messages.pop()
470470

471471
messages = [
472-
*self._params["messages"],
472+
*messages,
473473
BetaMessageParam(
474474
role="user",
475475
content=self._compaction_control.get("summary_prompt", DEFAULT_SUMMARY_PROMPT),

tests/lib/tools/test_runners.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,80 @@ async def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionTo
623623
).until_done()
624624

625625

626+
@pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1")
627+
async def test_async_compaction_filters_tool_use(async_client: AsyncAnthropic) -> None:
628+
"""Test that async compaction correctly filters out tool_use blocks.
629+
630+
When compaction runs and the last message is an assistant message with only
631+
tool_use blocks (no text), the filtering should remove it to avoid API errors
632+
about tool_use without corresponding tool_result.
633+
634+
This is a regression test for a bug where the async version used
635+
self._params["messages"] instead of the filtered local `messages` variable.
636+
"""
637+
from unittest.mock import AsyncMock, MagicMock
638+
639+
runner = async_client.beta.messages.tool_runner(
640+
model="claude-sonnet-4-20250514",
641+
max_tokens=500,
642+
tools=[],
643+
messages=[{"role": "user", "content": "test"}],
644+
compaction_control={
645+
"enabled": True,
646+
"context_token_threshold": 100,
647+
},
648+
)
649+
650+
# Set up messages ending with assistant containing ONLY tool_use (no text)
651+
runner._params["messages"] = [
652+
{"role": "user", "content": "What is 2+2?"},
653+
{
654+
"role": "assistant",
655+
"content": [
656+
{
657+
"type": "tool_use",
658+
"id": "toolu_test123",
659+
"name": "calculator",
660+
"input": {"a": 2, "b": 2}
661+
}
662+
]
663+
},
664+
]
665+
666+
# Mock _get_last_message to return high token usage to trigger compaction
667+
mock_message = MagicMock()
668+
mock_message.usage.input_tokens = 500
669+
mock_message.usage.output_tokens = 100
670+
mock_message.usage.cache_creation_input_tokens = 0
671+
mock_message.usage.cache_read_input_tokens = 0
672+
runner._get_last_message = AsyncMock(return_value=mock_message)
673+
674+
# Mock the API call for compaction summary
675+
mock_response = MagicMock()
676+
mock_response.content = [MagicMock(type="text", text="Summary of conversation")]
677+
mock_response.usage.output_tokens = 50
678+
runner._client.beta.messages.create = AsyncMock(return_value=mock_response)
679+
680+
# This should succeed - the tool_use should be filtered out
681+
# Before the fix, this would send tool_use without tool_result and fail
682+
result = await runner._check_and_compact()
683+
684+
assert result is True, "Compaction should have run"
685+
686+
# Verify the API was called (compaction happened)
687+
runner._client.beta.messages.create.assert_called_once()
688+
689+
# Get the messages that were sent to the API
690+
call_kwargs = runner._client.beta.messages.create.call_args[1]
691+
sent_messages = call_kwargs["messages"]
692+
693+
# The tool_use-only assistant message should have been removed
694+
# So we should have: [user_message, summary_prompt]
695+
assert len(sent_messages) == 2, f"Expected 2 messages, got {len(sent_messages)}"
696+
assert sent_messages[0]["role"] == "user"
697+
assert sent_messages[1]["role"] == "user" # Summary prompt is a user message
698+
699+
626700
def _get_weather(location: str, units: Literal["c", "f"]) -> Dict[str, Any]:
627701
# Simulate a weather API call
628702
print(f"Fetching weather for {location} in {units}")

0 commit comments

Comments
 (0)