From 9f4a22dd9d04e178e3af04b1b21e98fae8af6d19 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:58:33 -0800 Subject: [PATCH 1/7] Added shell tool --- .../agent_framework_anthropic/_chat_client.py | 46 +++--- .../anthropic/tests/test_anthropic_client.py | 85 ++++++++++- .../packages/core/agent_framework/__init__.py | 4 + .../packages/core/agent_framework/_agents.py | 6 +- .../packages/core/agent_framework/_tools.py | 141 ++++++++++++++++++ .../packages/core/agent_framework/_types.py | 128 ++++++++++++++++ .../openai/_responses_client.py | 139 +++++++++++++++++ python/packages/core/tests/core/test_types.py | 114 ++++++++++++++ .../openai/test_openai_responses_client.py | 140 +++++++++++++++++ .../tests/workflow/test_agent_executor.py | 4 +- .../tests/workflow/test_workflow_kwargs.py | 4 +- .../anthropic/anthropic_with_shell.py | 79 ++++++++++ ...penai_responses_client_with_local_shell.py | 77 ++++++++++ .../openai_responses_client_with_shell.py | 61 ++++++++ 14 files changed, 997 insertions(+), 31 deletions(-) create mode 100644 python/samples/02-agents/providers/anthropic/anthropic_with_shell.py create mode 100644 python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py create mode 100644 python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index acfc1b0180..57e0dc0fac 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -23,6 +23,7 @@ FunctionTool, Message, ResponseStream, + ShellTool, TextSpanRegion, UsageDetails, ) @@ -716,7 +717,16 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, tool_list: list[Any] = [] mcp_server_list: list[Any] = [] for tool in tools: - if isinstance(tool, FunctionTool): + if isinstance(tool, ShellTool): + api_type = (tool.additional_properties or {}).get("type", "bash_20250124") + # Anthropic requires name="bash" — align tool.name so + # the function invocation layer can match tool_use calls. + tool.name = "bash" + tool_list.append({ + "type": api_type, + "name": "bash", + }) + elif isinstance(tool, FunctionTool): tool_list.append({ "type": "custom", "name": tool.name, @@ -1006,33 +1016,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( @@ -1041,9 +1047,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, ) ) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index d7c4c9afc7..a127f8dc10 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -410,6 +410,42 @@ 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 ShellTool to Anthropic bash format.""" + from agent_framework import ShellTool + + client = create_test_anthropic_client(mock_anthropic_client) + chat_options = ChatOptions(tools=[ShellTool(func=_dummy_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.""" + from agent_framework import ShellTool + + client = create_test_anthropic_client(mock_anthropic_client) + shell = ShellTool(func=_dummy_bash, additional_properties={"type": "bash_20241022"}) + chat_options = ChatOptions(tools=[shell]) + + 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_mcp_tool(mock_anthropic_client: MagicMock) -> None: """Test converting MCP dict tool to Anthropic format.""" client = create_test_anthropic_client(mock_anthropic_client) @@ -1733,7 +1769,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 +1777,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 +1788,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 +1807,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 +1818,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 diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 32746cbe1c..a2b954f7f7 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -71,8 +71,10 @@ FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, + ShellTool, ToolTypes, normalize_function_invocation_configuration, + shell_tool, tool, ) from ._types import ( @@ -269,6 +271,7 @@ "RunnerContext", "SecretString", "SessionContext", + "ShellTool", "SingleEdgeGroup", "SubWorkflowRequestMessage", "SubWorkflowResponseMessage", @@ -329,6 +332,7 @@ "register_state_type", "resolve_agent_id", "response_handler", + "shell_tool", "tool", "validate_chat_options", "validate_tool_mode", diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index a519796b17..8f477f9223 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -947,7 +947,11 @@ def _propagate_conversation_id(update: AgentResponseUpdate) -> AgentResponseUpda def _finalizer(updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: ctx = ctx_holder["ctx"] - rf = ctx.get("chat_options", {}).get("response_format") if ctx else (options.get("response_format") if options else None) + rf = ( + ctx.get("chat_options", {}).get("response_format") + if ctx + else (options.get("response_format") if options else None) + ) return self._finalize_response_updates(updates, response_format=rf) return ( diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 3ec167d4f7..76d754ad3f 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1258,6 +1258,147 @@ def wrapper(f: Callable[..., Any]) -> FunctionTool: return decorator(func) if func else decorator +class ShellTool(FunctionTool): + """A tool for executing shell commands locally on behalf of a model. + + ShellTool extends FunctionTool to mark a function as a shell command + executor. + + Provider-specific configuration can be passed via ``additional_properties``. + The user-supplied function handles actual command execution; + the function invocation layer runs it like any other FunctionTool. + + Examples: + .. code-block:: python + + import subprocess + from agent_framework import ShellTool + + + def run_bash(command: str) -> str: + result = subprocess.run(command, shell=True, capture_output=True, text=True) + return result.stdout + result.stderr + + + shell = ShellTool(func=run_bash) + + # With provider-specific config + shell = ShellTool(func=run_bash, additional_properties={"type": "bash_20241022"}) + """ + + def __init__( + self, + *, + func: Callable[..., Any], + name: str | None = None, + description: str | None = None, + approval_mode: Literal["always_require", "never_require"] | None = None, + input_model: type[BaseModel] | Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Initialize the ShellTool. + + Keyword Args: + func: The function that executes shell commands. + name: The tool name. Defaults to the function's ``__name__``. + description: Tool description. Defaults to the function's docstring. + approval_mode: Whether approval is required. + input_model: Optional Pydantic model or JSON schema for input parameters. + **kwargs: Additional keyword arguments passed to FunctionTool. + """ + tool_name = name or getattr(func, "__name__", "shell") + tool_desc = description or (func.__doc__ or "") + super().__init__( + name=tool_name, + description=tool_desc, + func=func, + approval_mode=approval_mode, + input_model=input_model, + **kwargs, + ) + + +@overload +def shell_tool( + func: Callable[..., Any], + *, + name: str | None = None, + description: str | None = None, + approval_mode: Literal["always_require", "never_require"] | None = None, + additional_properties: dict[str, Any] | None = None, +) -> ShellTool: ... + + +@overload +def shell_tool( + func: None = None, + *, + name: str | None = None, + description: str | None = None, + approval_mode: Literal["always_require", "never_require"] | None = None, + additional_properties: dict[str, Any] | None = None, +) -> Callable[[Callable[..., Any]], ShellTool]: ... + + +def shell_tool( + func: Callable[..., Any] | None = None, + *, + name: str | None = None, + description: str | None = None, + approval_mode: Literal["always_require", "never_require"] | None = None, + additional_properties: dict[str, Any] | None = None, +) -> ShellTool | Callable[[Callable[..., Any]], ShellTool]: + """Decorate a function to turn it into a ShellTool. + + Works the same way as :func:`tool` but creates a :class:`ShellTool` + instead of a :class:`FunctionTool`, so providers can detect it and + map it to the correct shell API declaration. + + Args: + func: The function to decorate. + + Keyword Args: + name: The tool name. Defaults to the function's ``__name__``. + description: Tool description. Defaults to the function's docstring. + approval_mode: Whether approval is required to run this tool. + additional_properties: Provider-specific configuration passed + through to the tool (e.g. ``{"type": "bash_20241022"}``). + + Example: + + .. code-block:: python + + from agent_framework import shell_tool + + + @shell_tool + def run_bash(command: str) -> str: + '''Execute a bash command.''' + ... + + + # With options + @shell_tool(name="my_shell", additional_properties={"type": "bash_20241022"}) + def run_bash(command: str) -> str: ... + + """ + + def decorator(func: Callable[..., Any]) -> ShellTool: + @wraps(func) + def wrapper(f: Callable[..., Any]) -> ShellTool: + return ShellTool( + func=f, + name=name, + description=description, + approval_mode=approval_mode, + additional_properties=additional_properties or {}, + ) + + return wrapper(func) + + return decorator(func) if func else decorator + + # region Function Invoking Chat Client diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 37ee9f1138..866df86afd 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -340,6 +340,9 @@ def _serialize_value(value: Any, exclude_none: bool) -> Any: "image_generation_tool_result", "mcp_server_tool_call", "mcp_server_tool_result", + "shell_tool_call", + "shell_tool_result", + "shell_command_output", "function_approval_request", "function_approval_response", ] @@ -476,6 +479,16 @@ def __init__( outputs: list[Content] | Any | None = None, # Image generation tool fields image_id: str | None = None, + # Shell tool fields + commands: list[str] | None = None, + timeout_ms: int | None = None, + max_output_length: int | None = None, + status: str | None = None, + # Shell command output fields + stdout: str | None = None, + stderr: str | None = None, + exit_code: int | None = None, + timed_out: bool | None = None, # MCP server tool fields tool_name: str | None = None, server_name: str | None = None, @@ -518,6 +531,14 @@ def __init__( self.inputs = inputs self.outputs = outputs self.image_id = image_id + self.commands = commands + self.timeout_ms = timeout_ms + self.max_output_length = max_output_length + self.status = status + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + self.timed_out = timed_out self.tool_name = tool_name self.server_name = server_name self.output = output @@ -908,6 +929,105 @@ def from_image_generation_tool_result( raw_representation=raw_representation, ) + @classmethod + def from_shell_tool_call( + cls: type[ContentT], + *, + call_id: str | None = None, + commands: list[str] | None = None, + timeout_ms: int | None = None, + max_output_length: int | None = None, + status: str | None = None, + annotations: Sequence[Annotation] | None = None, + additional_properties: MutableMapping[str, Any] | None = None, + raw_representation: Any = None, + ) -> ContentT: + """Create shell tool call content. + + Keyword Args: + call_id: The unique identifier for this tool call. + commands: The list of commands to execute. + timeout_ms: The timeout in milliseconds for the shell command execution. + max_output_length: The maximum output length in characters. + status: The status of the shell call (e.g., "in_progress", "completed", "incomplete"). + annotations: Optional annotations for this content. + additional_properties: Optional additional properties. + raw_representation: The raw provider-specific representation. + """ + return cls( + "shell_tool_call", + call_id=call_id, + commands=commands, + timeout_ms=timeout_ms, + max_output_length=max_output_length, + status=status, + annotations=annotations, + additional_properties=additional_properties, + raw_representation=raw_representation, + ) + + @classmethod + def from_shell_tool_result( + cls: type[ContentT], + *, + call_id: str | None = None, + outputs: Sequence[Content] | None = None, + max_output_length: int | None = None, + annotations: Sequence[Annotation] | None = None, + additional_properties: MutableMapping[str, Any] | None = None, + raw_representation: Any = None, + ) -> ContentT: + """Create shell tool result content. + + Keyword Args: + call_id: The function call ID for which this is the result. + outputs: The list of shell command output Content objects. + max_output_length: The maximum output length in characters. + annotations: Optional annotations for this content. + additional_properties: Optional additional properties. + raw_representation: The raw provider-specific representation. + """ + return cls( + "shell_tool_result", + call_id=call_id, + outputs=list(outputs) if outputs is not None else None, + max_output_length=max_output_length, + annotations=annotations, + additional_properties=additional_properties, + raw_representation=raw_representation, + ) + + @classmethod + def from_shell_command_output( + cls: type[ContentT], + *, + stdout: str | None = None, + stderr: str | None = None, + exit_code: int | None = None, + timed_out: bool | None = None, + additional_properties: MutableMapping[str, Any] | None = None, + raw_representation: Any = None, + ) -> ContentT: + """Create shell command output content representing a single command execution result. + + Keyword Args: + stdout: The standard output of the command. + stderr: The standard error output of the command. + exit_code: The exit code of the command, or None if the command timed out. + timed_out: Whether the command execution timed out. + additional_properties: Optional additional properties. + raw_representation: The raw provider-specific representation. + """ + return cls( + "shell_command_output", + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + timed_out=timed_out, + additional_properties=additional_properties, + raw_representation=raw_representation, + ) + @classmethod def from_mcp_server_tool_call( cls: type[ContentT], @@ -1034,6 +1154,14 @@ def to_dict(self, *, exclude_none: bool = True, exclude: set[str] | None = None) "inputs", "outputs", "image_id", + "commands", + "timeout_ms", + "max_output_length", + "status", + "stdout", + "stderr", + "exit_code", + "timed_out", "tool_name", "server_name", "output", diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 5ba0bbc686..ad5acfe4b2 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, NoReturn, TypedDict, cast from openai import AsyncOpenAI, BadRequestError +from openai.types.responses import FunctionShellTool from openai.types.responses.file_search_tool_param import FileSearchToolParam from openai.types.responses.function_tool_param import FunctionToolParam from openai.types.responses.parsed_response import ( @@ -622,6 +623,48 @@ def get_image_generation_tool( return tool + @staticmethod + def get_hosted_shell_tool( + *, + environment: Literal["auto"] | dict[str, Any] | None = "auto", + ) -> Any: + """Create a hosted shell tool for the Responses API. + + Returns a shell tool that executes commands in OpenAI's managed + container environment. + + For **local** shell execution (commands run on your machine), create + a :class:`ShellTool` directly instead:: + + from agent_framework import ShellTool + + tool = ShellTool(func=my_bash_func) + + Keyword Args: + environment: Container environment configuration. + Use ``"auto"`` (default) for automatic container management, + or provide a dict with custom container settings. + + Returns: + A ``FunctionShellTool`` for hosted execution. + + Examples: + .. code-block:: python + + from agent_framework.openai import OpenAIResponsesClient + + # Hosted shell (OpenAI container) + tool = OpenAIResponsesClient.get_hosted_shell_tool() + + # With custom environment + tool = OpenAIResponsesClient.get_hosted_shell_tool( + environment={"type": "container_auto", "file_ids": ["file-abc"]} + ) + """ + env_config: dict[str, Any] = environment if isinstance(environment, dict) else {"type": "container_auto"} + + return FunctionShellTool(type="shell", environment=env_config) + @staticmethod def get_mcp_tool( *, @@ -1332,6 +1375,54 @@ def _parse_response_from_openai( raw_representation=item, ) ) + case "shell_call": # ResponseFunctionShellToolCall + shell_call_id = item.call_id if hasattr(item, "call_id") else "" + shell_commands: list[str] = [] + shell_timeout_ms: int | None = None + shell_max_output: int | None = None + if action := getattr(item, "action", None): + shell_commands = list(getattr(action, "commands", []) or []) + shell_timeout_ms = getattr(action, "timeout_ms", None) + shell_max_output = getattr(action, "max_output_length", None) + contents.append( + Content.from_shell_tool_call( + call_id=shell_call_id, + commands=shell_commands, + timeout_ms=shell_timeout_ms, + max_output_length=shell_max_output, + status=getattr(item, "status", None), + raw_representation=item, + ) + ) + case "shell_call_output": # ResponseFunctionShellToolCallOutput + shell_output_call_id = item.call_id if hasattr(item, "call_id") else "" + shell_outputs: list[Content] = [] + for shell_out in getattr(item, "output", []) or []: + s_exit_code: int | None = None + s_timed_out: bool | None = None + if outcome := getattr(shell_out, "outcome", None): + if getattr(outcome, "type", None) == "exit": + s_exit_code = getattr(outcome, "exit_code", None) + s_timed_out = False + elif getattr(outcome, "type", None) == "timeout": + s_timed_out = True + shell_outputs.append( + Content.from_shell_command_output( + stdout=getattr(shell_out, "stdout", None), + stderr=getattr(shell_out, "stderr", None), + exit_code=s_exit_code, + timed_out=s_timed_out, + raw_representation=shell_out, + ) + ) + contents.append( + Content.from_shell_tool_result( + call_id=shell_output_call_id, + outputs=shell_outputs, + max_output_length=getattr(item, "max_output_length", None), + raw_representation=item, + ) + ) case _: logger.debug("Unparsed output of type: %s: %s", item.type, item) response_message = Message(role="assistant", contents=contents) @@ -1646,6 +1737,54 @@ def _parse_chunk_from_openai( raw_representation=event_item, ) ) + case "shell_call": # ResponseFunctionShellToolCall + s_call_id = getattr(event_item, "call_id", None) or "" + s_commands: list[str] = [] + s_timeout_ms: int | None = None + s_max_output: int | None = None + if s_action := getattr(event_item, "action", None): + s_commands = list(getattr(s_action, "commands", []) or []) + s_timeout_ms = getattr(s_action, "timeout_ms", None) + s_max_output = getattr(s_action, "max_output_length", None) + contents.append( + Content.from_shell_tool_call( + call_id=s_call_id, + commands=s_commands, + timeout_ms=s_timeout_ms, + max_output_length=s_max_output, + status=getattr(event_item, "status", None), + raw_representation=event_item, + ) + ) + case "shell_call_output": # ResponseFunctionShellToolCallOutput + s_out_call_id = getattr(event_item, "call_id", None) or "" + s_outputs: list[Content] = [] + for s_out in getattr(event_item, "output", []) or []: + s_exit_code: int | None = None + s_timed_out: bool | None = None + if s_outcome := getattr(s_out, "outcome", None): + if getattr(s_outcome, "type", None) == "exit": + s_exit_code = getattr(s_outcome, "exit_code", None) + s_timed_out = False + elif getattr(s_outcome, "type", None) == "timeout": + s_timed_out = True + s_outputs.append( + Content.from_shell_command_output( + stdout=getattr(s_out, "stdout", None), + stderr=getattr(s_out, "stderr", None), + exit_code=s_exit_code, + timed_out=s_timed_out, + raw_representation=s_out, + ) + ) + contents.append( + Content.from_shell_tool_result( + call_id=s_out_call_id, + outputs=s_outputs, + max_output_length=getattr(event_item, "max_output_length", None), + raw_representation=event_item, + ) + ) case "reasoning": # ResponseOutputReasoning reasoning_id = getattr(event_item, "id", None) added_reasoning = False diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 8a8885b919..c858ff1e3f 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -332,6 +332,120 @@ def test_mcp_server_tool_call_and_result(): assert call2.call_id == "" +# region: Shell tool content + + +def test_shell_tool_call_content_creation(): + call = Content.from_shell_tool_call( + call_id="shell-1", + commands=["ls -la", "pwd"], + timeout_ms=60000, + max_output_length=4096, + status="completed", + ) + + assert call.type == "shell_tool_call" + assert call.call_id == "shell-1" + assert call.commands == ["ls -la", "pwd"] + assert call.timeout_ms == 60000 + assert call.max_output_length == 4096 + assert call.status == "completed" + + +def test_shell_tool_call_content_minimal(): + call = Content.from_shell_tool_call(call_id="shell-2") + + assert call.type == "shell_tool_call" + assert call.call_id == "shell-2" + assert call.commands is None + assert call.timeout_ms is None + assert call.max_output_length is None + assert call.status is None + + +def test_shell_tool_result_content_creation(): + result = Content.from_shell_tool_result( + call_id="shell-1", + outputs=[ + Content.from_shell_command_output(stdout="hello world\n", stderr=None, exit_code=0, timed_out=False), + Content.from_shell_command_output(stderr="error msg", exit_code=1, timed_out=False), + ], + max_output_length=4096, + ) + + assert result.type == "shell_tool_result" + assert result.call_id == "shell-1" + assert result.outputs is not None + assert len(result.outputs) == 2 + assert result.outputs[0].type == "shell_command_output" + assert result.outputs[0].stdout == "hello world\n" + assert result.outputs[0].exit_code == 0 + assert result.outputs[0].timed_out is False + assert result.outputs[1].type == "shell_command_output" + assert result.outputs[1].stderr == "error msg" + assert result.outputs[1].exit_code == 1 + assert result.max_output_length == 4096 + + +def test_shell_tool_result_with_timeout(): + result = Content.from_shell_tool_result( + call_id="shell-t", + outputs=[Content.from_shell_command_output(stdout="partial", timed_out=True)], + ) + + assert result.type == "shell_tool_result" + assert result.outputs is not None + assert result.outputs[0].timed_out is True + assert result.outputs[0].exit_code is None + + +def test_shell_command_output_content_creation(): + output = Content.from_shell_command_output( + stdout="hello\n", + stderr="warn\n", + exit_code=0, + timed_out=False, + ) + + assert output.type == "shell_command_output" + assert output.stdout == "hello\n" + assert output.stderr == "warn\n" + assert output.exit_code == 0 + assert output.timed_out is False + + +def test_shell_content_serialization_roundtrip(): + call = Content.from_shell_tool_call( + call_id="shell-r", + commands=["echo hello"], + timeout_ms=30000, + status="completed", + ) + call_dict = call.to_dict() + restored_call = Content.from_dict(call_dict) + assert restored_call.type == "shell_tool_call" + assert restored_call.call_id == "shell-r" + assert restored_call.commands == ["echo hello"] + assert restored_call.timeout_ms == 30000 + assert restored_call.status == "completed" + + result = Content.from_shell_tool_result( + call_id="shell-r", + outputs=[Content.from_shell_command_output(stdout="hello\n", exit_code=0, timed_out=False)], + max_output_length=4096, + ) + result_dict = result.to_dict() + restored_result = Content.from_dict(result_dict) + assert restored_result.type == "shell_tool_result" + assert restored_result.call_id == "shell-r" + assert restored_result.outputs is not None + assert len(restored_result.outputs) == 1 + assert restored_result.outputs[0].type == "shell_command_output" + assert restored_result.outputs[0].stdout == "hello\n" + assert restored_result.outputs[0].exit_code == 0 + assert restored_result.max_output_length == 4096 + + # region: HostedVectorStoreContent diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 7eaae1e776..2edfd500c5 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -564,6 +564,146 @@ def test_response_content_creation_with_code_interpreter() -> None: assert any(out.type == "uri" for out in result_content.outputs) +def test_get_hosted_shell_tool_basic() -> None: + """Test get_hosted_shell_tool returns correct tool type with default auto environment.""" + tool = OpenAIResponsesClient.get_hosted_shell_tool() + assert tool.type == "shell" + assert tool.environment.type == "container_auto" + + +def test_get_hosted_shell_tool_with_custom_environment() -> None: + """Test get_hosted_shell_tool with custom environment configuration.""" + env = {"type": "container_auto", "file_ids": ["file-abc123"]} + tool = OpenAIResponsesClient.get_hosted_shell_tool(environment=env) + assert tool.type == "shell" + assert tool.environment.file_ids == ["file-abc123"] + + +def test_response_content_creation_with_shell_call() -> None: + """Test _parse_response_from_openai with shell_call output.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + mock_response.incomplete = None + + mock_action = MagicMock() + mock_action.commands = ["ls -la", "pwd"] + mock_action.timeout_ms = 60000 + mock_action.max_output_length = 4096 + + mock_shell_call = MagicMock() + mock_shell_call.type = "shell_call" + mock_shell_call.call_id = "shell-call-1" + mock_shell_call.action = mock_action + mock_shell_call.status = "completed" + + mock_response.output = [mock_shell_call] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + assert len(response.messages[0].contents) == 1 + call_content = response.messages[0].contents[0] + assert call_content.type == "shell_tool_call" + assert call_content.call_id == "shell-call-1" + assert call_content.commands == ["ls -la", "pwd"] + assert call_content.timeout_ms == 60000 + assert call_content.max_output_length == 4096 + assert call_content.status == "completed" + + +def test_response_content_creation_with_shell_call_output() -> None: + """Test _parse_response_from_openai with shell_call_output output.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + mock_response.incomplete = None + + mock_outcome = MagicMock() + mock_outcome.type = "exit" + mock_outcome.exit_code = 0 + + mock_output_entry = MagicMock() + mock_output_entry.stdout = "hello world\n" + mock_output_entry.stderr = "" + mock_output_entry.outcome = mock_outcome + + mock_shell_output = MagicMock() + mock_shell_output.type = "shell_call_output" + mock_shell_output.call_id = "shell-call-1" + mock_shell_output.output = [mock_output_entry] + mock_shell_output.max_output_length = 4096 + + mock_response.output = [mock_shell_output] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + assert len(response.messages[0].contents) == 1 + result_content = response.messages[0].contents[0] + assert result_content.type == "shell_tool_result" + assert result_content.call_id == "shell-call-1" + assert result_content.outputs is not None + assert len(result_content.outputs) == 1 + assert result_content.outputs[0].type == "shell_command_output" + assert result_content.outputs[0].stdout == "hello world\n" + assert result_content.outputs[0].exit_code == 0 + assert result_content.outputs[0].timed_out is False + assert result_content.max_output_length == 4096 + + +def test_response_content_creation_with_shell_call_timeout() -> None: + """Test _parse_response_from_openai with shell_call_output that timed out.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + mock_response.incomplete = None + + mock_outcome = MagicMock() + mock_outcome.type = "timeout" + + mock_output_entry = MagicMock() + mock_output_entry.stdout = "partial output" + mock_output_entry.stderr = None + mock_output_entry.outcome = mock_outcome + + mock_shell_output = MagicMock() + mock_shell_output.type = "shell_call_output" + mock_shell_output.call_id = "shell-call-t" + mock_shell_output.output = [mock_output_entry] + mock_shell_output.max_output_length = None + + mock_response.output = [mock_shell_output] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + result_content = response.messages[0].contents[0] + assert result_content.type == "shell_tool_result" + assert result_content.outputs is not None + assert result_content.outputs[0].type == "shell_command_output" + assert result_content.outputs[0].timed_out is True + assert result_content.outputs[0].exit_code is None + + def test_response_content_creation_with_function_call() -> None: """Test _parse_response_from_openai with function call content.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index db53868ee1..4a850db642 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -286,9 +286,7 @@ async def test_agent_executor_run_streaming_with_stream_kwarg_does_not_raise() - @pytest.mark.parametrize("reserved_kwarg", ["session", "stream", "messages"]) -async def test_prepare_agent_run_args_strips_reserved_kwargs( - reserved_kwarg: str, caplog: "LogCaptureFixture" -) -> None: +async def test_prepare_agent_run_args_strips_reserved_kwargs(reserved_kwarg: str, caplog: "LogCaptureFixture") -> None: """_prepare_agent_run_args must remove reserved kwargs and log a warning.""" raw = {reserved_kwarg: "should-be-stripped", "custom_key": "keep-me"} diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index 379435e124..ce1465effc 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -499,9 +499,7 @@ async def _done() -> AgentResponse: # Continue with responses only — no new kwargs approval = request_events[0] - await workflow.run( - responses={approval.request_id: approval.data.to_function_approval_response(True)} - ) + await workflow.run(responses={approval.request_id: approval.data.to_function_approval_response(True)}) # Both calls should have received the original kwargs assert len(agent.captured_kwargs) == 2 diff --git a/python/samples/02-agents/providers/anthropic/anthropic_with_shell.py b/python/samples/02-agents/providers/anthropic/anthropic_with_shell.py new file mode 100644 index 0000000000..dc30d706da --- /dev/null +++ b/python/samples/02-agents/providers/anthropic/anthropic_with_shell.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import subprocess + +from agent_framework import Agent, shell_tool +from agent_framework.anthropic import AnthropicClient +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Anthropic Client with Shell Tool Example + +This sample demonstrates using ShellTool with AnthropicClient +for executing bash commands locally. The bash tool tells the model it can +request shell commands, while the actual execution happens on YOUR machine +via a user-provided function. + +SECURITY NOTE: This example executes real commands on your local machine. +Only enable this when you trust the agent's actions. Consider implementing +allowlists, sandboxing, or approval workflows for production use. +""" + + +@shell_tool +def run_bash(command: str) -> str: + """Execute a bash command using subprocess and return the output. + + Prints the command and asks the user for confirmation before running. + """ + print(f"\n[Shell] Command: {command}") + answer = input("[Shell] Execute? (y/n): ").strip().lower() + if answer != "y": + return "Command rejected by user." + + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=30, + ) + parts: list[str] = [] + if result.stdout: + parts.append(result.stdout) + if result.stderr: + parts.append(f"stderr: {result.stderr}") + parts.append(f"exit_code: {result.returncode}") + return "\n".join(parts) + except subprocess.TimeoutExpired: + return "Command timed out after 30 seconds" + except Exception as e: + return f"Error executing command: {e}" + + +async def main() -> None: + """Example showing how to use the shell tool with AnthropicClient.""" + print("=== Anthropic Agent with Shell Tool Example ===") + print("NOTE: Commands will execute on your local machine.\n") + + client = AnthropicClient() + + agent = Agent( + client=client, + instructions="You are a helpful assistant that can execute bash commands to answer questions.", + tools=[run_bash], + ) + + query = "Use bash to print 'Hello from Anthropic shell!' and show the current working directory" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py new file mode 100644 index 0000000000..69ce970492 --- /dev/null +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import subprocess + +from agent_framework import Agent, shell_tool +from agent_framework.openai import OpenAIResponsesClient +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +OpenAI Responses Client with Local Shell Tool Example + +This sample demonstrates implementing a local shell tool using ShellTool +that wraps Python's subprocess module. Unlike the hosted shell tool (get_hosted_shell_tool()), +local shell execution runs commands on YOUR machine, not in a remote container. + +SECURITY NOTE: This example executes real commands on your local machine. +Only enable this when you trust the agent's actions. Consider implementing +allowlists, sandboxing, or approval workflows for production use. +""" + + +@shell_tool +def run_bash(command: str) -> str: + """Execute a shell command locally and return stdout, stderr, and exit code. + + Prints the command and asks the user for confirmation before running. + """ + print(f"\n[Shell] Command: {command}") + answer = input("[Shell] Execute? (y/n): ").strip().lower() + if answer != "y": + return "Command rejected by user." + + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=30, + ) + parts: list[str] = [] + if result.stdout: + parts.append(f"stdout:\n{result.stdout}") + if result.stderr: + parts.append(f"stderr:\n{result.stderr}") + parts.append(f"exit_code: {result.returncode}") + return "\n".join(parts) + except subprocess.TimeoutExpired: + return "Command timed out after 30 seconds" + except Exception as e: + return f"Error executing command: {e}" + + +async def main() -> None: + """Example showing how to use a local shell tool with OpenAI.""" + print("=== OpenAI Agent with Local Shell Tool Example ===") + print("NOTE: Commands will execute on your local machine.\n") + + client = OpenAIResponsesClient() + agent = Agent( + client=client, + instructions="You are a helpful assistant that can run shell commands to help the user.", + tools=[run_bash], + ) + + query = "What Python version is installed on this machine?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.text}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py new file mode 100644 index 0000000000..248e33c075 --- /dev/null +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from agent_framework.openai import OpenAIResponsesClient +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +OpenAI Responses Client with Shell Tool Example + +This sample demonstrates using get_hosted_shell_tool() with OpenAI Responses Client +for executing shell commands in a managed container environment hosted by OpenAI. + +The shell tool allows the model to run commands like listing files, running scripts, +or performing system operations within a secure, sandboxed container. +""" + + +async def main() -> None: + """Example showing how to use the shell tool with OpenAI Responses.""" + print("=== OpenAI Responses Agent with Shell Tool Example ===") + + client = OpenAIResponsesClient() + + # Create a hosted shell tool with the default auto container environment + shell_tool = client.get_hosted_shell_tool() + + agent = Agent( + client=client, + instructions="You are a helpful assistant that can execute shell commands to answer questions.", + tools=shell_tool, + ) + + query = "Use a shell command to show the current date and time" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + # Print shell-specific content details + for message in result.messages: + shell_calls = [c for c in message.contents if c.type == "shell_tool_call"] + shell_results = [c for c in message.contents if c.type == "shell_tool_result"] + + if shell_calls: + print(f"Shell commands: {shell_calls[0].commands}") + if shell_results and shell_results[0].outputs: + for output in shell_results[0].outputs: + if output.stdout: + print(f"Stdout: {output.stdout}") + if output.stderr: + print(f"Stderr: {output.stderr}") + if output.exit_code is not None: + print(f"Exit code: {output.exit_code}") + + +if __name__ == "__main__": + asyncio.run(main()) From 49950f41fb9d4f8326b1748720de950d2d22cec6 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:07:50 -0800 Subject: [PATCH 2/7] Fixed CI error --- python/packages/core/agent_framework/_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 76d754ad3f..f9eb0f548a 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1306,7 +1306,7 @@ def __init__( input_model: Optional Pydantic model or JSON schema for input parameters. **kwargs: Additional keyword arguments passed to FunctionTool. """ - tool_name = name or getattr(func, "__name__", "shell") + tool_name: str = name or getattr(func, "__name__", "shell") tool_desc = description or (func.__doc__ or "") super().__init__( name=tool_name, From 9f21d683586c11e8af8e1e6500a955fdd2670715 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:17:22 -0800 Subject: [PATCH 3/7] 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> --- python/packages/core/agent_framework/_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index f9eb0f548a..8cd3d3c335 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1306,7 +1306,7 @@ def __init__( input_model: Optional Pydantic model or JSON schema for input parameters. **kwargs: Additional keyword arguments passed to FunctionTool. """ - tool_name: str = name or getattr(func, "__name__", "shell") + tool_name: str = name if name is not None else str(getattr(func, "__name__", "shell")) tool_desc = description or (func.__doc__ or "") super().__init__( name=tool_name, From 9b331a8595713f29464d8f96347f6b1d93ac75e6 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:25:44 -0800 Subject: [PATCH 4/7] Addressed comments --- .../agent_framework_anthropic/_chat_client.py | 89 ++++- .../anthropic/tests/test_anthropic_client.py | 104 +++++- .../packages/core/agent_framework/__init__.py | 4 - .../packages/core/agent_framework/_tools.py | 150 +------- .../packages/core/agent_framework/_types.py | 9 +- .../openai/_responses_client.py | 349 +++++++++++++++--- .../openai/test_openai_responses_client.py | 236 +++++++++++- python/pyproject.toml | 4 +- .../anthropic/anthropic_with_shell.py | 51 ++- ...penai_responses_client_with_local_shell.py | 77 +++- .../openai_responses_client_with_shell.py | 4 +- 11 files changed, 822 insertions(+), 255 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 57e0dc0fac..9ed9abae83 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -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 ( @@ -23,11 +23,12 @@ FunctionTool, Message, ResponseStream, - ShellTool, TextSpanRegion, UsageDetails, + tool, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._tools import SHELL_TOOL_KIND_KEY, 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 @@ -327,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 @@ -380,6 +382,62 @@ 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 + else: + base_tool = tool( + func=func, + description=description, + approval_mode=approval_mode, + ) + + additional_properties: dict[str, Any] = dict(base_tool.additional_properties or {}) + additional_properties[SHELL_TOOL_KIND_KEY] = SHELL_TOOL_KIND_VALUE + if type_name: + additional_properties["type"] = type_name + + if base_tool.func is None: + raise ValueError("Shell tool requires an executable function.") + + return FunctionTool( + name=base_tool.name, + description=description if description is not None else base_tool.description, + approval_mode=approval_mode or base_tool.approval_mode, + max_invocations=base_tool.max_invocations, + max_invocation_exceptions=base_tool.max_invocation_exceptions, + additional_properties=additional_properties, + func=base_tool.func, + input_model=base_tool.parameters() if base_tool._schema_supplied else base_tool.input_model, + result_parser=base_tool.result_parser, + ) + @staticmethod def get_mcp_tool( *, @@ -716,12 +774,14 @@ 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, ShellTool): + if ( + isinstance(tool, FunctionTool) + and (tool.additional_properties or {}).get(SHELL_TOOL_KIND_KEY) == SHELL_TOOL_KIND_VALUE + ): api_type = (tool.additional_properties or {}).get("type", "bash_20250124") - # Anthropic requires name="bash" — align tool.name so - # the function invocation layer can match tool_use calls. - tool.name = "bash" + tool_name_aliases["bash"] = tool.name tool_list.append({ "type": api_type, "name": "bash", @@ -754,6 +814,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: @@ -770,9 +833,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"} @@ -924,10 +996,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, ) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index a127f8dc10..8d4686c04d 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -14,6 +14,7 @@ tool, ) from agent_framework._settings import load_settings +from agent_framework._tools import SHELL_TOOL_KIND_KEY, 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 @@ -415,11 +422,14 @@ def _dummy_bash(command: str) -> str: def test_prepare_tools_for_anthropic_shell_tool(mock_anthropic_client: MagicMock) -> None: - """Test converting ShellTool to Anthropic bash format.""" - from agent_framework import ShellTool - + """Test converting tool-decorated FunctionTool to Anthropic bash format.""" client = create_test_anthropic_client(mock_anthropic_client) - chat_options = ChatOptions(tools=[ShellTool(func=_dummy_bash)]) + + @tool(additional_properties={SHELL_TOOL_KIND_KEY: 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) @@ -432,11 +442,13 @@ def test_prepare_tools_for_anthropic_shell_tool(mock_anthropic_client: MagicMock def test_prepare_tools_for_anthropic_shell_tool_custom_type(mock_anthropic_client: MagicMock) -> None: """Test shell tool with custom type via additional_properties.""" - from agent_framework import ShellTool - client = create_test_anthropic_client(mock_anthropic_client) - shell = ShellTool(func=_dummy_bash, additional_properties={"type": "bash_20241022"}) - chat_options = ChatOptions(tools=[shell]) + + @tool(additional_properties={SHELL_TOOL_KIND_KEY: SHELL_TOOL_KIND_VALUE, "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) @@ -446,6 +458,26 @@ def test_prepare_tools_for_anthropic_shell_tool_custom_type(mock_anthropic_clien 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", + additional_properties={SHELL_TOOL_KIND_KEY: 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_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) @@ -538,6 +570,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) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index a2b954f7f7..32746cbe1c 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -71,10 +71,8 @@ FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, - ShellTool, ToolTypes, normalize_function_invocation_configuration, - shell_tool, tool, ) from ._types import ( @@ -271,7 +269,6 @@ "RunnerContext", "SecretString", "SessionContext", - "ShellTool", "SingleEdgeGroup", "SubWorkflowRequestMessage", "SubWorkflowResponseMessage", @@ -332,7 +329,6 @@ "register_state_type", "resolve_agent_id", "response_handler", - "shell_tool", "tool", "validate_chat_options", "validate_tool_mode", diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 8cd3d3c335..bce1bf13f0 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -79,6 +79,8 @@ DEFAULT_MAX_ITERATIONS: Final[int] = 40 DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3 +SHELL_TOOL_KIND_KEY: Final[str] = "agent_framework.tool_kind" +SHELL_TOOL_KIND_VALUE: Final[str] = "shell" ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") # region Helpers @@ -1258,147 +1260,6 @@ def wrapper(f: Callable[..., Any]) -> FunctionTool: return decorator(func) if func else decorator -class ShellTool(FunctionTool): - """A tool for executing shell commands locally on behalf of a model. - - ShellTool extends FunctionTool to mark a function as a shell command - executor. - - Provider-specific configuration can be passed via ``additional_properties``. - The user-supplied function handles actual command execution; - the function invocation layer runs it like any other FunctionTool. - - Examples: - .. code-block:: python - - import subprocess - from agent_framework import ShellTool - - - def run_bash(command: str) -> str: - result = subprocess.run(command, shell=True, capture_output=True, text=True) - return result.stdout + result.stderr - - - shell = ShellTool(func=run_bash) - - # With provider-specific config - shell = ShellTool(func=run_bash, additional_properties={"type": "bash_20241022"}) - """ - - def __init__( - self, - *, - func: Callable[..., Any], - name: str | None = None, - description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | None = None, - input_model: type[BaseModel] | Mapping[str, Any] | None = None, - **kwargs: Any, - ) -> None: - """Initialize the ShellTool. - - Keyword Args: - func: The function that executes shell commands. - name: The tool name. Defaults to the function's ``__name__``. - description: Tool description. Defaults to the function's docstring. - approval_mode: Whether approval is required. - input_model: Optional Pydantic model or JSON schema for input parameters. - **kwargs: Additional keyword arguments passed to FunctionTool. - """ - tool_name: str = name if name is not None else str(getattr(func, "__name__", "shell")) - tool_desc = description or (func.__doc__ or "") - super().__init__( - name=tool_name, - description=tool_desc, - func=func, - approval_mode=approval_mode, - input_model=input_model, - **kwargs, - ) - - -@overload -def shell_tool( - func: Callable[..., Any], - *, - name: str | None = None, - description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | None = None, - additional_properties: dict[str, Any] | None = None, -) -> ShellTool: ... - - -@overload -def shell_tool( - func: None = None, - *, - name: str | None = None, - description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | None = None, - additional_properties: dict[str, Any] | None = None, -) -> Callable[[Callable[..., Any]], ShellTool]: ... - - -def shell_tool( - func: Callable[..., Any] | None = None, - *, - name: str | None = None, - description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | None = None, - additional_properties: dict[str, Any] | None = None, -) -> ShellTool | Callable[[Callable[..., Any]], ShellTool]: - """Decorate a function to turn it into a ShellTool. - - Works the same way as :func:`tool` but creates a :class:`ShellTool` - instead of a :class:`FunctionTool`, so providers can detect it and - map it to the correct shell API declaration. - - Args: - func: The function to decorate. - - Keyword Args: - name: The tool name. Defaults to the function's ``__name__``. - description: Tool description. Defaults to the function's docstring. - approval_mode: Whether approval is required to run this tool. - additional_properties: Provider-specific configuration passed - through to the tool (e.g. ``{"type": "bash_20241022"}``). - - Example: - - .. code-block:: python - - from agent_framework import shell_tool - - - @shell_tool - def run_bash(command: str) -> str: - '''Execute a bash command.''' - ... - - - # With options - @shell_tool(name="my_shell", additional_properties={"type": "bash_20241022"}) - def run_bash(command: str) -> str: ... - - """ - - def decorator(func: Callable[..., Any]) -> ShellTool: - @wraps(func) - def wrapper(f: Callable[..., Any]) -> ShellTool: - return ShellTool( - func=f, - name=name, - description=description, - approval_mode=approval_mode, - additional_properties=additional_properties or {}, - ) - - return wrapper(func) - - return decorator(func) if func else decorator - - # region Function Invoking Chat Client @@ -1531,6 +1392,7 @@ async def _auto_invoke_function( call_id=function_call_content.call_id, # type: ignore[arg-type] result=f'Error: Requested function "{function_call_content.name}" not found.', exception=str(exc), # type: ignore[arg-type] + additional_properties=function_call_content.additional_properties, ) else: # Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results @@ -1571,6 +1433,7 @@ async def _auto_invoke_function( call_id=function_call_content.call_id, # type: ignore[arg-type] result=message, exception=str(exc), # type: ignore[arg-type] + additional_properties=function_call_content.additional_properties, ) if middleware_pipeline is None or not middleware_pipeline.has_middlewares: @@ -1584,6 +1447,7 @@ async def _auto_invoke_function( return Content.from_function_result( call_id=function_call_content.call_id, # type: ignore[arg-type] result=function_result, + additional_properties=function_call_content.additional_properties, ) except Exception as exc: message = "Error: Function failed." @@ -1593,6 +1457,7 @@ async def _auto_invoke_function( call_id=function_call_content.call_id, # type: ignore[arg-type] result=message, exception=str(exc), + additional_properties=function_call_content.additional_properties, ) # Execute through middleware pipeline if available from ._middleware import FunctionInvocationContext @@ -1618,6 +1483,7 @@ async def final_function_handler(context_obj: Any) -> Any: return Content.from_function_result( call_id=function_call_content.call_id, # type: ignore[arg-type] result=function_result, + additional_properties=function_call_content.additional_properties, ) except MiddlewareTermination as term_exc: # Re-raise to signal loop termination, but first capture any result set by middleware @@ -1626,6 +1492,7 @@ async def final_function_handler(context_obj: Any) -> Any: term_exc.result = Content.from_function_result( call_id=function_call_content.call_id, # type: ignore[arg-type] result=middleware_context.result, + additional_properties=function_call_content.additional_properties, ) raise except Exception as exc: @@ -1636,6 +1503,7 @@ async def final_function_handler(context_obj: Any) -> Any: call_id=function_call_content.call_id, # type: ignore[arg-type] result=message, exception=str(exc), # type: ignore[arg-type] + additional_properties=function_call_content.additional_properties, ) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index c999c2f430..beed97834c 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -944,6 +944,9 @@ def from_shell_tool_call( ) -> ContentT: """Create shell tool call content. + This content represents the model's request to run one or more shell + commands. It is request metadata, not command output. + Keyword Args: call_id: The unique identifier for this tool call. commands: The list of commands to execute. @@ -979,6 +982,10 @@ def from_shell_tool_result( ) -> ContentT: """Create shell tool result content. + This content represents the aggregate result for a shell tool call. + Use :meth:`from_shell_command_output` to build each per-command output + item and pass those objects via ``outputs``. + Keyword Args: call_id: The function call ID for which this is the result. outputs: The list of shell command output Content objects. @@ -1008,7 +1015,7 @@ def from_shell_command_output( additional_properties: MutableMapping[str, Any] | None = None, raw_representation: Any = None, ) -> ContentT: - """Create shell command output content representing a single command execution result. + """Create shell command output content for one command execution. Keyword Args: stdout: The standard output of the command. diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index ad5acfe4b2..e47660fcf4 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -2,7 +2,9 @@ from __future__ import annotations +import json import logging +import shlex import sys from collections.abc import ( AsyncIterable, @@ -41,11 +43,14 @@ from .._middleware import ChatMiddlewareLayer from .._settings import load_settings from .._tools import ( + SHELL_TOOL_KIND_KEY, + SHELL_TOOL_KIND_VALUE, FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, ToolTypes, normalize_tools, + tool, ) from .._types import ( Annotation, @@ -93,6 +98,12 @@ ) logger = logging.getLogger("agent_framework.openai") +OPENAI_SHELL_ENVIRONMENT_KEY = "openai.responses.shell.environment" +OPENAI_SHELL_OUTPUT_TYPE_KEY = "openai.responses.shell.output_type" +OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY = "openai.responses.local_shell.call_item_id" +OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY = "openai.local_shell_command_parts" +OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL = "shell_call_output" +OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL = "local_shell_call_output" class OpenAIContinuationToken(ContinuationToken): @@ -433,7 +444,9 @@ def _prepare_tools_for_openai( ) -> list[Any]: """Prepare tools for the OpenAI Responses API. - Converts FunctionTool to Responses API format. All other tools pass through unchanged. + Converts FunctionTool to Responses API format. Shell-enabled FunctionTools + with explicit shell environment metadata are mapped to OpenAI shell tools. + All other tools pass through unchanged. Args: tools: A single tool or sequence of tools to prepare. @@ -445,24 +458,52 @@ def _prepare_tools_for_openai( if not tools_list: return [] response_tools: list[Any] = [] - for tool in tools_list: - if isinstance(tool, FunctionTool): - params = tool.parameters() + for tool_item in tools_list: + if ( + isinstance(tool_item, FunctionTool) + and (tool_item.additional_properties or {}).get(SHELL_TOOL_KIND_KEY) == SHELL_TOOL_KIND_VALUE + ): + shell_env = (tool_item.additional_properties or {}).get(OPENAI_SHELL_ENVIRONMENT_KEY) + if isinstance(shell_env, Mapping): + response_tools.append( + FunctionShellTool( + type="shell", + environment=dict(shell_env), + ) + ) + continue + if isinstance(tool_item, FunctionTool): + params = tool_item.parameters() params["additionalProperties"] = False response_tools.append( FunctionToolParam( - name=tool.name, + name=tool_item.name, parameters=params, strict=False, type="function", - description=tool.description, + description=tool_item.description, ) ) else: # Pass through all other tools (dicts, SDK types) unchanged - response_tools.append(tool) + response_tools.append(tool_item) return response_tools + def _get_local_shell_tool_name( + self, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, + ) -> str | None: + """Return the name of the configured local shell tool function, if any.""" + for tool_item in normalize_tools(tools): + if not isinstance(tool_item, FunctionTool): + continue + if (tool_item.additional_properties or {}).get(SHELL_TOOL_KIND_KEY) != SHELL_TOOL_KIND_VALUE: + continue + shell_env = (tool_item.additional_properties or {}).get(OPENAI_SHELL_ENVIRONMENT_KEY) + if isinstance(shell_env, Mapping) and shell_env.get("type") == "local": + return tool_item.name + return None + # region Hosted Tool Factory Methods @staticmethod @@ -624,29 +665,34 @@ def get_image_generation_tool( return tool @staticmethod - def get_hosted_shell_tool( + def get_shell_tool( *, + func: Callable[..., Any] | FunctionTool | None = None, environment: Literal["auto"] | dict[str, Any] | None = "auto", + name: str | None = None, + description: str | None = None, + approval_mode: Literal["always_require", "never_require"] | None = None, ) -> Any: - """Create a hosted shell tool for the Responses API. + """Create a shell tool for the Responses API. - Returns a shell tool that executes commands in OpenAI's managed - container environment. - - For **local** shell execution (commands run on your machine), create - a :class:`ShellTool` directly instead:: - - from agent_framework import ShellTool - - tool = ShellTool(func=my_bash_func) + - When ``func`` is ``None`` (default), returns an OpenAI hosted shell + tool declaration. + - When ``func`` is provided, returns a local FunctionTool that is + declared to OpenAI as a local shell tool and executed via the function + invocation layer. Keyword Args: + func: Optional local shell function or ``FunctionTool``. environment: Container environment configuration. - Use ``"auto"`` (default) for automatic container management, - or provide a dict with custom container settings. + Used only when ``func`` is ``None``. + Use ``"auto"`` (default) for managed containers, or provide a + dict with explicit hosted container settings. + name: Optional local tool name when ``func`` is provided. + description: Optional local tool description when ``func`` is provided. + approval_mode: Optional local tool approval mode. Returns: - A ``FunctionShellTool`` for hosted execution. + A hosted shell declaration or a local shell FunctionTool. Examples: .. code-block:: python @@ -654,16 +700,59 @@ def get_hosted_shell_tool( from agent_framework.openai import OpenAIResponsesClient # Hosted shell (OpenAI container) - tool = OpenAIResponsesClient.get_hosted_shell_tool() + tool = OpenAIResponsesClient.get_shell_tool() - # With custom environment - tool = OpenAIResponsesClient.get_hosted_shell_tool( + # Hosted shell with custom environment + tool = OpenAIResponsesClient.get_shell_tool( environment={"type": "container_auto", "file_ids": ["file-abc"]} ) + + # Local shell execution + tool = OpenAIResponsesClient.get_shell_tool( + func=my_shell_func, + ) """ - env_config: dict[str, Any] = environment if isinstance(environment, dict) else {"type": "container_auto"} + if func is None: + env_config: dict[str, Any] = ( + dict(environment) if isinstance(environment, dict) else {"type": "container_auto"} + ) + if env_config.get("type") == "local": + raise ValueError("Local shell requires func. Provide func for local execution.") + return FunctionShellTool(type="shell", environment=env_config) + + if isinstance(environment, dict): + raise ValueError("When func is provided, environment config is not supported.") + local_env = {"type": "local"} + + base_tool: FunctionTool + if isinstance(func, FunctionTool): + base_tool = func + else: + base_tool = tool( + func=func, + name=name, + description=description, + approval_mode=approval_mode, + ) - return FunctionShellTool(type="shell", environment=env_config) + if base_tool.func is None: + raise ValueError("Shell tool requires an executable function.") + + additional_properties = dict(base_tool.additional_properties or {}) + additional_properties[SHELL_TOOL_KIND_KEY] = SHELL_TOOL_KIND_VALUE + additional_properties[OPENAI_SHELL_ENVIRONMENT_KEY] = local_env + + return FunctionTool( + name=name or base_tool.name, + description=description if description is not None else base_tool.description, + approval_mode=approval_mode or base_tool.approval_mode, + max_invocations=base_tool.max_invocations, + max_invocation_exceptions=base_tool.max_invocation_exceptions, + additional_properties=additional_properties, + func=base_tool.func, + input_model=base_tool.parameters() if base_tool._schema_supplied else base_tool.input_model, + result_parser=base_tool.result_parser, + ) @staticmethod def get_mcp_tool( @@ -1087,13 +1176,34 @@ def _prepare_content_for_openai( "status": None, } case "function_result": + shell_output_type = ( + content.additional_properties.get(OPENAI_SHELL_OUTPUT_TYPE_KEY) + if content.additional_properties + else None + ) + if shell_output_type == OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL: + return { + "call_id": content.call_id, + "type": OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL, + "output": self._to_shell_call_output_payload(content), + } + local_shell_call_item_id = ( + content.additional_properties.get(OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY) + if content.additional_properties + else None + ) + if shell_output_type == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL and local_shell_call_item_id: + return { + "id": local_shell_call_item_id, + "type": OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL, + "output": self._to_local_shell_output_payload(content), + } # call_id for the result needs to be the same as the call_id for the function call - args: dict[str, Any] = { + return { "call_id": content.call_id, "type": "function_call_output", "output": content.result if content.result is not None else "", } - return args case "function_approval_request": return { "type": "mcp_approval_request", @@ -1119,6 +1229,65 @@ def _prepare_content_for_openai( logger.debug("Unsupported content type passed (type: %s)", content.type) return {} + @staticmethod + def _to_local_shell_output_payload(content: Content) -> str: + """Convert function tool output to the local shell JSON payload format.""" + payload: dict[str, Any] + if isinstance(content.result, Mapping): + payload = dict(content.result) + else: + payload = { + "stdout": "" if content.result is None else str(content.result), + } + if content.exception is not None and "stderr" not in payload: + payload["stderr"] = str(content.exception) + if "exit_code" not in payload: + payload["exit_code"] = 1 if content.exception else 0 + return json.dumps(payload, ensure_ascii=False) + + @staticmethod + def _to_shell_call_output_payload(content: Content) -> list[dict[str, Any]]: + """Convert function tool output to shell_call_output payload format.""" + payload: dict[str, Any] + if isinstance(content.result, Mapping): + payload = dict(content.result) + else: + payload = { + "stdout": "" if content.result is None else str(content.result), + } + if content.exception is not None and "stderr" not in payload: + payload["stderr"] = str(content.exception) + + # Pass through native payload shape when tool already returns shell output entries. + direct_output = payload.get("output") + if isinstance(direct_output, list) and all(isinstance(item, Mapping) for item in direct_output): + return [dict(item) for item in direct_output] + + stdout = str(payload.get("stdout", "")) + stderr = str(payload.get("stderr", "")) + timed_out = bool(payload.get("timed_out", False)) + if timed_out: + outcome: dict[str, Any] = {"type": "timeout"} + else: + exit_code_raw = payload.get("exit_code") + try: + exit_code = int(exit_code_raw) if exit_code_raw is not None else (1 if content.exception else 0) + except (TypeError, ValueError): + exit_code = 1 if content.exception else 0 + outcome = {"type": "exit", "exit_code": exit_code} + return [ + { + "stdout": stdout, + "stderr": stderr, + "outcome": outcome, + } + ] + + @staticmethod + def _join_shell_commands(commands: Sequence[str]) -> str: + """Join shell commands into a single executable command string.""" + return "\n".join(command for command in commands if command).strip() + # region Parse methods def _parse_response_from_openai( self, @@ -1130,6 +1299,7 @@ def _parse_response_from_openai( metadata: dict[str, Any] = response.metadata or {} contents: list[Content] = [] + local_shell_tool_name = self._get_local_shell_tool_name(options.get("tools")) for item in response.output: # type: ignore[reportUnknownMemberType] match item.type: # types: @@ -1384,16 +1554,59 @@ def _parse_response_from_openai( shell_commands = list(getattr(action, "commands", []) or []) shell_timeout_ms = getattr(action, "timeout_ms", None) shell_max_output = getattr(action, "max_output_length", None) - contents.append( - Content.from_shell_tool_call( - call_id=shell_call_id, - commands=shell_commands, - timeout_ms=shell_timeout_ms, - max_output_length=shell_max_output, - status=getattr(item, "status", None), - raw_representation=item, + if local_shell_tool_name: + command_text = self._join_shell_commands(shell_commands) + contents.append( + Content.from_function_call( + call_id=shell_call_id, + name=local_shell_tool_name, + arguments=json.dumps({"command": command_text}), + additional_properties={ + OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL, + OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: shell_commands, + }, + raw_representation=item, + ) + ) + else: + contents.append( + Content.from_shell_tool_call( + call_id=shell_call_id, + commands=shell_commands, + timeout_ms=shell_timeout_ms, + max_output_length=shell_max_output, + status=getattr(item, "status", None), + raw_representation=item, + ) + ) + case "local_shell_call": + local_call_id = getattr(item, "call_id", None) or "" + local_command_parts = list(getattr(getattr(item, "action", None), "command", []) or []) + local_command = shlex.join(local_command_parts) if local_command_parts else "" + if local_shell_tool_name: + contents.append( + Content.from_function_call( + call_id=local_call_id, + name=local_shell_tool_name, + arguments=json.dumps({"command": local_command}), + additional_properties={ + OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL, + OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: getattr(item, "id", None), + OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: local_command_parts, + }, + raw_representation=item, + ) + ) + else: + contents.append( + Content.from_shell_tool_call( + call_id=local_call_id, + commands=[local_command] if local_command else [], + timeout_ms=getattr(getattr(item, "action", None), "timeout_ms", None), + status=getattr(item, "status", None), + raw_representation=item, + ) ) - ) case "shell_call_output": # ResponseFunctionShellToolCallOutput shell_output_call_id = item.call_id if hasattr(item, "call_id") else "" shell_outputs: list[Content] = [] @@ -1461,6 +1674,7 @@ def _parse_chunk_from_openai( """Parse an OpenAI Responses API streaming event into a ChatResponseUpdate.""" metadata: dict[str, Any] = {} contents: list[Content] = [] + local_shell_tool_name = self._get_local_shell_tool_name(options.get("tools")) conversation_id: str | None = None response_id: str | None = None continuation_token: OpenAIContinuationToken | None = None @@ -1746,16 +1960,59 @@ def _parse_chunk_from_openai( s_commands = list(getattr(s_action, "commands", []) or []) s_timeout_ms = getattr(s_action, "timeout_ms", None) s_max_output = getattr(s_action, "max_output_length", None) - contents.append( - Content.from_shell_tool_call( - call_id=s_call_id, - commands=s_commands, - timeout_ms=s_timeout_ms, - max_output_length=s_max_output, - status=getattr(event_item, "status", None), - raw_representation=event_item, + if local_shell_tool_name: + command_text = self._join_shell_commands(s_commands) + contents.append( + Content.from_function_call( + call_id=s_call_id, + name=local_shell_tool_name, + arguments=json.dumps({"command": command_text}), + additional_properties={ + OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL, + OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: s_commands, + }, + raw_representation=event_item, + ) + ) + else: + contents.append( + Content.from_shell_tool_call( + call_id=s_call_id, + commands=s_commands, + timeout_ms=s_timeout_ms, + max_output_length=s_max_output, + status=getattr(event_item, "status", None), + raw_representation=event_item, + ) + ) + case "local_shell_call": + local_call_id = getattr(event_item, "call_id", None) or "" + local_command_parts = list(getattr(getattr(event_item, "action", None), "command", []) or []) + local_command = shlex.join(local_command_parts) if local_command_parts else "" + if local_shell_tool_name: + contents.append( + Content.from_function_call( + call_id=local_call_id, + name=local_shell_tool_name, + arguments=json.dumps({"command": local_command}), + additional_properties={ + OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL, + OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: getattr(event_item, "id", None), + OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: local_command_parts, + }, + raw_representation=event_item, + ) + ) + else: + contents.append( + Content.from_shell_tool_call( + call_id=local_call_id, + commands=[local_command] if local_command else [], + timeout_ms=getattr(getattr(event_item, "action", None), "timeout_ms", None), + status=getattr(event_item, "status", None), + raw_representation=event_item, + ) ) - ) case "shell_call_output": # ResponseFunctionShellToolCallOutput s_out_call_id = getattr(event_item, "call_id", None) or "" s_outputs: list[Content] = [] diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 2edfd500c5..2673e303a6 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -31,6 +31,7 @@ ChatResponse, ChatResponseUpdate, Content, + FunctionTool, Message, SupportsChatGetResponse, tool, @@ -38,6 +39,7 @@ from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai._exceptions import OpenAIContentFilterException +from agent_framework.openai._responses_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY skip_if_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), @@ -564,19 +566,235 @@ def test_response_content_creation_with_code_interpreter() -> None: assert any(out.type == "uri" for out in result_content.outputs) -def test_get_hosted_shell_tool_basic() -> None: - """Test get_hosted_shell_tool returns correct tool type with default auto environment.""" - tool = OpenAIResponsesClient.get_hosted_shell_tool() +def test_get_shell_tool_basic() -> None: + """Test get_shell_tool returns hosted shell config with default auto environment.""" + tool = OpenAIResponsesClient.get_shell_tool() assert tool.type == "shell" assert tool.environment.type == "container_auto" -def test_get_hosted_shell_tool_with_custom_environment() -> None: - """Test get_hosted_shell_tool with custom environment configuration.""" - env = {"type": "container_auto", "file_ids": ["file-abc123"]} - tool = OpenAIResponsesClient.get_hosted_shell_tool(environment=env) - assert tool.type == "shell" - assert tool.environment.file_ids == ["file-abc123"] +def test_get_shell_tool_rejects_local_without_func() -> None: + """Local environment requires a local function executor.""" + with pytest.raises(ValueError, match="Local shell requires func"): + OpenAIResponsesClient.get_shell_tool(environment={"type": "local"}) + + +def test_get_shell_tool_rejects_environment_config_with_func() -> None: + """Environment config is hosted-only and must not be passed with func.""" + + def local_exec(command: str) -> str: + return command + + with pytest.raises(ValueError, match="environment config is not supported"): + OpenAIResponsesClient.get_shell_tool( + func=local_exec, + environment={"type": "container_auto"}, + ) + + +def test_get_shell_tool_local_executor_maps_to_shell_tool() -> None: + """Test local shell FunctionTool maps to OpenAI shell tool declaration.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIResponsesClient.get_shell_tool( + func=local_exec, + approval_mode="never_require", + ) + + assert isinstance(local_shell_tool, FunctionTool) + response_tools = client._prepare_tools_for_openai([local_shell_tool]) + assert len(response_tools) == 1 + assert response_tools[0].type == "shell" + assert response_tools[0].environment.type == "local" + + +def test_response_content_creation_with_local_shell_call_maps_to_function_call() -> None: + """Test local_shell_call is translated into function_call for invocation loop.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIResponsesClient.get_shell_tool(func=local_exec) + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.status = "completed" + mock_response.incomplete = None + + mock_action = MagicMock() + mock_action.command = ["python", "--version"] + mock_action.timeout_ms = 30000 + + mock_local_shell_call = MagicMock() + mock_local_shell_call.type = "local_shell_call" + mock_local_shell_call.id = "local-shell-item-1" + mock_local_shell_call.call_id = "local-shell-call-1" + mock_local_shell_call.action = mock_action + mock_local_shell_call.status = "completed" + + mock_response.output = [mock_local_shell_call] + + response = client._parse_response_from_openai(mock_response, options={"tools": [local_shell_tool]}) # type: ignore[arg-type] + assert len(response.messages[0].contents) == 1 + call_content = response.messages[0].contents[0] + assert call_content.type == "function_call" + assert call_content.call_id == "local-shell-call-1" + assert call_content.name == local_shell_tool.name + assert call_content.parse_arguments() == {"command": "python --version"} + assert call_content.additional_properties[OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY] == "local-shell-item-1" + + +@pytest.mark.asyncio +async def test_local_shell_tool_is_invoked_in_function_loop() -> None: + """Test local shell call executes executor and sends local_shell_call_output.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + executed_commands: list[str] = [] + + def local_exec(command: str) -> str: + executed_commands.append(command) + return "Python 3.13.0" + + local_shell_tool = OpenAIResponsesClient.get_shell_tool( + func=local_exec, + approval_mode="never_require", + ) + + mock_response1 = MagicMock() + mock_response1.output_parsed = None + mock_response1.metadata = {} + mock_response1.usage = None + mock_response1.id = "resp-1" + mock_response1.model = "test-model" + mock_response1.created_at = 1000000000 + mock_response1.status = "completed" + mock_response1.incomplete = None + + mock_action = MagicMock() + mock_action.command = ["python", "--version"] + mock_action.timeout_ms = 30000 + + mock_local_shell_call = MagicMock() + mock_local_shell_call.type = "local_shell_call" + mock_local_shell_call.id = "local-shell-item-1" + mock_local_shell_call.call_id = "local-shell-call-1" + mock_local_shell_call.action = mock_action + mock_local_shell_call.status = "completed" + mock_response1.output = [mock_local_shell_call] + + mock_response2 = MagicMock() + mock_response2.output_parsed = None + mock_response2.metadata = {} + mock_response2.usage = None + mock_response2.id = "resp-2" + mock_response2.model = "test-model" + mock_response2.created_at = 1000000001 + mock_response2.status = "completed" + mock_response2.incomplete = None + + mock_text_item = MagicMock() + mock_text_item.type = "message" + mock_text_content = MagicMock() + mock_text_content.type = "output_text" + mock_text_content.text = "Python 3.13.0" + mock_text_item.content = [mock_text_content] + mock_response2.output = [mock_text_item] + + with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create: + await client.get_response( + messages=[Message(role="user", text="What Python version is available?")], + options={"tools": [local_shell_tool]}, + ) + + assert executed_commands == ["python --version"] + assert mock_create.call_count == 2 + second_call_input = mock_create.call_args_list[1].kwargs["input"] + local_shell_outputs = [item for item in second_call_input if item.get("type") == "local_shell_call_output"] + assert len(local_shell_outputs) == 1 + output_payload = json.loads(local_shell_outputs[0]["output"]) + assert output_payload["stdout"] == "Python 3.13.0" + + +@pytest.mark.asyncio +async def test_shell_call_is_invoked_as_local_shell_function_loop() -> None: + """Test shell_call maps to local function invocation and returns shell_call_output.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + executed_commands: list[str] = [] + + def local_exec(command: str) -> str: + executed_commands.append(command) + return "Python 3.13.0" + + local_shell_tool = OpenAIResponsesClient.get_shell_tool( + func=local_exec, + approval_mode="never_require", + ) + + mock_response1 = MagicMock() + mock_response1.output_parsed = None + mock_response1.metadata = {} + mock_response1.usage = None + mock_response1.id = "resp-1" + mock_response1.model = "test-model" + mock_response1.created_at = 1000000000 + mock_response1.status = "completed" + mock_response1.incomplete = None + + mock_action = MagicMock() + mock_action.commands = ["python --version"] + mock_action.timeout_ms = 30000 + mock_action.max_output_length = 4096 + + mock_shell_call = MagicMock() + mock_shell_call.type = "shell_call" + mock_shell_call.id = "sh_test_shell_call_1" + mock_shell_call.call_id = "shell-call-1" + mock_shell_call.action = mock_action + mock_shell_call.status = "completed" + mock_response1.output = [mock_shell_call] + + mock_response2 = MagicMock() + mock_response2.output_parsed = None + mock_response2.metadata = {} + mock_response2.usage = None + mock_response2.id = "resp-2" + mock_response2.model = "test-model" + mock_response2.created_at = 1000000001 + mock_response2.status = "completed" + mock_response2.incomplete = None + + mock_text_item = MagicMock() + mock_text_item.type = "message" + mock_text_content = MagicMock() + mock_text_content.type = "output_text" + mock_text_content.text = "Python 3.13.0" + mock_text_item.content = [mock_text_content] + mock_response2.output = [mock_text_item] + + with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create: + await client.get_response( + messages=[Message(role="user", text="What Python version is available?")], + options={"tools": [local_shell_tool]}, + ) + + assert executed_commands == ["python --version"] + assert mock_create.call_count == 2 + second_call_input = mock_create.call_args_list[1].kwargs["input"] + shell_outputs = [item for item in second_call_input if item.get("type") == "shell_call_output"] + assert len(shell_outputs) == 1 + assert shell_outputs[0]["call_id"] == "shell-call-1" + assert isinstance(shell_outputs[0]["output"], list) + assert shell_outputs[0]["output"][0]["stdout"] == "Python 3.13.0" + local_shell_outputs = [item for item in second_call_input if item.get("type") == "local_shell_call_output"] + assert len(local_shell_outputs) == 0 def test_response_content_creation_with_shell_call() -> None: diff --git a/python/pyproject.toml b/python/pyproject.toml index e4e45f0290..5547a077db 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -148,8 +148,8 @@ ignore = [ "**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] "samples/**" = ["D", "INP", "ERA001", "RUF", "S", "T201", "CPY"] "*.ipynb" = ["CPY", "E501"] -# RUF070: Assignment before yield is intentional - context manager must exit before yielding -"**/agent_framework/_workflows/_workflow.py" = ["RUF070"] +# Ignore Ruff-specific rules for workflow context manager yield pattern. +"**/agent_framework/_workflows/_workflow.py" = ["RUF"] [tool.ruff.format] docstring-code-format = true diff --git a/python/samples/02-agents/providers/anthropic/anthropic_with_shell.py b/python/samples/02-agents/providers/anthropic/anthropic_with_shell.py index dc30d706da..40c6aedc43 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_with_shell.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_with_shell.py @@ -2,8 +2,9 @@ import asyncio import subprocess +from typing import Any -from agent_framework import Agent, shell_tool +from agent_framework import Agent, Message, tool from agent_framework.anthropic import AnthropicClient from dotenv import load_dotenv @@ -13,7 +14,7 @@ """ Anthropic Client with Shell Tool Example -This sample demonstrates using ShellTool with AnthropicClient +This sample demonstrates using @tool(approval_mode=...) with AnthropicClient for executing bash commands locally. The bash tool tells the model it can request shell commands, while the actual execution happens on YOUR machine via a user-provided function. @@ -24,17 +25,9 @@ """ -@shell_tool +@tool(approval_mode="always_require") def run_bash(command: str) -> str: - """Execute a bash command using subprocess and return the output. - - Prints the command and asks the user for confirmation before running. - """ - print(f"\n[Shell] Command: {command}") - answer = input("[Shell] Execute? (y/n): ").strip().lower() - if answer != "y": - return "Command rejected by user." - + """Execute a bash command using subprocess and return the output.""" try: result = subprocess.run( command, @@ -62,18 +55,46 @@ async def main() -> None: print("NOTE: Commands will execute on your local machine.\n") client = AnthropicClient() - + shell = client.get_shell_tool(func=run_bash) agent = Agent( client=client, instructions="You are a helpful assistant that can execute bash commands to answer questions.", - tools=[run_bash], + tools=[shell], ) query = "Use bash to print 'Hello from Anthropic shell!' and show the current working directory" print(f"User: {query}") - result = await agent.run(query) + result = await run_with_approvals(query, agent) print(f"Result: {result}\n") +async def run_with_approvals(query: str, agent: Agent) -> Any: + """Run the agent and handle shell approvals outside tool execution.""" + current_input: str | list[Any] = query + while True: + result = await agent.run(current_input) + if not result.user_input_requests: + return result + + next_input: list[Any] = [query] + rejected = False + for user_input_needed in result.user_input_requests: + print( + f"\nShell request: {user_input_needed.function_call.name}" + f"\nArguments: {user_input_needed.function_call.arguments}" + ) + user_approval = await asyncio.to_thread(input, "\nApprove shell command? (y/n): ") + approved = user_approval.strip().lower() == "y" + next_input.append(Message("assistant", [user_input_needed])) + next_input.append(Message("user", [user_input_needed.to_function_approval_response(approved)])) + if not approved: + rejected = True + break + if rejected: + print("\nShell command rejected. Stopping without additional approval prompts.") + return "Shell command execution was rejected by user." + current_input = next_input + + if __name__ == "__main__": asyncio.run(main()) diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py index 69ce970492..b3135702a7 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_local_shell.py @@ -2,8 +2,9 @@ import asyncio import subprocess +from typing import Any -from agent_framework import Agent, shell_tool +from agent_framework import Agent, Message, tool from agent_framework.openai import OpenAIResponsesClient from dotenv import load_dotenv @@ -13,8 +14,8 @@ """ OpenAI Responses Client with Local Shell Tool Example -This sample demonstrates implementing a local shell tool using ShellTool -that wraps Python's subprocess module. Unlike the hosted shell tool (get_hosted_shell_tool()), +This sample demonstrates implementing a local shell tool using get_shell_tool(func=...) +that wraps Python's subprocess module. Unlike the hosted shell tool (get_shell_tool()), local shell execution runs commands on YOUR machine, not in a remote container. SECURITY NOTE: This example executes real commands on your local machine. @@ -23,17 +24,9 @@ """ -@shell_tool +@tool(approval_mode="always_require") def run_bash(command: str) -> str: - """Execute a shell command locally and return stdout, stderr, and exit code. - - Prints the command and asks the user for confirmation before running. - """ - print(f"\n[Shell] Command: {command}") - answer = input("[Shell] Execute? (y/n): ").strip().lower() - if answer != "y": - return "Command rejected by user." - + """Execute a shell command locally and return stdout, stderr, and exit code.""" try: result = subprocess.run( command, @@ -44,9 +37,9 @@ def run_bash(command: str) -> str: ) parts: list[str] = [] if result.stdout: - parts.append(f"stdout:\n{result.stdout}") + parts.append(result.stdout) if result.stderr: - parts.append(f"stderr:\n{result.stderr}") + parts.append(f"stderr: {result.stderr}") parts.append(f"exit_code: {result.returncode}") return "\n".join(parts) except subprocess.TimeoutExpired: @@ -61,16 +54,62 @@ async def main() -> None: print("NOTE: Commands will execute on your local machine.\n") client = OpenAIResponsesClient() + local_shell_tool = client.get_shell_tool( + func=run_bash, + ) + agent = Agent( client=client, instructions="You are a helpful assistant that can run shell commands to help the user.", - tools=[run_bash], + tools=[local_shell_tool], ) - query = "What Python version is installed on this machine?" + query = "Use the run_bash tool to execute `python --version` and show only the command output." print(f"User: {query}") - result = await agent.run(query) - print(f"Agent: {result.text}\n") + result = await run_with_approvals(query, agent) + if isinstance(result, str): + print(f"Agent: {result}\n") + return + if result.text: + print(f"Agent: {result.text}\n") + else: + printed = False + for message in result.messages: + for content in message.contents: + if content.type == "function_result" and content.result: + print(f"Agent (tool output): {content.result}\n") + printed = True + if not printed: + print("Agent: (no text output returned)\n") + + +async def run_with_approvals(query: str, agent: Agent) -> Any: + """Run the agent and handle shell approvals outside tool execution.""" + current_input: str | list[Any] = query + + while True: + result = await agent.run(current_input) + if not result.user_input_requests: + return result + + next_input: list[Any] = [query] + rejected = False + for user_input_needed in result.user_input_requests: + print( + f"\nShell request: {user_input_needed.function_call.name}" + f"\nArguments: {user_input_needed.function_call.arguments}" + ) + user_approval = await asyncio.to_thread(input, "\nApprove shell command? (y/n): ") + approved = user_approval.strip().lower() == "y" + next_input.append(Message("assistant", [user_input_needed])) + next_input.append(Message("user", [user_input_needed.to_function_approval_response(approved)])) + if not approved: + rejected = True + break + if rejected: + print("\nShell command rejected. Stopping without additional approval prompts.") + return "Shell command execution was rejected by user." + current_input = next_input if __name__ == "__main__": diff --git a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py b/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py index 248e33c075..b86f36fde5 100644 --- a/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py +++ b/python/samples/02-agents/providers/openai/openai_responses_client_with_shell.py @@ -12,7 +12,7 @@ """ OpenAI Responses Client with Shell Tool Example -This sample demonstrates using get_hosted_shell_tool() with OpenAI Responses Client +This sample demonstrates using get_shell_tool() with OpenAI Responses Client for executing shell commands in a managed container environment hosted by OpenAI. The shell tool allows the model to run commands like listing files, running scripts, @@ -27,7 +27,7 @@ async def main() -> None: client = OpenAIResponsesClient() # Create a hosted shell tool with the default auto container environment - shell_tool = client.get_hosted_shell_tool() + shell_tool = client.get_shell_tool() agent = Agent( client=client, From bbe604edb9d36ad88d0477595137f862bc908563 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:28:38 -0800 Subject: [PATCH 5/7] Reverted ruff change --- python/pyproject.toml | 4 +-- python/uv.lock | 57 ++++++++++++++++++++----------------------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 5547a077db..e4e45f0290 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -148,8 +148,8 @@ ignore = [ "**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] "samples/**" = ["D", "INP", "ERA001", "RUF", "S", "T201", "CPY"] "*.ipynb" = ["CPY", "E501"] -# Ignore Ruff-specific rules for workflow context manager yield pattern. -"**/agent_framework/_workflows/_workflow.py" = ["RUF"] +# RUF070: Assignment before yield is intentional - context manager must exit before yielding +"**/agent_framework/_workflows/_workflow.py" = ["RUF070"] [tool.ruff.format] docstring-code-format = true diff --git a/python/uv.lock b/python/uv.lock index 9f2e97a91e..a3183b94c2 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2323,7 +2323,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2331,7 +2330,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2340,7 +2338,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2349,7 +2346,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2358,7 +2354,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2367,7 +2362,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -2843,6 +2837,9 @@ name = "jsonpath-ng" version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/32/58/250751940d75c8019659e15482d548a4aa3b6ce122c515102a4bfdac50e3/jsonpath_ng-1.8.0.tar.gz", hash = "sha256:54252968134b5e549ea5b872f1df1168bd7defe1a52fed5a358c194e1943ddc3", size = 74513, upload-time = "2026-02-24T14:42:06.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/99/33c7d78a3fb70d545fd5411ac67a651c81602cc09c9cf0df383733f068c5/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138", size = 67844, upload-time = "2026-02-28T00:53:19.637Z" }, +] [[package]] name = "jsonschema" @@ -4095,15 +4092,15 @@ wheels = [ [[package]] name = "opentelemetry-semantic-conventions-ai" -version = "0.4.14" +version = "0.4.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/33/f77151a8c9bf93094074533ea751305e9f3fec1a4197b0f218d09cb8dce2/opentelemetry_semantic_conventions_ai-0.4.14.tar.gz", hash = "sha256:0495774011933010db7dbfa5111a2fa649edeedef922e39c898154c81eae89d8", size = 18418, upload-time = "2026-02-22T20:25:34.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/75/455c15f8360b475dd31101a87eab316420388486f7941bf019cbf4e63d5b/opentelemetry_semantic_conventions_ai-0.4.15.tar.gz", hash = "sha256:12de172d1e11d21c6e82bbf578c7e8a713589a7fda76af9ed785632564a28b81", size = 18595, upload-time = "2026-03-02T15:36:50.254Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/d5/cdc62ce0f7357cd91682bb1e31d7a68a3c0da5abdbd8c69ffa9aec555f1b/opentelemetry_semantic_conventions_ai-0.4.14-py3-none-any.whl", hash = "sha256:218e0bf656b1d459c5bc608e2a30272b7ab0a4a5b69c1bd5b659c3918f4ad144", size = 5824, upload-time = "2026-02-22T20:25:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/819fb212386f77cfd93f81bd916d674f0e735f87c8ac2262ed14e3b852c2/opentelemetry_semantic_conventions_ai-0.4.15-py3-none-any.whl", hash = "sha256:011461f1fba30f27035c49ab3b8344367adc72da0a6c8d3c7428303c6779edc9", size = 5999, upload-time = "2026-03-02T15:36:51.44Z" }, ] [[package]] @@ -5720,27 +5717,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, - { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, - { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, - { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, - { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, - { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] From aa441d491c5920823fa89f4705151318dfa1b394 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:48:38 -0800 Subject: [PATCH 6/7] Fixed tests --- .../core/tests/openai/test_openai_responses_client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 2673e303a6..27af564602 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -676,6 +676,7 @@ def local_exec(command: str) -> str: mock_response1.model = "test-model" mock_response1.created_at = 1000000000 mock_response1.status = "completed" + mock_response1.finish_reason = "tool_calls" mock_response1.incomplete = None mock_action = MagicMock() @@ -698,6 +699,7 @@ def local_exec(command: str) -> str: mock_response2.model = "test-model" mock_response2.created_at = 1000000001 mock_response2.status = "completed" + mock_response2.finish_reason = "stop" mock_response2.incomplete = None mock_text_item = MagicMock() @@ -746,6 +748,7 @@ def local_exec(command: str) -> str: mock_response1.model = "test-model" mock_response1.created_at = 1000000000 mock_response1.status = "completed" + mock_response1.finish_reason = "tool_calls" mock_response1.incomplete = None mock_action = MagicMock() @@ -769,6 +772,7 @@ def local_exec(command: str) -> str: mock_response2.model = "test-model" mock_response2.created_at = 1000000001 mock_response2.status = "completed" + mock_response2.finish_reason = "stop" mock_response2.incomplete = None mock_text_item = MagicMock() From 5d3930e452bdd4794f791606077e1460eee81b1c Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:14:11 -0800 Subject: [PATCH 7/7] Addressed comments --- .../agent_framework_anthropic/_chat_client.py | 26 ++++++---------- .../anthropic/tests/test_anthropic_client.py | 28 ++++++++++++++--- .../packages/core/agent_framework/_tools.py | 10 ++++++- .../openai/_assistants_client.py | 15 ++++++++-- .../openai/_responses_client.py | 30 +++++++------------ .../openai/test_openai_assistants_client.py | 29 +++++++++--------- .../openai/test_openai_responses_client.py | 20 +++++++++++++ 7 files changed, 99 insertions(+), 59 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 9ed9abae83..f9c2b99a6b 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -28,7 +28,7 @@ tool, ) from agent_framework._settings import SecretString, load_settings -from agent_framework._tools import SHELL_TOOL_KIND_KEY, SHELL_TOOL_KIND_VALUE +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 @@ -411,6 +411,10 @@ def get_shell_tool( 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, @@ -419,24 +423,15 @@ def get_shell_tool( ) additional_properties: dict[str, Any] = dict(base_tool.additional_properties or {}) - additional_properties[SHELL_TOOL_KIND_KEY] = SHELL_TOOL_KIND_VALUE if type_name: additional_properties["type"] = type_name if base_tool.func is None: raise ValueError("Shell tool requires an executable function.") - return FunctionTool( - name=base_tool.name, - description=description if description is not None else base_tool.description, - approval_mode=approval_mode or base_tool.approval_mode, - max_invocations=base_tool.max_invocations, - max_invocation_exceptions=base_tool.max_invocation_exceptions, - additional_properties=additional_properties, - func=base_tool.func, - input_model=base_tool.parameters() if base_tool._schema_supplied else base_tool.input_model, - result_parser=base_tool.result_parser, - ) + base_tool.additional_properties = additional_properties + base_tool.kind = SHELL_TOOL_KIND_VALUE + return base_tool @staticmethod def get_mcp_tool( @@ -776,10 +771,7 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, mcp_server_list: list[Any] = [] tool_name_aliases: dict[str, str] = {} for tool in tools: - if ( - isinstance(tool, FunctionTool) - and (tool.additional_properties or {}).get(SHELL_TOOL_KIND_KEY) == SHELL_TOOL_KIND_VALUE - ): + 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({ diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 8d4686c04d..028e49673a 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -14,7 +14,7 @@ tool, ) from agent_framework._settings import load_settings -from agent_framework._tools import SHELL_TOOL_KIND_KEY, SHELL_TOOL_KIND_VALUE +from agent_framework._tools import SHELL_TOOL_KIND_VALUE from anthropic.types.beta import ( BetaMessage, BetaTextBlock, @@ -425,7 +425,7 @@ def test_prepare_tools_for_anthropic_shell_tool(mock_anthropic_client: MagicMock """Test converting tool-decorated FunctionTool to Anthropic bash format.""" client = create_test_anthropic_client(mock_anthropic_client) - @tool(additional_properties={SHELL_TOOL_KIND_KEY: SHELL_TOOL_KIND_VALUE}) + @tool(kind=SHELL_TOOL_KIND_VALUE) def run_bash(command: str) -> str: return _dummy_bash(command) @@ -444,7 +444,7 @@ def test_prepare_tools_for_anthropic_shell_tool_custom_type(mock_anthropic_clien """Test shell tool with custom type via additional_properties.""" client = create_test_anthropic_client(mock_anthropic_client) - @tool(additional_properties={SHELL_TOOL_KIND_KEY: SHELL_TOOL_KIND_VALUE, "type": "bash_20241022"}) + @tool(kind=SHELL_TOOL_KIND_VALUE, additional_properties={"type": "bash_20241022"}) def run_bash(command: str) -> str: return _dummy_bash(command) @@ -465,7 +465,7 @@ def test_prepare_tools_for_anthropic_shell_tool_does_not_mutate_name(mock_anthro @tool( name="run_local_shell", approval_mode="never_require", - additional_properties={SHELL_TOOL_KIND_KEY: SHELL_TOOL_KIND_VALUE}, + kind=SHELL_TOOL_KIND_VALUE, ) def run_local_shell(command: str) -> str: return command @@ -478,6 +478,26 @@ def run_local_shell(command: str) -> str: 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) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index bce1bf13f0..303699572c 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -79,7 +79,6 @@ DEFAULT_MAX_ITERATIONS: Final[int] = 40 DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3 -SHELL_TOOL_KIND_KEY: Final[str] = "agent_framework.tool_kind" SHELL_TOOL_KIND_VALUE: Final[str] = "shell" ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") # region Helpers @@ -239,6 +238,7 @@ def __init__( name: str, description: str = "", approval_mode: Literal["always_require", "never_require"] | None = None, + kind: str | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, @@ -254,6 +254,8 @@ def __init__( description: A description of the function. approval_mode: Whether or not approval is required to run this tool. Default is that approval is NOT required (``"never_require"``). + kind: Optional provider-agnostic tool classification + (for example ``"shell"``). max_invocations: The maximum number of times this function can be invoked across the **lifetime of this tool instance**. If None (default), there is no limit. Should be at least 1. If the tool is called multiple @@ -298,6 +300,7 @@ def __init__( # Core attributes (formerly from BaseTool) self.name = name self.description = description + self.kind = kind self.additional_properties = additional_properties for key, value in kwargs.items(): setattr(self, key, value) @@ -1079,6 +1082,7 @@ def tool( description: str | None = None, schema: type[BaseModel] | Mapping[str, Any] | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, + kind: str | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, @@ -1094,6 +1098,7 @@ def tool( description: str | None = None, schema: type[BaseModel] | Mapping[str, Any] | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, + kind: str | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, @@ -1108,6 +1113,7 @@ def tool( description: str | None = None, schema: type[BaseModel] | Mapping[str, Any] | None = None, approval_mode: Literal["always_require", "never_require"] | None = None, + kind: str | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, @@ -1147,6 +1153,7 @@ def tool( function's signature. Defaults to ``None`` (infer from signature). approval_mode: Whether or not approval is required to run this tool. Default is that approval is NOT required (``"never_require"``). + kind: Optional provider-agnostic tool classification. max_invocations: The maximum number of times this function can be invoked across the **lifetime of this tool instance**. If None (default), there is no limit. Should be at least 1. For per-request limits, use @@ -1247,6 +1254,7 @@ def wrapper(f: Callable[..., Any]) -> FunctionTool: name=tool_name, description=tool_desc, approval_mode=approval_mode, + kind=kind, max_invocations=max_invocations, max_invocation_exceptions=max_invocation_exceptions, additional_properties=additional_properties or {}, diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 1c8aafc94e..17b801a36a 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -639,9 +639,15 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter additional_properties=props, raw_representation=completed_annotation, ) - if completed_annotation.file_citation and completed_annotation.file_citation.file_id: + if ( + completed_annotation.file_citation + and completed_annotation.file_citation.file_id + ): ann["file_id"] = completed_annotation.file_citation.file_id - if completed_annotation.start_index is not None and completed_annotation.end_index is not None: + if ( + completed_annotation.start_index is not None + and completed_annotation.end_index is not None + ): ann["annotated_regions"] = [ TextSpanRegion( type="text_span", @@ -660,7 +666,10 @@ async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIter ) if completed_annotation.file_path and completed_annotation.file_path.file_id: ann["file_id"] = completed_annotation.file_path.file_id - if completed_annotation.start_index is not None and completed_annotation.end_index is not None: + if ( + completed_annotation.start_index is not None + and completed_annotation.end_index is not None + ): ann["annotated_regions"] = [ TextSpanRegion( type="text_span", diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index e47660fcf4..f11b60b767 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -43,7 +43,6 @@ from .._middleware import ChatMiddlewareLayer from .._settings import load_settings from .._tools import ( - SHELL_TOOL_KIND_KEY, SHELL_TOOL_KIND_VALUE, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -459,10 +458,7 @@ def _prepare_tools_for_openai( return [] response_tools: list[Any] = [] for tool_item in tools_list: - if ( - isinstance(tool_item, FunctionTool) - and (tool_item.additional_properties or {}).get(SHELL_TOOL_KIND_KEY) == SHELL_TOOL_KIND_VALUE - ): + if isinstance(tool_item, FunctionTool) and tool_item.kind == SHELL_TOOL_KIND_VALUE: shell_env = (tool_item.additional_properties or {}).get(OPENAI_SHELL_ENVIRONMENT_KEY) if isinstance(shell_env, Mapping): response_tools.append( @@ -497,7 +493,7 @@ def _get_local_shell_tool_name( for tool_item in normalize_tools(tools): if not isinstance(tool_item, FunctionTool): continue - if (tool_item.additional_properties or {}).get(SHELL_TOOL_KIND_KEY) != SHELL_TOOL_KIND_VALUE: + if tool_item.kind != SHELL_TOOL_KIND_VALUE: continue shell_env = (tool_item.additional_properties or {}).get(OPENAI_SHELL_ENVIRONMENT_KEY) if isinstance(shell_env, Mapping) and shell_env.get("type") == "local": @@ -727,6 +723,12 @@ def get_shell_tool( base_tool: FunctionTool if isinstance(func, FunctionTool): base_tool = func + if name is not None: + base_tool.name = name + 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, @@ -739,20 +741,10 @@ def get_shell_tool( raise ValueError("Shell tool requires an executable function.") additional_properties = dict(base_tool.additional_properties or {}) - additional_properties[SHELL_TOOL_KIND_KEY] = SHELL_TOOL_KIND_VALUE additional_properties[OPENAI_SHELL_ENVIRONMENT_KEY] = local_env - - return FunctionTool( - name=name or base_tool.name, - description=description if description is not None else base_tool.description, - approval_mode=approval_mode or base_tool.approval_mode, - max_invocations=base_tool.max_invocations, - max_invocation_exceptions=base_tool.max_invocation_exceptions, - additional_properties=additional_properties, - func=base_tool.func, - input_model=base_tool.parameters() if base_tool._schema_supplied else base_tool.input_model, - result_parser=base_tool.result_parser, - ) + base_tool.additional_properties = additional_properties + base_tool.kind = SHELL_TOOL_KIND_VALUE + return base_tool @staticmethod def get_mcp_tool( diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index 1ce40eeba0..21f7173ca3 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -7,19 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import ( - Agent, - AgentResponse, - AgentResponseUpdate, - AgentSession, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - SupportsChatGetResponse, - tool, -) -from agent_framework.openai import OpenAIAssistantsClient from openai.types.beta.threads import ( FileCitationAnnotation, FilePathAnnotation, @@ -35,6 +22,20 @@ from openai.types.beta.threads.runs import RunStep from pydantic import Field +from agent_framework import ( + Agent, + AgentResponse, + AgentResponseUpdate, + AgentSession, + ChatResponse, + ChatResponseUpdate, + Content, + Message, + SupportsChatGetResponse, + tool, +) +from agent_framework.openai import OpenAIAssistantsClient + skip_if_openai_integration_tests_disabled = pytest.mark.skipif( os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"), reason="No real OPENAI_API_KEY provided; skipping integration tests.", @@ -1720,8 +1721,6 @@ async def test_message_completed_with_file_citation(self, client): assert ann["annotated_regions"][0]["start_index"] == 10 assert ann["annotated_regions"][0]["end_index"] == 24 - - @pytest.mark.asyncio async def test_message_completed_with_file_path(self, client): """Verify file path annotations are extracted from completed messages.""" diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 27af564602..e049dbd16e 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -611,6 +611,26 @@ def local_exec(command: str) -> str: assert response_tools[0].environment.type == "local" +def test_get_shell_tool_reuses_function_tool_instance() -> None: + """Passing a FunctionTool should update and return the same tool instance.""" + + @tool(name="run_shell", approval_mode="never_require") + def run_shell(command: str) -> str: + return command + + shell_tool = OpenAIResponsesClient.get_shell_tool( + func=run_shell, + description="Run local shell command", + approval_mode="always_require", + ) + + assert shell_tool is run_shell + assert shell_tool.kind == "shell" + assert shell_tool.description == "Run local shell command" + assert shell_tool.approval_mode == "always_require" + assert (shell_tool.additional_properties or {}).get("openai.responses.shell.environment") == {"type": "local"} + + def test_response_content_creation_with_local_shell_call_maps_to_function_call() -> None: """Test local_shell_call is translated into function_call for invocation loop.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")