mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user