Skip to content
117 changes: 94 additions & 23 deletions python/packages/anthropic/agent_framework_anthropic/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Mapping, MutableMapping, Sequence
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
from typing import Any, ClassVar, Final, Generic, Literal, TypedDict

from agent_framework import (
Expand All @@ -25,8 +25,10 @@
ResponseStream,
TextSpanRegion,
UsageDetails,
tool,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
from agent_framework._types import _get_data_bytes_as_str # type: ignore
from agent_framework.observability import ChatTelemetryLayer
from anthropic import AsyncAnthropic
Expand Down Expand Up @@ -326,6 +328,7 @@ class MyOptions(AnthropicChatOptions, total=False):
# streaming requires tracking the last function call ID, name, and content type
self._last_call_id_name: tuple[str, str] | None = None
self._last_call_content_type: str | None = None
self._tool_name_aliases: dict[str, str] = {}

# region Static factory methods for hosted tools

Expand Down Expand Up @@ -379,6 +382,57 @@ def get_web_search_tool(
"""
return {"type": type_name or "web_search_20250305", "name": name}

@staticmethod
def get_shell_tool(
*,
func: Callable[..., Any] | FunctionTool,
description: str | None = None,
type_name: str | None = None,
approval_mode: Literal["always_require", "never_require"] | None = None,
) -> FunctionTool:
"""Create a local shell FunctionTool for Anthropic.

This helper wraps ``func`` as a shell-enabled ``FunctionTool`` for local
execution and configures Anthropic API declaration details via metadata.

Anthropic always exposes this tool to the model as ``name="bash"`` and
executes it using a ``bash_*`` tool type.

Keyword Args:
func: Python callable or ``FunctionTool`` that executes the requested shell command.
description: Optional tool description shown to the model.
type_name: Optional Anthropic shell tool type override.
Defaults to ``"bash_20250124"`` when omitted.
approval_mode: Optional approval mode for local execution.

Returns:
A shell-enabled ``FunctionTool`` suitable for ``ChatOptions.tools``.
"""
base_tool: FunctionTool
if isinstance(func, FunctionTool):
base_tool = func
if description is not None:
base_tool.description = description
if approval_mode is not None:
base_tool.approval_mode = approval_mode
else:
base_tool = tool(
func=func,
description=description,
approval_mode=approval_mode,
)

additional_properties: dict[str, Any] = dict(base_tool.additional_properties or {})
if type_name:
additional_properties["type"] = type_name

if base_tool.func is None:
raise ValueError("Shell tool requires an executable function.")

base_tool.additional_properties = additional_properties
base_tool.kind = SHELL_TOOL_KIND_VALUE
return base_tool

@staticmethod
def get_mcp_tool(
*,
Expand Down Expand Up @@ -715,8 +769,16 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str,
if tools:
tool_list: list[Any] = []
mcp_server_list: list[Any] = []
tool_name_aliases: dict[str, str] = {}
for tool in tools:
if isinstance(tool, FunctionTool):
if isinstance(tool, FunctionTool) and tool.kind == SHELL_TOOL_KIND_VALUE:
api_type = (tool.additional_properties or {}).get("type", "bash_20250124")
tool_name_aliases["bash"] = tool.name
tool_list.append({
"type": api_type,
"name": "bash",
})
elif isinstance(tool, FunctionTool):
tool_list.append({
"type": "custom",
"name": tool.name,
Expand Down Expand Up @@ -744,6 +806,9 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str,
result["tools"] = tool_list
if mcp_server_list:
result["mcp_servers"] = mcp_server_list
self._tool_name_aliases = tool_name_aliases
else:
self._tool_name_aliases = {}

# Process tool choice
if options.get("tool_choice") is None:
Expand All @@ -760,9 +825,18 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str,
result["tool_choice"] = tool_choice
case "required":
if "required_function_name" in tool_mode:
required_name = tool_mode["required_function_name"]
api_tool_name = next(
(
api_name
for api_name, local_name in self._tool_name_aliases.items()
if local_name == required_name
),
required_name,
)
tool_choice = {
"type": "tool",
"name": tool_mode["required_function_name"],
"name": api_tool_name,
}
else:
tool_choice = {"type": "any"}
Expand Down Expand Up @@ -914,10 +988,11 @@ def _parse_contents_from_anthropic(
)
)
else:
resolved_tool_name = self._tool_name_aliases.get(content_block.name, content_block.name)
contents.append(
Content.from_function_call(
call_id=content_block.id,
name=content_block.name,
name=resolved_tool_name,
arguments=content_block.input,
raw_representation=content_block,
)
Expand Down Expand Up @@ -1006,33 +1081,29 @@ def _parse_contents_from_anthropic(
)
)
case "bash_code_execution_tool_result":
bash_outputs: list[Content] = []
shell_outputs: list[Content] = []
if content_block.content:
if isinstance(
content_block.content,
BetaBashCodeExecutionToolResultError,
):
bash_outputs.append(
Content.from_error(
message=content_block.content.error_code,
shell_outputs.append(
Content.from_shell_command_output(
stderr=content_block.content.error_code,
timed_out=content_block.content.error_code == "execution_time_exceeded",
raw_representation=content_block.content,
)
)
else:
if content_block.content.stdout:
bash_outputs.append(
Content.from_text(
text=content_block.content.stdout,
raw_representation=content_block.content,
)
)
if content_block.content.stderr:
bash_outputs.append(
Content.from_error(
message=content_block.content.stderr,
raw_representation=content_block.content,
)
shell_outputs.append(
Content.from_shell_command_output(
stdout=content_block.content.stdout or None,
stderr=content_block.content.stderr or None,
exit_code=int(content_block.content.return_code),
timed_out=False,
raw_representation=content_block.content,
)
)
for bash_file_content in content_block.content.content:
contents.append(
Content.from_hosted_file(
Expand All @@ -1041,9 +1112,9 @@ def _parse_contents_from_anthropic(
)
)
contents.append(
Content.from_function_result(
Content.from_shell_tool_result(
call_id=content_block.tool_use_id,
result=bash_outputs,
outputs=shell_outputs,
raw_representation=content_block,
)
)
Expand Down
Loading