fix: detect anthropic preview tool-use blocks

This commit is contained in:
alex-clawd
2026-07-23 23:39:07 -07:00
parent b14d36bfe4
commit f21d995114
2 changed files with 309 additions and 66 deletions

View File

@@ -124,6 +124,33 @@ def _contains_file_id_reference(messages: list[dict[str, Any]]) -> bool:
return False
def _is_tool_use_block(block: Any) -> bool:
"""Return true for Anthropic tool-use blocks across SDK shapes.
New/preview Anthropic models can return content blocks whose concrete SDK
class is not one of the imported ``ToolUseBlock`` aliases. The stable part
of the API shape is the block type plus ``name`` and ``input`` fields, so use
duck typing instead of relying only on SDK classes.
"""
if isinstance(block, (ToolUseBlock, BetaToolUseBlock)):
return True
if isinstance(block, dict):
return block.get("type") == "tool_use" and "name" in block and "input" in block
return (
getattr(block, "type", None) == "tool_use"
and hasattr(block, "name")
and hasattr(block, "input")
)
def _tool_use_blocks(blocks: list[Any]) -> list[Any]:
return [block for block in blocks if _is_tool_use_block(block)]
def _tool_use_attr(block: Any, attr: str) -> Any:
return block.get(attr) if isinstance(block, dict) else getattr(block, attr)
class AnthropicThinkingConfig(BaseModel):
type: Literal["enabled", "disabled"]
budget_tokens: int | None = None
@@ -589,20 +616,41 @@ class AnthropicCompletion(BaseLLM):
Returns:
Dictionary with thinking block data including signature, or None if not a thinking block
"""
if content_block.type == "thinking":
block_type = (
content_block.get("type")
if isinstance(content_block, dict)
else getattr(content_block, "type", None)
)
if block_type == "thinking":
thinking = (
content_block.get("thinking")
if isinstance(content_block, dict)
else content_block.thinking
)
thinking_block = {
"type": "thinking",
"thinking": content_block.thinking,
"thinking": thinking,
}
if hasattr(content_block, "signature"):
thinking_block["signature"] = content_block.signature
signature = (
content_block.get("signature")
if isinstance(content_block, dict)
else getattr(content_block, "signature", None)
)
if signature:
thinking_block["signature"] = signature
return thinking_block
if content_block.type == "redacted_thinking":
if block_type == "redacted_thinking":
redacted_block = {"type": "redacted_thinking"}
if hasattr(content_block, "thinking"):
redacted_block["thinking"] = content_block.thinking
if hasattr(content_block, "signature"):
redacted_block["signature"] = content_block.signature
if isinstance(content_block, dict):
if "thinking" in content_block:
redacted_block["thinking"] = content_block["thinking"]
if "signature" in content_block:
redacted_block["signature"] = content_block["signature"]
else:
if hasattr(content_block, "thinking"):
redacted_block["thinking"] = content_block.thinking
if hasattr(content_block, "signature"):
redacted_block["signature"] = content_block.signature
return redacted_block
return None
@@ -944,10 +992,12 @@ class AnthropicCompletion(BaseLLM):
else:
for block in response.content:
if (
isinstance(block, (ToolUseBlock, BetaToolUseBlock))
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_attr(block, "name") == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_attr(block, "input")
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -962,11 +1012,7 @@ class AnthropicCompletion(BaseLLM):
# Check if Claude wants to use tools
if response.content:
tool_uses = [
block
for block in response.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(response.content))
if tool_uses:
# Without available_functions, return tool calls so the executor can
@@ -1093,11 +1139,13 @@ class AnthropicCompletion(BaseLLM):
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
if _is_tool_use_block(block):
block_index = event.index
tool_use_id = _tool_use_attr(block, "id")
tool_name = _tool_use_attr(block, "name")
current_tool_calls[block_index] = {
"id": block.id,
"name": block.name,
"id": tool_use_id,
"name": tool_name,
"arguments": "",
"index": block_index,
}
@@ -1106,9 +1154,9 @@ class AnthropicCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
tool_call={
"id": block.id,
"id": tool_use_id,
"function": {
"name": block.name,
"name": tool_name,
"arguments": "",
},
"type": "function",
@@ -1177,10 +1225,12 @@ class AnthropicCompletion(BaseLLM):
return structured_data
for block in final_message.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_attr(block, "name") == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_attr(block, "input")
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1194,11 +1244,7 @@ class AnthropicCompletion(BaseLLM):
return structured_data
if final_message.content:
tool_uses = [
block
for block in final_message.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(final_message.content))
if tool_uses:
if not available_functions:
@@ -1229,7 +1275,7 @@ class AnthropicCompletion(BaseLLM):
def _execute_tools_and_collect_results(
self,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[Any],
available_functions: dict[str, Any],
from_task: Any | None = None,
from_agent: Any | None = None,
@@ -1248,8 +1294,8 @@ class AnthropicCompletion(BaseLLM):
tool_results = []
for tool_use in tool_uses:
function_name = tool_use.name
function_args = tool_use.input
function_name = _tool_use_attr(tool_use, "name")
function_args = _tool_use_attr(tool_use, "input")
result = self._handle_tool_execution(
function_name=function_name,
@@ -1261,7 +1307,7 @@ class AnthropicCompletion(BaseLLM):
tool_result = {
"type": "tool_result",
"tool_use_id": tool_use.id,
"tool_use_id": _tool_use_attr(tool_use, "id"),
"content": str(result)
if result is not None
else "Tool execution completed",
@@ -1272,7 +1318,7 @@ class AnthropicCompletion(BaseLLM):
def _execute_first_tool(
self,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[Any],
available_functions: dict[str, Any],
from_task: Any | None = None,
from_agent: Any | None = None,
@@ -1293,8 +1339,8 @@ class AnthropicCompletion(BaseLLM):
The result of the first tool execution, or None if execution failed
"""
tool_use = tool_uses[0]
function_name = tool_use.name
function_args = cast(dict[str, Any], tool_use.input)
function_name = _tool_use_attr(tool_use, "name")
function_args = cast(dict[str, Any], _tool_use_attr(tool_use, "input"))
return self._handle_tool_execution(
function_name=function_name,
@@ -1308,7 +1354,7 @@ class AnthropicCompletion(BaseLLM):
def _handle_tool_use_conversation(
self,
initial_response: Message | BetaMessage,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[Any],
params: dict[str, Any],
available_functions: dict[str, Any],
from_task: Any | None = None,
@@ -1335,13 +1381,13 @@ class AnthropicCompletion(BaseLLM):
thinking_block = self._extract_thinking_block(block)
if thinking_block:
assistant_content.append(thinking_block)
elif block.type == "tool_use":
elif _is_tool_use_block(block):
assistant_content.append(
{
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
"id": _tool_use_attr(block, "id"),
"name": _tool_use_attr(block, "name"),
"input": _tool_use_attr(block, "input"),
}
)
elif hasattr(block, "text"):
@@ -1494,10 +1540,12 @@ class AnthropicCompletion(BaseLLM):
else:
for block in response.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_attr(block, "name") == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_attr(block, "input")
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1510,13 +1558,9 @@ class AnthropicCompletion(BaseLLM):
)
return structured_data
# Handle both ToolUseBlock (regular API) and BetaToolUseBlock (beta API features)
# Handle Anthropic tool-use blocks across stable, beta, and preview SDK shapes.
if response.content:
tool_uses = [
block
for block in response.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(response.content))
if tool_uses:
if not available_functions:
@@ -1629,11 +1673,13 @@ class AnthropicCompletion(BaseLLM):
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
if _is_tool_use_block(block):
block_index = event.index
tool_use_id = _tool_use_attr(block, "id")
tool_name = _tool_use_attr(block, "name")
current_tool_calls[block_index] = {
"id": block.id,
"name": block.name,
"id": tool_use_id,
"name": tool_name,
"arguments": "",
"index": block_index,
}
@@ -1642,9 +1688,9 @@ class AnthropicCompletion(BaseLLM):
from_task=from_task,
from_agent=from_agent,
tool_call={
"id": block.id,
"id": tool_use_id,
"function": {
"name": block.name,
"name": tool_name,
"arguments": "",
},
"type": "function",
@@ -1703,10 +1749,12 @@ class AnthropicCompletion(BaseLLM):
return structured_data
for block in final_message.content:
if (
isinstance(block, ToolUseBlock)
and block.name == "structured_output"
_is_tool_use_block(block)
and _tool_use_attr(block, "name") == "structured_output"
):
structured_data = response_model.model_validate(block.input)
structured_data = response_model.model_validate(
_tool_use_attr(block, "input")
)
self._emit_call_completed_event(
response=structured_data.model_dump_json(),
call_type=LLMCallType.LLM_CALL,
@@ -1720,11 +1768,7 @@ class AnthropicCompletion(BaseLLM):
return structured_data
if final_message.content:
tool_uses = [
block
for block in final_message.content
if isinstance(block, (ToolUseBlock, BetaToolUseBlock))
]
tool_uses = _tool_use_blocks(list(final_message.content))
if tool_uses:
if not available_functions:
@@ -1754,7 +1798,7 @@ class AnthropicCompletion(BaseLLM):
async def _ahandle_tool_use_conversation(
self,
initial_response: Message | BetaMessage,
tool_uses: list[ToolUseBlock | BetaToolUseBlock],
tool_uses: list[Any],
params: dict[str, Any],
available_functions: dict[str, Any],
from_task: Any | None = None,

View File

@@ -1,7 +1,7 @@
import os
import sys
import types
from unittest.mock import patch, MagicMock
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from crewai.llm import LLM
@@ -1358,6 +1358,205 @@ _MANY_TOOLS = [
]
def _dict_tool_use_response():
mock_response = MagicMock()
mock_response.content = [
{
"type": "tool_use",
"id": "toolu_123",
"name": "search_web",
"input": {"query": "CrewAI"},
}
]
mock_response.usage = MagicMock(input_tokens=10, output_tokens=2)
mock_response.stop_reason = "tool_use"
mock_response.id = "msg_123"
return mock_response
class _SyncAnthropicStream:
def __init__(self, events, final_message):
self.events = events
self.final_message = final_message
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def __iter__(self):
return iter(self.events)
def get_final_message(self):
return self.final_message
class _AsyncAnthropicStream:
def __init__(self, events, final_message):
self.events = list(events)
self.final_message = final_message
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
def __aiter__(self):
self._iter = iter(self.events)
return self
async def __anext__(self):
try:
return next(self._iter)
except StopIteration:
raise StopAsyncIteration
async def get_final_message(self):
return self.final_message
def _dict_tool_use_stream_events():
return [
types.SimpleNamespace(
type="content_block_start",
index=0,
content_block={
"type": "tool_use",
"id": "toolu_123",
"name": "search_web",
"input": {},
},
),
types.SimpleNamespace(
type="content_block_delta",
index=0,
delta=types.SimpleNamespace(
type="input_json_delta",
partial_json='{"query":"CrewAI"}',
),
),
]
def test_anthropic_tool_use_dict_blocks_are_returned_as_tool_calls():
"""Preview Anthropic models may return dict-shaped tool_use blocks."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
@pytest.mark.asyncio
async def test_anthropic_acall_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create = AsyncMock(return_value=mock_response)
llm._async_client = mock_client
result = await llm.acall("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
def test_anthropic_streaming_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5", stream=True)
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.stream.return_value = _SyncAnthropicStream(
_dict_tool_use_stream_events(), mock_response
)
llm._client = mock_client
llm._emit_stream_chunk_event = MagicMock()
result = llm.call("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
assert any(
call.kwargs.get("tool_call", {}).get("id") == "toolu_123"
for call in llm._emit_stream_chunk_event.call_args_list
)
@pytest.mark.asyncio
async def test_anthropic_async_streaming_returns_dict_tool_use_blocks_as_tool_calls():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5", stream=True)
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.stream.return_value = _AsyncAnthropicStream(
_dict_tool_use_stream_events(), mock_response
)
llm._async_client = mock_client
llm._emit_stream_chunk_event = MagicMock()
result = await llm.acall("Search for CrewAI", tools=_MANY_TOOLS)
assert result == mock_response.content
assert any(
call.kwargs.get("tool_call", {}).get("id") == "toolu_123"
for call in llm._emit_stream_chunk_event.call_args_list
)
def test_anthropic_dict_tool_use_blocks_execute_available_function():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
mock_response = _dict_tool_use_response()
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client
result = llm.call(
"Search for CrewAI",
tools=_MANY_TOOLS,
available_functions={"search_web": lambda query: f"found {query}"},
)
assert result == "found CrewAI"
def test_anthropic_dict_tool_use_blocks_work_in_follow_up_conversation():
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
llm = AnthropicCompletion(model="claude-fable-5")
initial_response = _dict_tool_use_response()
final_response = MagicMock()
final_response.content = [types.SimpleNamespace(text="Final answer")]
final_response.usage = MagicMock(input_tokens=4, output_tokens=3)
final_response.stop_reason = "end_turn"
final_response.id = "msg_final"
mock_client = MagicMock()
mock_client.messages.create.return_value = final_response
llm._client = mock_client
result = llm._handle_tool_use_conversation(
initial_response,
initial_response.content,
params={"messages": []},
available_functions={"search_web": lambda query: f"found {query}"},
)
assert result == "Final answer"
@pytest.mark.vcr()
def test_tool_search_discovers_and_calls_tool():
"""Tool search should discover the right tool and return a tool_use block."""