Python: Added Shell tool (#4339)

* Added shell tool

* Fixed CI error

* Add ShellTool support for OpenAI and Anthropic providers

- Add shell_tool_call, shell_tool_result, and shell_command_output content types
- Add ShellTool class and shell_tool decorator to core
- Add get_hosted_shell_tool() to OpenAI Responses client
- Handle shell_call and shell_call_output parsing in OpenAI (sync and streaming)
- Map ShellTool to Anthropic bash tool API format
- Parse bash_code_execution_tool_result as shell_tool_result in Anthropic
- Add unit tests for all new functionality
- Add sample scripts for hosted and local shell execution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Addressed comments

* Reverted ruff change

* Fixed tests

* Addressed comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Dmytro Struk
2026-03-03 16:22:15 +00:00
committed by GitHub
co-authored by Copilot
parent dae3caa719
commit 1c0ae4b659
15 changed files with 1638 additions and 61 deletions
@@ -4,7 +4,7 @@ from __future__ import annotations
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 (
@@ -25,8 +25,10 @@ from agent_framework import (
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
@@ -326,6 +328,7 @@ class AnthropicClient(
# 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
@@ -379,6 +382,57 @@ class AnthropicClient(
"""
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(
*,
@@ -715,8 +769,16 @@ class AnthropicClient(
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,
@@ -744,6 +806,9 @@ class AnthropicClient(
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:
@@ -760,9 +825,18 @@ class AnthropicClient(
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"}
@@ -914,10 +988,11 @@ class AnthropicClient(
)
)
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,
)
@@ -1006,33 +1081,29 @@ class AnthropicClient(
)
)
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(
@@ -1041,9 +1112,9 @@ class AnthropicClient(
)
)
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,
)
)
@@ -14,6 +14,7 @@ from agent_framework import (
tool,
)
from agent_framework._settings import load_settings
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
from anthropic.types.beta import (
BetaMessage,
BetaTextBlock,
@@ -40,6 +41,8 @@ def create_test_anthropic_client(
anthropic_settings: AnthropicSettings | None = None,
) -> AnthropicClient:
"""Helper function to create AnthropicClient instances for testing, bypassing normal validation."""
from agent_framework._tools import normalize_function_invocation_configuration
if anthropic_settings is None:
anthropic_settings = load_settings(
AnthropicSettings,
@@ -55,9 +58,13 @@ def create_test_anthropic_client(
client.anthropic_client = mock_anthropic_client
client.model_id = model_id or anthropic_settings["chat_model_id"]
client._last_call_id_name = None
client._tool_name_aliases = {}
client.additional_properties = {}
client.middleware = None
client.additional_beta_flags = []
client.chat_middleware = []
client.function_middleware = []
client.function_invocation_configuration = normalize_function_invocation_configuration(None)
return client
@@ -410,6 +417,87 @@ def test_prepare_tools_for_anthropic_code_interpreter(mock_anthropic_client: Mag
assert result["tools"][0]["name"] == "code_execution"
def _dummy_bash(command: str) -> str:
return f"executed: {command}"
def test_prepare_tools_for_anthropic_shell_tool(mock_anthropic_client: MagicMock) -> None:
"""Test converting tool-decorated FunctionTool to Anthropic bash format."""
client = create_test_anthropic_client(mock_anthropic_client)
@tool(kind=SHELL_TOOL_KIND_VALUE)
def run_bash(command: str) -> str:
return _dummy_bash(command)
chat_options = ChatOptions(tools=[run_bash])
result = client._prepare_tools_for_anthropic(chat_options)
assert result is not None
assert "tools" in result
assert len(result["tools"]) == 1
assert result["tools"][0]["type"] == "bash_20250124"
assert result["tools"][0]["name"] == "bash"
def test_prepare_tools_for_anthropic_shell_tool_custom_type(mock_anthropic_client: MagicMock) -> None:
"""Test shell tool with custom type via additional_properties."""
client = create_test_anthropic_client(mock_anthropic_client)
@tool(kind=SHELL_TOOL_KIND_VALUE, additional_properties={"type": "bash_20241022"})
def run_bash(command: str) -> str:
return _dummy_bash(command)
chat_options = ChatOptions(tools=[run_bash])
result = client._prepare_tools_for_anthropic(chat_options)
assert result is not None
assert "tools" in result
assert result["tools"][0]["type"] == "bash_20241022"
assert result["tools"][0]["name"] == "bash"
def test_prepare_tools_for_anthropic_shell_tool_does_not_mutate_name(mock_anthropic_client: MagicMock) -> None:
"""Shell tool API name should be 'bash' without mutating local FunctionTool name."""
client = create_test_anthropic_client(mock_anthropic_client)
@tool(
name="run_local_shell",
approval_mode="never_require",
kind=SHELL_TOOL_KIND_VALUE,
)
def run_local_shell(command: str) -> str:
return command
chat_options = ChatOptions(tools=[run_local_shell])
result = client._prepare_tools_for_anthropic(chat_options)
assert result is not None
assert result["tools"][0]["name"] == "bash"
assert run_local_shell.name == "run_local_shell"
def test_get_shell_tool_reuses_function_tool_instance(mock_anthropic_client: MagicMock) -> None:
"""Passing a FunctionTool should update and return the same tool instance."""
client = create_test_anthropic_client(mock_anthropic_client)
@tool(name="run_shell", approval_mode="never_require")
def run_shell(command: str) -> str:
return command
shell_tool = client.get_shell_tool(
func=run_shell,
description="Run local bash",
approval_mode="always_require",
)
assert shell_tool is run_shell
assert shell_tool.kind == SHELL_TOOL_KIND_VALUE
assert shell_tool.description == "Run local bash"
assert shell_tool.approval_mode == "always_require"
def test_prepare_tools_for_anthropic_mcp_tool(mock_anthropic_client: MagicMock) -> None:
"""Test converting MCP dict tool to Anthropic format."""
client = create_test_anthropic_client(mock_anthropic_client)
@@ -502,6 +590,62 @@ async def test_prepare_options_with_system_message(mock_anthropic_client: MagicM
assert len(run_options["messages"]) == 1 # System message not in messages list
async def test_anthropic_shell_tool_is_invoked_in_function_loop(mock_anthropic_client: MagicMock) -> None:
"""Function invocation loop should execute shell tool when Anthropic returns bash tool_use."""
client = create_test_anthropic_client(mock_anthropic_client)
executed_commands: list[str] = []
def run_local_shell(command: str) -> str:
executed_commands.append(command)
return f"executed: {command}"
shell_tool_instance = client.get_shell_tool(func=run_local_shell, approval_mode="never_require")
mock_tool_use = MagicMock()
mock_tool_use.type = "tool_use"
mock_tool_use.id = "call_bash_loop"
mock_tool_use.name = "bash"
mock_tool_use.input = {"command": "pwd"}
first_message = MagicMock()
first_message.id = "msg_1"
first_message.content = [mock_tool_use]
first_message.usage = None
first_message.model = "claude-test"
first_message.stop_reason = "tool_use"
mock_text_block = MagicMock()
mock_text_block.type = "text"
mock_text_block.text = "Done"
second_message = MagicMock()
second_message.id = "msg_2"
second_message.content = [mock_text_block]
second_message.usage = None
second_message.model = "claude-test"
second_message.stop_reason = "end_turn"
mock_anthropic_client.beta.messages.create.side_effect = [first_message, second_message]
await client.get_response(
messages=[Message(role="user", text="Run pwd")],
options={"tools": [shell_tool_instance], "max_tokens": 64},
)
assert executed_commands == ["pwd"]
assert mock_anthropic_client.beta.messages.create.call_count == 2
second_request_messages = mock_anthropic_client.beta.messages.create.call_args_list[1].kwargs["messages"]
tool_results = [
block
for message in second_request_messages
for block in message.get("content", [])
if block.get("type") == "tool_result"
]
assert len(tool_results) == 1
assert tool_results[0]["tool_use_id"] == "call_bash_loop"
assert "executed: pwd" in tool_results[0]["content"]
async def test_prepare_options_with_tool_choice_auto(mock_anthropic_client: MagicMock) -> None:
"""Test _prepare_options with auto tool choice."""
client = create_test_anthropic_client(mock_anthropic_client)
@@ -1733,7 +1877,7 @@ def test_parse_code_execution_result_with_files(mock_anthropic_client: MagicMock
def test_parse_bash_execution_result_with_stdout(mock_anthropic_client: MagicMock) -> None:
"""Test parsing bash execution result with stdout."""
"""Test parsing bash execution result with stdout produces shell_tool_result."""
client = create_test_anthropic_client(mock_anthropic_client)
client._last_call_id_name = ("call_bash2", "bash_code_execution")
@@ -1741,6 +1885,7 @@ def test_parse_bash_execution_result_with_stdout(mock_anthropic_client: MagicMoc
mock_content = MagicMock()
mock_content.stdout = "Output text"
mock_content.stderr = None
mock_content.return_code = 0
mock_content.content = []
mock_block = MagicMock()
@@ -1751,11 +1896,18 @@ def test_parse_bash_execution_result_with_stdout(mock_anthropic_client: MagicMoc
result = client._parse_contents_from_anthropic([mock_block])
assert len(result) == 1
assert result[0].type == "function_result"
assert result[0].type == "shell_tool_result"
assert result[0].call_id == "call_bash2"
assert result[0].outputs is not None
assert len(result[0].outputs) == 1
assert result[0].outputs[0].type == "shell_command_output"
assert result[0].outputs[0].stdout == "Output text"
assert result[0].outputs[0].exit_code == 0
assert result[0].outputs[0].timed_out is False
def test_parse_bash_execution_result_with_stderr(mock_anthropic_client: MagicMock) -> None:
"""Test parsing bash execution result with stderr."""
"""Test parsing bash execution result with stderr produces shell_tool_result."""
client = create_test_anthropic_client(mock_anthropic_client)
client._last_call_id_name = ("call_bash3", "bash_code_execution")
@@ -1763,6 +1915,7 @@ def test_parse_bash_execution_result_with_stderr(mock_anthropic_client: MagicMoc
mock_content = MagicMock()
mock_content.stdout = None
mock_content.stderr = "Error output"
mock_content.return_code = 1
mock_content.content = []
mock_block = MagicMock()
@@ -1773,7 +1926,39 @@ def test_parse_bash_execution_result_with_stderr(mock_anthropic_client: MagicMoc
result = client._parse_contents_from_anthropic([mock_block])
assert len(result) == 1
assert result[0].type == "function_result"
assert result[0].type == "shell_tool_result"
assert result[0].call_id == "call_bash3"
assert result[0].outputs is not None
assert result[0].outputs[0].type == "shell_command_output"
assert result[0].outputs[0].stderr == "Error output"
assert result[0].outputs[0].exit_code == 1
def test_parse_bash_execution_result_with_error(mock_anthropic_client: MagicMock) -> None:
"""Test parsing bash execution error produces shell_tool_result with error info."""
from anthropic.types.beta.beta_bash_code_execution_tool_result_error import (
BetaBashCodeExecutionToolResultError,
)
client = create_test_anthropic_client(mock_anthropic_client)
client._last_call_id_name = ("call_bash_err", "bash_code_execution")
mock_error = MagicMock(spec=BetaBashCodeExecutionToolResultError)
mock_error.error_code = "execution_time_exceeded"
mock_block = MagicMock()
mock_block.type = "bash_code_execution_tool_result"
mock_block.tool_use_id = "call_bash_err"
mock_block.content = mock_error
result = client._parse_contents_from_anthropic([mock_block])
assert len(result) == 1
assert result[0].type == "shell_tool_result"
assert result[0].outputs is not None
assert result[0].outputs[0].type == "shell_command_output"
assert result[0].outputs[0].stderr == "execution_time_exceeded"
assert result[0].outputs[0].timed_out is True
# Text Editor Result Tests