diff --git a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py index b012aa61d..11a50cd31 100644 --- a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py +++ b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py @@ -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, diff --git a/lib/crewai/tests/llms/anthropic/test_anthropic.py b/lib/crewai/tests/llms/anthropic/test_anthropic.py index 1269b5bd3..c33f4cfbb 100644 --- a/lib/crewai/tests/llms/anthropic/test_anthropic.py +++ b/lib/crewai/tests/llms/anthropic/test_anthropic.py @@ -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."""