mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Added Shell tool (#4339)
* Added shell tool * Fixed CI error * Add ShellTool support for OpenAI and Anthropic providers - Add shell_tool_call, shell_tool_result, and shell_command_output content types - Add ShellTool class and shell_tool decorator to core - Add get_hosted_shell_tool() to OpenAI Responses client - Handle shell_call and shell_call_output parsing in OpenAI (sync and streaming) - Map ShellTool to Anthropic bash tool API format - Parse bash_code_execution_tool_result as shell_tool_result in Anthropic - Add unit tests for all new functionality - Add sample scripts for hosted and local shell execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Addressed comments * Reverted ruff change * Fixed tests * Addressed comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
dae3caa719
commit
1c0ae4b659
@@ -947,7 +947,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
|
||||
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 (
|
||||
|
||||
@@ -79,6 +79,7 @@ logger = logging.getLogger("agent_framework")
|
||||
|
||||
DEFAULT_MAX_ITERATIONS: Final[int] = 40
|
||||
DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3
|
||||
SHELL_TOOL_KIND_VALUE: Final[str] = "shell"
|
||||
ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]")
|
||||
# region Helpers
|
||||
|
||||
@@ -237,6 +238,7 @@ class FunctionTool(SerializationMixin):
|
||||
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,
|
||||
@@ -252,6 +254,8 @@ class FunctionTool(SerializationMixin):
|
||||
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
|
||||
@@ -296,6 +300,7 @@ class FunctionTool(SerializationMixin):
|
||||
# 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)
|
||||
@@ -1077,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,
|
||||
@@ -1092,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,
|
||||
@@ -1106,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,
|
||||
@@ -1145,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
|
||||
@@ -1245,6 +1254,7 @@ def tool(
|
||||
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 {},
|
||||
@@ -1390,6 +1400,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
|
||||
@@ -1430,6 +1441,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:
|
||||
@@ -1443,6 +1455,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."
|
||||
@@ -1452,6 +1465,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
|
||||
@@ -1477,6 +1491,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 MiddlewareTermination as term_exc:
|
||||
# Re-raise to signal loop termination, but first capture any result set by middleware
|
||||
@@ -1485,6 +1500,7 @@ async def _auto_invoke_function(
|
||||
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:
|
||||
@@ -1495,6 +1511,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -340,6 +340,9 @@ ContentType = Literal[
|
||||
"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 @@ class Content:
|
||||
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 @@ class Content:
|
||||
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,112 @@ class Content:
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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 for one command execution.
|
||||
|
||||
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 +1161,14 @@ class Content:
|
||||
"inputs",
|
||||
"outputs",
|
||||
"image_id",
|
||||
"commands",
|
||||
"timeout_ms",
|
||||
"max_output_length",
|
||||
"status",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"exit_code",
|
||||
"timed_out",
|
||||
"tool_name",
|
||||
"server_name",
|
||||
"output",
|
||||
|
||||
@@ -639,9 +639,15 @@ class OpenAIAssistantsClient( # type: ignore[misc]
|
||||
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 @@ class OpenAIAssistantsClient( # type: ignore[misc]
|
||||
)
|
||||
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",
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import (
|
||||
AsyncIterable,
|
||||
@@ -17,6 +19,7 @@ from itertools import chain
|
||||
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 (
|
||||
@@ -40,11 +43,13 @@ from .._clients import BaseChatClient
|
||||
from .._middleware import ChatMiddlewareLayer
|
||||
from .._settings import load_settings
|
||||
from .._tools import (
|
||||
SHELL_TOOL_KIND_VALUE,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
FunctionTool,
|
||||
ToolTypes,
|
||||
normalize_tools,
|
||||
tool,
|
||||
)
|
||||
from .._types import (
|
||||
Annotation,
|
||||
@@ -92,6 +97,12 @@ if TYPE_CHECKING:
|
||||
)
|
||||
|
||||
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):
|
||||
@@ -432,7 +443,9 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
) -> 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.
|
||||
@@ -444,24 +457,49 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
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.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(
|
||||
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.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":
|
||||
return tool_item.name
|
||||
return None
|
||||
|
||||
# region Hosted Tool Factory Methods
|
||||
|
||||
@staticmethod
|
||||
@@ -622,6 +660,92 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
|
||||
return tool
|
||||
|
||||
@staticmethod
|
||||
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 shell tool for the Responses API.
|
||||
|
||||
- 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.
|
||||
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 hosted shell declaration or a local shell FunctionTool.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
# Hosted shell (OpenAI container)
|
||||
tool = OpenAIResponsesClient.get_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,
|
||||
)
|
||||
"""
|
||||
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
|
||||
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,
|
||||
name=name,
|
||||
description=description,
|
||||
approval_mode=approval_mode,
|
||||
)
|
||||
|
||||
if base_tool.func is None:
|
||||
raise ValueError("Shell tool requires an executable function.")
|
||||
|
||||
additional_properties = dict(base_tool.additional_properties or {})
|
||||
additional_properties[OPENAI_SHELL_ENVIRONMENT_KEY] = local_env
|
||||
base_tool.additional_properties = additional_properties
|
||||
base_tool.kind = SHELL_TOOL_KIND_VALUE
|
||||
return base_tool
|
||||
|
||||
@staticmethod
|
||||
def get_mcp_tool(
|
||||
*,
|
||||
@@ -1044,13 +1168,34 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
"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",
|
||||
@@ -1076,6 +1221,65 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
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,
|
||||
@@ -1087,6 +1291,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
|
||||
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:
|
||||
@@ -1332,6 +1537,97 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
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)
|
||||
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] = []
|
||||
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)
|
||||
@@ -1370,6 +1666,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
"""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
|
||||
@@ -1646,6 +1943,97 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
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)
|
||||
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] = []
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -7,19 +7,6 @@ from typing import Annotated, Any
|
||||
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.file_path_delta_annotation import FilePathDeltaAn
|
||||
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 @@ class TestMessageCompletedAnnotations:
|
||||
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."""
|
||||
|
||||
@@ -31,6 +31,7 @@ from agent_framework import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
FunctionTool,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
tool,
|
||||
@@ -38,6 +39,7 @@ from agent_framework import (
|
||||
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,6 +566,386 @@ def test_response_content_creation_with_code_interpreter() -> None:
|
||||
assert any(out.type == "uri" for out in result_content.outputs)
|
||||
|
||||
|
||||
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_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_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")
|
||||
|
||||
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.finish_reason = "tool_calls"
|
||||
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.finish_reason = "stop"
|
||||
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.finish_reason = "tool_calls"
|
||||
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.finish_reason = "stop"
|
||||
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:
|
||||
"""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")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -499,9 +499,7 @@ async def test_kwargs_preserved_on_response_continuation() -> None:
|
||||
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user