Skip to content

Commit e6f0370

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 9b5ab24 commit e6f0370

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
@@ -108,7 +108,7 @@ def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[Respon
108108
for message in messages
109109
]
110110
self._messages_modified = True
111-
self.set_messages_params(lambda params: {**params, "messages": [*self._params["messages"], *message_params]})
111+
self.set_messages_params(lambda params: {**params, "messages": [*messages, *message_params]})
112112
self._cached_tool_call_response = None
113113

114114
def _should_stop(self) -> bool:
@@ -451,7 +451,7 @@ async def _check_and_compact(self) -> bool:
451451
messages.pop()
452452

453453
messages = [
454-
*self._params["messages"],
454+
*messages,
455455
BetaMessageParam(
456456
role="user",
457457
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
@@ -581,6 +581,80 @@ async def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionTo
581581
assert print_obj(message, monkeypatch) == snapshots["basic"]["result"]
582582

583583

584+
@pytest.mark.skipif(PYDANTIC_V1, reason="tool runner not supported with pydantic v1")
585+
async def test_async_compaction_filters_tool_use(async_client: AsyncAnthropic) -> None:
586+
"""Test that async compaction correctly filters out tool_use blocks.
587+
588+
When compaction runs and the last message is an assistant message with only
589+
tool_use blocks (no text), the filtering should remove it to avoid API errors
590+
about tool_use without corresponding tool_result.
591+
592+
This is a regression test for a bug where the async version used
593+
self._params["messages"] instead of the filtered local `messages` variable.
594+
"""
595+
from unittest.mock import AsyncMock, MagicMock
596+
597+
runner = async_client.beta.messages.tool_runner(
598+
model="claude-sonnet-4-20250514",
599+
max_tokens=500,
600+
tools=[],
601+
messages=[{"role": "user", "content": "test"}],
602+
compaction_control={
603+
"enabled": True,
604+
"context_token_threshold": 100,
605+
},
606+
)
607+
608+
# Set up messages ending with assistant containing ONLY tool_use (no text)
609+
runner._params["messages"] = [
610+
{"role": "user", "content": "What is 2+2?"},
611+
{
612+
"role": "assistant",
613+
"content": [
614+
{
615+
"type": "tool_use",
616+
"id": "toolu_test123",
617+
"name": "calculator",
618+
"input": {"a": 2, "b": 2}
619+
}
620+
]
621+
},
622+
]
623+
624+
# Mock _get_last_message to return high token usage to trigger compaction
625+
mock_message = MagicMock()
626+
mock_message.usage.input_tokens = 500
627+
mock_message.usage.output_tokens = 100
628+
mock_message.usage.cache_creation_input_tokens = 0
629+
mock_message.usage.cache_read_input_tokens = 0
630+
runner._get_last_message = AsyncMock(return_value=mock_message)
631+
632+
# Mock the API call for compaction summary
633+
mock_response = MagicMock()
634+
mock_response.content = [MagicMock(type="text", text="Summary of conversation")]
635+
mock_response.usage.output_tokens = 50
636+
runner._client.beta.messages.create = AsyncMock(return_value=mock_response)
637+
638+
# This should succeed - the tool_use should be filtered out
639+
# Before the fix, this would send tool_use without tool_result and fail
640+
result = await runner._check_and_compact()
641+
642+
assert result is True, "Compaction should have run"
643+
644+
# Verify the API was called (compaction happened)
645+
runner._client.beta.messages.create.assert_called_once()
646+
647+
# Get the messages that were sent to the API
648+
call_kwargs = runner._client.beta.messages.create.call_args[1]
649+
sent_messages = call_kwargs["messages"]
650+
651+
# The tool_use-only assistant message should have been removed
652+
# So we should have: [user_message, summary_prompt]
653+
assert len(sent_messages) == 2, f"Expected 2 messages, got {len(sent_messages)}"
654+
assert sent_messages[0]["role"] == "user"
655+
assert sent_messages[1]["role"] == "user" # Summary prompt is a user message
656+
657+
584658
def _get_weather(location: str, units: Literal["c", "f"]) -> Dict[str, Any]:
585659
# Simulate a weather API call
586660
print(f"Fetching weather for {location} in {units}")

0 commit comments

Comments
 (0)