mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-xunit3-mtp-upgrade
This commit is contained in:
@@ -247,6 +247,51 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
name: Python Integration Tests - Cosmos
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
services:
|
||||
cosmosdb:
|
||||
image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
|
||||
ports:
|
||||
- 8081:8081
|
||||
env:
|
||||
AZURE_COSMOS_ENDPOINT: "http://localhost:8081/"
|
||||
# Static Azure Cosmos DB emulator key (documented): https://learn.microsoft.com/en-us/azure/cosmos-db/emulator
|
||||
AZURE_COSMOS_KEY: "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
AZURE_COSMOS_DATABASE_NAME: "agent-framework-cosmos-it-db"
|
||||
AZURE_COSMOS_CONTAINER_NAME: "agent-framework-cosmos-it-container"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
persist-credentials: false
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Wait for Cosmos DB emulator
|
||||
run: |
|
||||
for i in {1..60}; do
|
||||
if curl --silent --show-error http://localhost:8081/ > /dev/null; then
|
||||
echo "Cosmos DB emulator is ready."
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Cosmos DB emulator did not become ready in time." >&2
|
||||
exit 1
|
||||
- name: Test with pytest (Cosmos integration)
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
@@ -257,7 +302,8 @@ jobs:
|
||||
python-tests-azure-openai,
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-azure-ai
|
||||
python-tests-azure-ai,
|
||||
python-tests-cosmos
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
miscChanged: ${{ steps.filter.outputs.misc }}
|
||||
functionsChanged: ${{ steps.filter.outputs.functions }}
|
||||
azureAiChanged: ${{ steps.filter.outputs.azure-ai }}
|
||||
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -67,6 +68,8 @@ jobs:
|
||||
- 'python/packages/durabletask/**'
|
||||
azure-ai:
|
||||
- 'python/packages/azure-ai/**'
|
||||
cosmos:
|
||||
- 'python/packages/azure-cosmos/**'
|
||||
# run only if 'python' files were changed
|
||||
- name: python tests
|
||||
if: steps.filter.outputs.python == 'true'
|
||||
@@ -390,6 +393,64 @@ jobs:
|
||||
|
||||
# TODO: Add python-tests-lab
|
||||
|
||||
# Azure Cosmos integration tests
|
||||
python-tests-cosmos:
|
||||
name: Python Tests - Cosmos Integration
|
||||
needs: paths-filter
|
||||
if: >
|
||||
github.event_name != 'pull_request' &&
|
||||
needs.paths-filter.outputs.pythonChanges == 'true' &&
|
||||
(github.event_name != 'merge_group' ||
|
||||
needs.paths-filter.outputs.cosmosChanged == 'true' ||
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
services:
|
||||
cosmosdb:
|
||||
image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
|
||||
ports:
|
||||
- 8081:8081
|
||||
env:
|
||||
AZURE_COSMOS_ENDPOINT: "http://localhost:8081/"
|
||||
# Static Azure Cosmos DB emulator key (documented): https://learn.microsoft.com/en-us/azure/cosmos-db/emulator
|
||||
AZURE_COSMOS_KEY: "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
AZURE_COSMOS_DATABASE_NAME: "agent-framework-cosmos-it-db"
|
||||
AZURE_COSMOS_CONTAINER_NAME: "agent-framework-cosmos-it-container"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Wait for Cosmos DB emulator
|
||||
run: |
|
||||
for i in {1..60}; do
|
||||
if curl --silent --show-error http://localhost:8081/ > /dev/null; then
|
||||
echo "Cosmos DB emulator is ready."
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Cosmos DB emulator did not become ready in time." >&2
|
||||
exit 1
|
||||
- name: Test with pytest (Cosmos integration)
|
||||
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
- name: Surface failing tests
|
||||
if: always()
|
||||
uses: pmeier/pytest-results-action@v0.7.2
|
||||
with:
|
||||
path: ./python/**.xml
|
||||
summary: true
|
||||
display-options: fEX
|
||||
fail-on-empty: false
|
||||
title: Cosmos integration test results
|
||||
|
||||
python-integration-tests-check:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
@@ -401,6 +462,7 @@ jobs:
|
||||
python-tests-misc-integration,
|
||||
python-tests-functions,
|
||||
python-tests-azure-ai,
|
||||
python-tests-cosmos,
|
||||
]
|
||||
steps:
|
||||
- name: Fail workflow if tests failed
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Mapping, MutableMapping, Sequence
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
|
||||
from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
|
||||
|
||||
from agent_framework import (
|
||||
@@ -25,8 +25,10 @@ from agent_framework import (
|
||||
ResponseStream,
|
||||
TextSpanRegion,
|
||||
UsageDetails,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
|
||||
from agent_framework._types import _get_data_bytes_as_str # type: ignore
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from anthropic import AsyncAnthropic
|
||||
@@ -326,6 +328,7 @@ class AnthropicClient(
|
||||
# streaming requires tracking the last function call ID, name, and content type
|
||||
self._last_call_id_name: tuple[str, str] | None = None
|
||||
self._last_call_content_type: str | None = None
|
||||
self._tool_name_aliases: dict[str, str] = {}
|
||||
|
||||
# region Static factory methods for hosted tools
|
||||
|
||||
@@ -379,6 +382,57 @@ class AnthropicClient(
|
||||
"""
|
||||
return {"type": type_name or "web_search_20250305", "name": name}
|
||||
|
||||
@staticmethod
|
||||
def get_shell_tool(
|
||||
*,
|
||||
func: Callable[..., Any] | FunctionTool,
|
||||
description: str | None = None,
|
||||
type_name: str | None = None,
|
||||
approval_mode: Literal["always_require", "never_require"] | None = None,
|
||||
) -> FunctionTool:
|
||||
"""Create a local shell FunctionTool for Anthropic.
|
||||
|
||||
This helper wraps ``func`` as a shell-enabled ``FunctionTool`` for local
|
||||
execution and configures Anthropic API declaration details via metadata.
|
||||
|
||||
Anthropic always exposes this tool to the model as ``name="bash"`` and
|
||||
executes it using a ``bash_*`` tool type.
|
||||
|
||||
Keyword Args:
|
||||
func: Python callable or ``FunctionTool`` that executes the requested shell command.
|
||||
description: Optional tool description shown to the model.
|
||||
type_name: Optional Anthropic shell tool type override.
|
||||
Defaults to ``"bash_20250124"`` when omitted.
|
||||
approval_mode: Optional approval mode for local execution.
|
||||
|
||||
Returns:
|
||||
A shell-enabled ``FunctionTool`` suitable for ``ChatOptions.tools``.
|
||||
"""
|
||||
base_tool: FunctionTool
|
||||
if isinstance(func, FunctionTool):
|
||||
base_tool = func
|
||||
if description is not None:
|
||||
base_tool.description = description
|
||||
if approval_mode is not None:
|
||||
base_tool.approval_mode = approval_mode
|
||||
else:
|
||||
base_tool = tool(
|
||||
func=func,
|
||||
description=description,
|
||||
approval_mode=approval_mode,
|
||||
)
|
||||
|
||||
additional_properties: dict[str, Any] = dict(base_tool.additional_properties or {})
|
||||
if type_name:
|
||||
additional_properties["type"] = type_name
|
||||
|
||||
if base_tool.func is None:
|
||||
raise ValueError("Shell tool requires an executable function.")
|
||||
|
||||
base_tool.additional_properties = additional_properties
|
||||
base_tool.kind = SHELL_TOOL_KIND_VALUE
|
||||
return base_tool
|
||||
|
||||
@staticmethod
|
||||
def get_mcp_tool(
|
||||
*,
|
||||
@@ -715,8 +769,16 @@ class AnthropicClient(
|
||||
if tools:
|
||||
tool_list: list[Any] = []
|
||||
mcp_server_list: list[Any] = []
|
||||
tool_name_aliases: dict[str, str] = {}
|
||||
for tool in tools:
|
||||
if isinstance(tool, FunctionTool):
|
||||
if isinstance(tool, FunctionTool) and tool.kind == SHELL_TOOL_KIND_VALUE:
|
||||
api_type = (tool.additional_properties or {}).get("type", "bash_20250124")
|
||||
tool_name_aliases["bash"] = tool.name
|
||||
tool_list.append({
|
||||
"type": api_type,
|
||||
"name": "bash",
|
||||
})
|
||||
elif isinstance(tool, FunctionTool):
|
||||
tool_list.append({
|
||||
"type": "custom",
|
||||
"name": tool.name,
|
||||
@@ -744,6 +806,9 @@ class AnthropicClient(
|
||||
result["tools"] = tool_list
|
||||
if mcp_server_list:
|
||||
result["mcp_servers"] = mcp_server_list
|
||||
self._tool_name_aliases = tool_name_aliases
|
||||
else:
|
||||
self._tool_name_aliases = {}
|
||||
|
||||
# Process tool choice
|
||||
if options.get("tool_choice") is None:
|
||||
@@ -760,9 +825,18 @@ class AnthropicClient(
|
||||
result["tool_choice"] = tool_choice
|
||||
case "required":
|
||||
if "required_function_name" in tool_mode:
|
||||
required_name = tool_mode["required_function_name"]
|
||||
api_tool_name = next(
|
||||
(
|
||||
api_name
|
||||
for api_name, local_name in self._tool_name_aliases.items()
|
||||
if local_name == required_name
|
||||
),
|
||||
required_name,
|
||||
)
|
||||
tool_choice = {
|
||||
"type": "tool",
|
||||
"name": tool_mode["required_function_name"],
|
||||
"name": api_tool_name,
|
||||
}
|
||||
else:
|
||||
tool_choice = {"type": "any"}
|
||||
@@ -914,10 +988,11 @@ class AnthropicClient(
|
||||
)
|
||||
)
|
||||
else:
|
||||
resolved_tool_name = self._tool_name_aliases.get(content_block.name, content_block.name)
|
||||
contents.append(
|
||||
Content.from_function_call(
|
||||
call_id=content_block.id,
|
||||
name=content_block.name,
|
||||
name=resolved_tool_name,
|
||||
arguments=content_block.input,
|
||||
raw_representation=content_block,
|
||||
)
|
||||
@@ -1006,33 +1081,29 @@ class AnthropicClient(
|
||||
)
|
||||
)
|
||||
case "bash_code_execution_tool_result":
|
||||
bash_outputs: list[Content] = []
|
||||
shell_outputs: list[Content] = []
|
||||
if content_block.content:
|
||||
if isinstance(
|
||||
content_block.content,
|
||||
BetaBashCodeExecutionToolResultError,
|
||||
):
|
||||
bash_outputs.append(
|
||||
Content.from_error(
|
||||
message=content_block.content.error_code,
|
||||
shell_outputs.append(
|
||||
Content.from_shell_command_output(
|
||||
stderr=content_block.content.error_code,
|
||||
timed_out=content_block.content.error_code == "execution_time_exceeded",
|
||||
raw_representation=content_block.content,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if content_block.content.stdout:
|
||||
bash_outputs.append(
|
||||
Content.from_text(
|
||||
text=content_block.content.stdout,
|
||||
raw_representation=content_block.content,
|
||||
)
|
||||
)
|
||||
if content_block.content.stderr:
|
||||
bash_outputs.append(
|
||||
Content.from_error(
|
||||
message=content_block.content.stderr,
|
||||
raw_representation=content_block.content,
|
||||
)
|
||||
shell_outputs.append(
|
||||
Content.from_shell_command_output(
|
||||
stdout=content_block.content.stdout or None,
|
||||
stderr=content_block.content.stderr or None,
|
||||
exit_code=int(content_block.content.return_code),
|
||||
timed_out=False,
|
||||
raw_representation=content_block.content,
|
||||
)
|
||||
)
|
||||
for bash_file_content in content_block.content.content:
|
||||
contents.append(
|
||||
Content.from_hosted_file(
|
||||
@@ -1041,9 +1112,9 @@ class AnthropicClient(
|
||||
)
|
||||
)
|
||||
contents.append(
|
||||
Content.from_function_result(
|
||||
Content.from_shell_tool_result(
|
||||
call_id=content_block.tool_use_id,
|
||||
result=bash_outputs,
|
||||
outputs=shell_outputs,
|
||||
raw_representation=content_block,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from agent_framework import (
|
||||
tool,
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
|
||||
from anthropic.types.beta import (
|
||||
BetaMessage,
|
||||
BetaTextBlock,
|
||||
@@ -40,6 +41,8 @@ def create_test_anthropic_client(
|
||||
anthropic_settings: AnthropicSettings | None = None,
|
||||
) -> AnthropicClient:
|
||||
"""Helper function to create AnthropicClient instances for testing, bypassing normal validation."""
|
||||
from agent_framework._tools import normalize_function_invocation_configuration
|
||||
|
||||
if anthropic_settings is None:
|
||||
anthropic_settings = load_settings(
|
||||
AnthropicSettings,
|
||||
@@ -55,9 +58,13 @@ def create_test_anthropic_client(
|
||||
client.anthropic_client = mock_anthropic_client
|
||||
client.model_id = model_id or anthropic_settings["chat_model_id"]
|
||||
client._last_call_id_name = None
|
||||
client._tool_name_aliases = {}
|
||||
client.additional_properties = {}
|
||||
client.middleware = None
|
||||
client.additional_beta_flags = []
|
||||
client.chat_middleware = []
|
||||
client.function_middleware = []
|
||||
client.function_invocation_configuration = normalize_function_invocation_configuration(None)
|
||||
|
||||
return client
|
||||
|
||||
@@ -410,6 +417,87 @@ def test_prepare_tools_for_anthropic_code_interpreter(mock_anthropic_client: Mag
|
||||
assert result["tools"][0]["name"] == "code_execution"
|
||||
|
||||
|
||||
def _dummy_bash(command: str) -> str:
|
||||
return f"executed: {command}"
|
||||
|
||||
|
||||
def test_prepare_tools_for_anthropic_shell_tool(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting tool-decorated FunctionTool to Anthropic bash format."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
@tool(kind=SHELL_TOOL_KIND_VALUE)
|
||||
def run_bash(command: str) -> str:
|
||||
return _dummy_bash(command)
|
||||
|
||||
chat_options = ChatOptions(tools=[run_bash])
|
||||
|
||||
result = client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert "tools" in result
|
||||
assert len(result["tools"]) == 1
|
||||
assert result["tools"][0]["type"] == "bash_20250124"
|
||||
assert result["tools"][0]["name"] == "bash"
|
||||
|
||||
|
||||
def test_prepare_tools_for_anthropic_shell_tool_custom_type(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test shell tool with custom type via additional_properties."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
@tool(kind=SHELL_TOOL_KIND_VALUE, additional_properties={"type": "bash_20241022"})
|
||||
def run_bash(command: str) -> str:
|
||||
return _dummy_bash(command)
|
||||
|
||||
chat_options = ChatOptions(tools=[run_bash])
|
||||
|
||||
result = client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert "tools" in result
|
||||
assert result["tools"][0]["type"] == "bash_20241022"
|
||||
assert result["tools"][0]["name"] == "bash"
|
||||
|
||||
|
||||
def test_prepare_tools_for_anthropic_shell_tool_does_not_mutate_name(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Shell tool API name should be 'bash' without mutating local FunctionTool name."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
@tool(
|
||||
name="run_local_shell",
|
||||
approval_mode="never_require",
|
||||
kind=SHELL_TOOL_KIND_VALUE,
|
||||
)
|
||||
def run_local_shell(command: str) -> str:
|
||||
return command
|
||||
|
||||
chat_options = ChatOptions(tools=[run_local_shell])
|
||||
result = client._prepare_tools_for_anthropic(chat_options)
|
||||
|
||||
assert result is not None
|
||||
assert result["tools"][0]["name"] == "bash"
|
||||
assert run_local_shell.name == "run_local_shell"
|
||||
|
||||
|
||||
def test_get_shell_tool_reuses_function_tool_instance(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Passing a FunctionTool should update and return the same tool instance."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
|
||||
@tool(name="run_shell", approval_mode="never_require")
|
||||
def run_shell(command: str) -> str:
|
||||
return command
|
||||
|
||||
shell_tool = client.get_shell_tool(
|
||||
func=run_shell,
|
||||
description="Run local bash",
|
||||
approval_mode="always_require",
|
||||
)
|
||||
|
||||
assert shell_tool is run_shell
|
||||
assert shell_tool.kind == SHELL_TOOL_KIND_VALUE
|
||||
assert shell_tool.description == "Run local bash"
|
||||
assert shell_tool.approval_mode == "always_require"
|
||||
|
||||
|
||||
def test_prepare_tools_for_anthropic_mcp_tool(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test converting MCP dict tool to Anthropic format."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
@@ -502,6 +590,62 @@ async def test_prepare_options_with_system_message(mock_anthropic_client: MagicM
|
||||
assert len(run_options["messages"]) == 1 # System message not in messages list
|
||||
|
||||
|
||||
async def test_anthropic_shell_tool_is_invoked_in_function_loop(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Function invocation loop should execute shell tool when Anthropic returns bash tool_use."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
executed_commands: list[str] = []
|
||||
|
||||
def run_local_shell(command: str) -> str:
|
||||
executed_commands.append(command)
|
||||
return f"executed: {command}"
|
||||
|
||||
shell_tool_instance = client.get_shell_tool(func=run_local_shell, approval_mode="never_require")
|
||||
|
||||
mock_tool_use = MagicMock()
|
||||
mock_tool_use.type = "tool_use"
|
||||
mock_tool_use.id = "call_bash_loop"
|
||||
mock_tool_use.name = "bash"
|
||||
mock_tool_use.input = {"command": "pwd"}
|
||||
|
||||
first_message = MagicMock()
|
||||
first_message.id = "msg_1"
|
||||
first_message.content = [mock_tool_use]
|
||||
first_message.usage = None
|
||||
first_message.model = "claude-test"
|
||||
first_message.stop_reason = "tool_use"
|
||||
|
||||
mock_text_block = MagicMock()
|
||||
mock_text_block.type = "text"
|
||||
mock_text_block.text = "Done"
|
||||
|
||||
second_message = MagicMock()
|
||||
second_message.id = "msg_2"
|
||||
second_message.content = [mock_text_block]
|
||||
second_message.usage = None
|
||||
second_message.model = "claude-test"
|
||||
second_message.stop_reason = "end_turn"
|
||||
|
||||
mock_anthropic_client.beta.messages.create.side_effect = [first_message, second_message]
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", text="Run pwd")],
|
||||
options={"tools": [shell_tool_instance], "max_tokens": 64},
|
||||
)
|
||||
|
||||
assert executed_commands == ["pwd"]
|
||||
assert mock_anthropic_client.beta.messages.create.call_count == 2
|
||||
second_request_messages = mock_anthropic_client.beta.messages.create.call_args_list[1].kwargs["messages"]
|
||||
tool_results = [
|
||||
block
|
||||
for message in second_request_messages
|
||||
for block in message.get("content", [])
|
||||
if block.get("type") == "tool_result"
|
||||
]
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "call_bash_loop"
|
||||
assert "executed: pwd" in tool_results[0]["content"]
|
||||
|
||||
|
||||
async def test_prepare_options_with_tool_choice_auto(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test _prepare_options with auto tool choice."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
@@ -1733,7 +1877,7 @@ def test_parse_code_execution_result_with_files(mock_anthropic_client: MagicMock
|
||||
|
||||
|
||||
def test_parse_bash_execution_result_with_stdout(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test parsing bash execution result with stdout."""
|
||||
"""Test parsing bash execution result with stdout produces shell_tool_result."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
client._last_call_id_name = ("call_bash2", "bash_code_execution")
|
||||
|
||||
@@ -1741,6 +1885,7 @@ def test_parse_bash_execution_result_with_stdout(mock_anthropic_client: MagicMoc
|
||||
mock_content = MagicMock()
|
||||
mock_content.stdout = "Output text"
|
||||
mock_content.stderr = None
|
||||
mock_content.return_code = 0
|
||||
mock_content.content = []
|
||||
|
||||
mock_block = MagicMock()
|
||||
@@ -1751,11 +1896,18 @@ def test_parse_bash_execution_result_with_stdout(mock_anthropic_client: MagicMoc
|
||||
result = client._parse_contents_from_anthropic([mock_block])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "function_result"
|
||||
assert result[0].type == "shell_tool_result"
|
||||
assert result[0].call_id == "call_bash2"
|
||||
assert result[0].outputs is not None
|
||||
assert len(result[0].outputs) == 1
|
||||
assert result[0].outputs[0].type == "shell_command_output"
|
||||
assert result[0].outputs[0].stdout == "Output text"
|
||||
assert result[0].outputs[0].exit_code == 0
|
||||
assert result[0].outputs[0].timed_out is False
|
||||
|
||||
|
||||
def test_parse_bash_execution_result_with_stderr(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test parsing bash execution result with stderr."""
|
||||
"""Test parsing bash execution result with stderr produces shell_tool_result."""
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
client._last_call_id_name = ("call_bash3", "bash_code_execution")
|
||||
|
||||
@@ -1763,6 +1915,7 @@ def test_parse_bash_execution_result_with_stderr(mock_anthropic_client: MagicMoc
|
||||
mock_content = MagicMock()
|
||||
mock_content.stdout = None
|
||||
mock_content.stderr = "Error output"
|
||||
mock_content.return_code = 1
|
||||
mock_content.content = []
|
||||
|
||||
mock_block = MagicMock()
|
||||
@@ -1773,7 +1926,39 @@ def test_parse_bash_execution_result_with_stderr(mock_anthropic_client: MagicMoc
|
||||
result = client._parse_contents_from_anthropic([mock_block])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "function_result"
|
||||
assert result[0].type == "shell_tool_result"
|
||||
assert result[0].call_id == "call_bash3"
|
||||
assert result[0].outputs is not None
|
||||
assert result[0].outputs[0].type == "shell_command_output"
|
||||
assert result[0].outputs[0].stderr == "Error output"
|
||||
assert result[0].outputs[0].exit_code == 1
|
||||
|
||||
|
||||
def test_parse_bash_execution_result_with_error(mock_anthropic_client: MagicMock) -> None:
|
||||
"""Test parsing bash execution error produces shell_tool_result with error info."""
|
||||
from anthropic.types.beta.beta_bash_code_execution_tool_result_error import (
|
||||
BetaBashCodeExecutionToolResultError,
|
||||
)
|
||||
|
||||
client = create_test_anthropic_client(mock_anthropic_client)
|
||||
client._last_call_id_name = ("call_bash_err", "bash_code_execution")
|
||||
|
||||
mock_error = MagicMock(spec=BetaBashCodeExecutionToolResultError)
|
||||
mock_error.error_code = "execution_time_exceeded"
|
||||
|
||||
mock_block = MagicMock()
|
||||
mock_block.type = "bash_code_execution_tool_result"
|
||||
mock_block.tool_use_id = "call_bash_err"
|
||||
mock_block.content = mock_error
|
||||
|
||||
result = client._parse_contents_from_anthropic([mock_block])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "shell_tool_result"
|
||||
assert result[0].outputs is not None
|
||||
assert result[0].outputs[0].type == "shell_command_output"
|
||||
assert result[0].outputs[0].stderr == "execution_time_exceeded"
|
||||
assert result[0].outputs[0].timed_out is True
|
||||
|
||||
|
||||
# Text Editor Result Tests
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Azure Cosmos DB Package (agent-framework-azure-cosmos)
|
||||
|
||||
Azure Cosmos DB history provider integration for Agent Framework.
|
||||
|
||||
## Main Classes
|
||||
|
||||
- **`CosmosHistoryProvider`** - Persistent conversation history storage backed by Azure Cosmos DB
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from agent_framework_azure_cosmos import CosmosHistoryProvider
|
||||
|
||||
provider = CosmosHistoryProvider(
|
||||
endpoint="https://<account>.documents.azure.com:443/",
|
||||
credential="<key-or-token-credential>",
|
||||
database_name="agent-framework",
|
||||
container_name="chat-history",
|
||||
)
|
||||
```
|
||||
|
||||
Container name is configured on the provider. `session_id` is used as the partition key.
|
||||
|
||||
## Import Path
|
||||
|
||||
```python
|
||||
from agent_framework_azure_cosmos import CosmosHistoryProvider
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,38 @@
|
||||
# Get Started with Microsoft Agent Framework Azure Cosmos DB
|
||||
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-azure-cosmos --pre
|
||||
```
|
||||
|
||||
## Azure Cosmos DB History Provider
|
||||
|
||||
The Azure Cosmos DB integration provides `CosmosHistoryProvider` for persistent conversation history storage.
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
```python
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from agent_framework_azure_cosmos import CosmosHistoryProvider
|
||||
|
||||
provider = CosmosHistoryProvider(
|
||||
endpoint="https://<account>.documents.azure.com:443/",
|
||||
credential=DefaultAzureCredential(),
|
||||
database_name="agent-framework",
|
||||
container_name="chat-history",
|
||||
)
|
||||
```
|
||||
|
||||
Credentials follow the same pattern used by other Azure connectors in the repository:
|
||||
|
||||
- Pass a credential object (for example `DefaultAzureCredential`)
|
||||
- Or pass a key string directly
|
||||
- Or set `AZURE_COSMOS_KEY` in the environment
|
||||
|
||||
Container naming behavior:
|
||||
|
||||
- Container name is configured on the provider (`container_name` or `AZURE_COSMOS_CONTAINER_NAME`)
|
||||
- `session_id` is used as the Cosmos partition key for reads/writes
|
||||
|
||||
See `samples/cosmos_history_provider.py` for a runnable package-local example.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._history_provider import CosmosHistoryProvider
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"CosmosHistoryProvider",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,269 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Azure Cosmos DB history provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, ClassVar, TypedDict
|
||||
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
|
||||
from agent_framework._sessions import BaseHistoryProvider
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from azure.cosmos import PartitionKey
|
||||
from azure.cosmos.aio import ContainerProxy, CosmosClient, DatabaseProxy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AzureCosmosHistorySettings(TypedDict, total=False):
|
||||
"""Settings for CosmosHistoryProvider resolved from args and environment."""
|
||||
|
||||
endpoint: str | None
|
||||
database_name: str | None
|
||||
container_name: str | None
|
||||
key: SecretString | None
|
||||
|
||||
|
||||
class CosmosHistoryProvider(BaseHistoryProvider):
|
||||
"""Azure Cosmos DB-backed history provider using BaseHistoryProvider hooks."""
|
||||
|
||||
DEFAULT_SOURCE_ID: ClassVar[str] = "azure_cosmos_history"
|
||||
_BATCH_OPERATION_LIMIT: ClassVar[int] = 100
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_SOURCE_ID,
|
||||
*,
|
||||
load_messages: bool = True,
|
||||
store_outputs: bool = True,
|
||||
store_inputs: bool = True,
|
||||
store_context_messages: bool = False,
|
||||
store_context_from: set[str] | None = None,
|
||||
endpoint: str | None = None,
|
||||
database_name: str | None = None,
|
||||
container_name: str | None = None,
|
||||
credential: str | AzureCredentialTypes | None = None,
|
||||
cosmos_client: CosmosClient | None = None,
|
||||
container_client: ContainerProxy | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Azure Cosmos DB history provider.
|
||||
|
||||
Args:
|
||||
source_id: Unique identifier for this provider instance.
|
||||
load_messages: Whether to load messages before invocation.
|
||||
store_outputs: Whether to store response messages.
|
||||
store_inputs: Whether to store input messages.
|
||||
store_context_messages: Whether to store context from other providers.
|
||||
store_context_from: If set, only store context from these source_ids.
|
||||
endpoint: Cosmos DB account endpoint.
|
||||
Can be set via ``AZURE_COSMOS_ENDPOINT``.
|
||||
database_name: Cosmos DB database name.
|
||||
Can be set via ``AZURE_COSMOS_DATABASE_NAME``.
|
||||
container_name: Cosmos DB container name.
|
||||
Can be set via ``AZURE_COSMOS_CONTAINER_NAME``.
|
||||
credential: Credential to authenticate with Cosmos DB.
|
||||
Supports key string and Azure credential objects.
|
||||
Can be set via ``AZURE_COSMOS_KEY`` when omitted.
|
||||
cosmos_client: Pre-created Cosmos async client.
|
||||
container_client: Pre-created Cosmos container client for fixed-container usage.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
"""
|
||||
super().__init__(
|
||||
source_id,
|
||||
load_messages=load_messages,
|
||||
store_outputs=store_outputs,
|
||||
store_inputs=store_inputs,
|
||||
store_context_messages=store_context_messages,
|
||||
store_context_from=store_context_from,
|
||||
)
|
||||
|
||||
self._cosmos_client: CosmosClient | None = cosmos_client
|
||||
self._container_proxy: ContainerProxy | None = container_client
|
||||
self._owns_client = False
|
||||
self._database_client: DatabaseProxy | None = None
|
||||
|
||||
if self._container_proxy is not None:
|
||||
self.database_name: str = database_name or ""
|
||||
self.container_name: str = container_name or ""
|
||||
return
|
||||
|
||||
required_fields: list[str] = ["database_name", "container_name"]
|
||||
if cosmos_client is None:
|
||||
required_fields.append("endpoint")
|
||||
if credential is None:
|
||||
required_fields.append("key")
|
||||
|
||||
settings = load_settings(
|
||||
AzureCosmosHistorySettings,
|
||||
env_prefix="AZURE_COSMOS_",
|
||||
required_fields=required_fields,
|
||||
endpoint=endpoint,
|
||||
database_name=database_name,
|
||||
container_name=container_name,
|
||||
key=credential if isinstance(credential, str) else None,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
self.database_name = settings["database_name"] # type: ignore[assignment]
|
||||
self.container_name = settings["container_name"] # type: ignore[assignment]
|
||||
if self._cosmos_client is None:
|
||||
self._cosmos_client = CosmosClient(
|
||||
url=settings["endpoint"], # type: ignore[arg-type]
|
||||
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
|
||||
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
self._owns_client = True
|
||||
|
||||
self._database_client = self._cosmos_client.get_database_client(self.database_name)
|
||||
|
||||
|
||||
async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]:
|
||||
"""Retrieve stored messages for this session from Azure Cosmos DB."""
|
||||
await self._ensure_container_proxy()
|
||||
session_key = self._session_partition_key(session_id)
|
||||
|
||||
query = (
|
||||
"SELECT c.message FROM c "
|
||||
"WHERE c.session_id = @session_id AND c.source_id = @source_id "
|
||||
"ORDER BY c.sort_key ASC"
|
||||
)
|
||||
parameters: list[dict[str, object]] = [
|
||||
{"name": "@session_id", "value": session_key},
|
||||
{"name": "@source_id", "value": self.source_id},
|
||||
]
|
||||
items = self._container_proxy.query_items( # type: ignore[union-attr]
|
||||
query=query, parameters=parameters, partition_key=session_key
|
||||
)
|
||||
|
||||
messages: list[Message] = []
|
||||
async for item in items:
|
||||
message_payload = item.get("message")
|
||||
if isinstance(message_payload, dict):
|
||||
messages.append(Message.from_dict(message_payload))
|
||||
|
||||
return messages
|
||||
|
||||
async def save_messages(self, session_id: str | None, messages: Sequence[Message], **kwargs: Any) -> None:
|
||||
"""Persist messages for this session to Azure Cosmos DB."""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
await self._ensure_container_proxy()
|
||||
session_key = self._session_partition_key(session_id)
|
||||
|
||||
base_sort_key = time.time_ns()
|
||||
operations: list[tuple[str, tuple[dict[str, Any]]]] = []
|
||||
for index, message in enumerate(messages):
|
||||
document = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"session_id": session_key,
|
||||
"sort_key": base_sort_key + index,
|
||||
"source_id": self.source_id,
|
||||
"message": message.to_dict(),
|
||||
}
|
||||
operations.append(("upsert", (document,)))
|
||||
|
||||
for start in range(0, len(operations), self._BATCH_OPERATION_LIMIT):
|
||||
batch = operations[start : start + self._BATCH_OPERATION_LIMIT]
|
||||
await self._container_proxy.execute_item_batch( # type: ignore[union-attr]
|
||||
batch_operations=batch, partition_key=session_key
|
||||
)
|
||||
|
||||
async def clear(self, session_id: str | None) -> None:
|
||||
"""Clear all messages for a session from Azure Cosmos DB."""
|
||||
await self._ensure_container_proxy()
|
||||
session_key = self._session_partition_key(session_id)
|
||||
query = "SELECT c.id FROM c WHERE c.session_id = @session_id AND c.source_id = @source_id"
|
||||
parameters: list[dict[str, object]] = [
|
||||
{"name": "@session_id", "value": session_key},
|
||||
{"name": "@source_id", "value": self.source_id},
|
||||
]
|
||||
items = self._container_proxy.query_items( # type: ignore[union-attr]
|
||||
query=query, parameters=parameters, partition_key=session_key
|
||||
)
|
||||
|
||||
delete_operations: list[tuple[str, tuple[str]]] = []
|
||||
async for item in items:
|
||||
item_id = item.get("id")
|
||||
if isinstance(item_id, str):
|
||||
delete_operations.append(("delete", (item_id,)))
|
||||
|
||||
for start in range(0, len(delete_operations), self._BATCH_OPERATION_LIMIT):
|
||||
batch = delete_operations[start : start + self._BATCH_OPERATION_LIMIT]
|
||||
await self._container_proxy.execute_item_batch( # type: ignore[union-attr]
|
||||
batch_operations=batch, partition_key=session_key
|
||||
)
|
||||
|
||||
async def list_sessions(self) -> list[str]:
|
||||
"""List all session IDs stored in this provider's Cosmos container."""
|
||||
await self._ensure_container_proxy()
|
||||
query = (
|
||||
"SELECT DISTINCT VALUE c.session_id FROM c WHERE c.source_id = @source_id"
|
||||
)
|
||||
parameters: list[dict[str, object]] = [
|
||||
{"name": "@source_id", "value": self.source_id}
|
||||
]
|
||||
# without a partition key, it is automatically a cross-partition query
|
||||
items = self._container_proxy.query_items(query=query, parameters=parameters) # type: ignore[union-attr]
|
||||
|
||||
session_ids: set[str] = set()
|
||||
async for item in items:
|
||||
if isinstance(item, str):
|
||||
session_ids.add(item)
|
||||
return sorted(session_ids)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying Cosmos client when this provider owns it."""
|
||||
if self._owns_client and self._cosmos_client is not None:
|
||||
await self._cosmos_client.close()
|
||||
|
||||
async def __aenter__(self) -> CosmosHistoryProvider:
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: Any,
|
||||
) -> None:
|
||||
"""Async context manager exit."""
|
||||
try:
|
||||
await self.close()
|
||||
except Exception:
|
||||
if exc_type is None:
|
||||
raise
|
||||
|
||||
async def _ensure_container_proxy(self) -> None:
|
||||
"""Get or create the Cosmos DB container for storing messages."""
|
||||
if self._container_proxy is not None:
|
||||
return
|
||||
if self._database_client is None:
|
||||
raise RuntimeError("Cosmos database client is not initialized.")
|
||||
|
||||
self._container_proxy = (
|
||||
await self._database_client.create_container_if_not_exists(
|
||||
id=self.container_name,
|
||||
partition_key=PartitionKey(path="/session_id"),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _session_partition_key(session_id: str | None) -> str:
|
||||
if session_id:
|
||||
return session_id
|
||||
|
||||
generated_session_id = str(uuid.uuid4())
|
||||
logger.warning(
|
||||
"Received empty session_id; generated temporary session id '%s' for Cosmos partition key.",
|
||||
generated_session_id,
|
||||
)
|
||||
return generated_session_id
|
||||
@@ -0,0 +1,93 @@
|
||||
[project]
|
||||
name = "agent-framework-azure-cosmos"
|
||||
description = "Azure Cosmos DB history provider integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260219"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc1",
|
||||
"azure-cosmos>=4.9.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_azure_cosmos"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos"
|
||||
test = "pytest --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
|
||||
integration-tests = "pytest tests/test_cosmos_history_provider.py -m integration"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Azure Cosmos DB Package Samples
|
||||
|
||||
This folder contains samples for `agent-framework-azure-cosmos`.
|
||||
|
||||
| File | Description |
|
||||
| --- | --- |
|
||||
| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Demonstrates an Agent using `CosmosHistoryProvider` with `AzureOpenAIResponsesClient` (project endpoint), provider-configured container name, and `session_id` partitioning. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `AZURE_COSMOS_ENDPOINT`
|
||||
- `AZURE_COSMOS_DATABASE_NAME`
|
||||
- `AZURE_COSMOS_CONTAINER_NAME`
|
||||
- `AZURE_COSMOS_KEY` (or equivalent credential flow)
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
uv run --directory packages/azure-cosmos python samples/cosmos_history_provider.py
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Samples for the Azure Cosmos history provider package."""
|
||||
@@ -0,0 +1,100 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff: noqa: T201
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from agent_framework_azure_cosmos import CosmosHistoryProvider
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file.
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
This sample demonstrates CosmosHistoryProvider as an agent context provider.
|
||||
|
||||
Key components:
|
||||
- AzureOpenAIResponsesClient configured with an Azure AI project endpoint
|
||||
- CosmosHistoryProvider configured for Cosmos DB-backed message history
|
||||
- Provider-configured container name with session_id as partition key
|
||||
|
||||
Environment variables:
|
||||
AZURE_AI_PROJECT_ENDPOINT
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME
|
||||
AZURE_COSMOS_ENDPOINT
|
||||
AZURE_COSMOS_DATABASE_NAME
|
||||
AZURE_COSMOS_CONTAINER_NAME
|
||||
Optional:
|
||||
AZURE_COSMOS_KEY
|
||||
"""
|
||||
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the Cosmos history provider sample with an Agent."""
|
||||
project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT")
|
||||
deployment_name = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME")
|
||||
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
|
||||
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
|
||||
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
|
||||
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
|
||||
|
||||
if (
|
||||
not project_endpoint
|
||||
or not deployment_name
|
||||
or not cosmos_endpoint
|
||||
or not cosmos_database_name
|
||||
or not cosmos_container_name
|
||||
):
|
||||
print(
|
||||
"Please set AZURE_AI_PROJECT_ENDPOINT, AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME, "
|
||||
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME."
|
||||
)
|
||||
return
|
||||
|
||||
# 1. Create an Azure credential and Responses client using project endpoint auth.
|
||||
async with AzureCliCredential() as credential:
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_endpoint=project_endpoint,
|
||||
deployment_name=deployment_name,
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
# 2. Create an agent that uses the history provider as a context provider.
|
||||
async with (
|
||||
CosmosHistoryProvider(
|
||||
endpoint=cosmos_endpoint,
|
||||
database_name=cosmos_database_name,
|
||||
container_name=cosmos_container_name,
|
||||
credential=cosmos_key or credential,
|
||||
) as history_provider,
|
||||
client.as_agent(
|
||||
name="CosmosHistoryAgent",
|
||||
instructions="You are a helpful assistant that remembers prior turns.",
|
||||
context_providers=[history_provider],
|
||||
default_options={"store": False},
|
||||
) as agent,
|
||||
):
|
||||
# 3. Create a session (session_id is used as the partition key).
|
||||
session = agent.create_session()
|
||||
|
||||
# 4. Run a multi-turn conversation; history is persisted by CosmosHistoryProvider.
|
||||
response1 = await agent.run("My name is Ada and I enjoy distributed systems.", session=session)
|
||||
print(f"Assistant: {response1.text}")
|
||||
|
||||
response2 = await agent.run("What do you remember about me?", session=session)
|
||||
print(f"Assistant: {response2.text}")
|
||||
print(f"Container: {history_provider.container_name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
Assistant: Nice to meet you, Ada! Distributed systems are a fascinating area.
|
||||
Assistant: You told me your name is Ada and that you enjoy distributed systems.
|
||||
Container: <AZURE_COSMOS_CONTAINER_NAME>
|
||||
"""
|
||||
@@ -0,0 +1,409 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import agent_framework_azure_cosmos._history_provider as history_provider_module
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, Message
|
||||
from agent_framework._sessions import AgentSession, SessionContext
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
from agent_framework_azure_cosmos._history_provider import CosmosHistoryProvider
|
||||
from azure.cosmos.aio import CosmosClient
|
||||
from azure.cosmos.exceptions import CosmosResourceNotFoundError
|
||||
|
||||
skip_if_cosmos_integration_tests_disabled = pytest.mark.skipif(
|
||||
any(
|
||||
os.getenv(name, "") == ""
|
||||
for name in (
|
||||
"AZURE_COSMOS_ENDPOINT",
|
||||
"AZURE_COSMOS_KEY",
|
||||
"AZURE_COSMOS_DATABASE_NAME",
|
||||
"AZURE_COSMOS_CONTAINER_NAME",
|
||||
)
|
||||
),
|
||||
reason=(
|
||||
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_KEY, AZURE_COSMOS_DATABASE_NAME, and "
|
||||
"AZURE_COSMOS_CONTAINER_NAME are required for Cosmos integration tests."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _to_async_iter(items: list[Any]) -> AsyncIterator[Any]:
|
||||
async def _iterator() -> AsyncIterator[Any]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
return _iterator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_container() -> MagicMock:
|
||||
container = MagicMock()
|
||||
container.query_items = MagicMock(return_value=_to_async_iter([]))
|
||||
container.execute_item_batch = AsyncMock(return_value=[])
|
||||
return container
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cosmos_client(mock_container: MagicMock) -> MagicMock:
|
||||
database_client = MagicMock()
|
||||
database_client.create_container_if_not_exists = AsyncMock(return_value=mock_container)
|
||||
|
||||
client = MagicMock()
|
||||
client.get_database_client.return_value = database_client
|
||||
client.close = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
class TestCosmosHistoryProviderInit:
|
||||
def test_uses_provided_container_client(self, mock_container: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
assert provider.source_id == "mem"
|
||||
assert provider.load_messages is True
|
||||
assert provider.store_outputs is True
|
||||
assert provider.store_inputs is True
|
||||
assert provider.database_name == ""
|
||||
assert provider.container_name == ""
|
||||
|
||||
def test_uses_provided_cosmos_client(self, mock_cosmos_client: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(
|
||||
source_id="mem",
|
||||
cosmos_client=mock_cosmos_client,
|
||||
database_name="db1",
|
||||
container_name="history",
|
||||
)
|
||||
|
||||
mock_cosmos_client.get_database_client.assert_called_once_with("db1")
|
||||
assert provider.database_name == "db1"
|
||||
assert provider.container_name == "history"
|
||||
|
||||
def test_missing_required_settings_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("AZURE_COSMOS_ENDPOINT", raising=False)
|
||||
monkeypatch.delenv("AZURE_COSMOS_DATABASE_NAME", raising=False)
|
||||
monkeypatch.delenv("AZURE_COSMOS_CONTAINER_NAME", raising=False)
|
||||
monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False)
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="database_name"):
|
||||
CosmosHistoryProvider()
|
||||
|
||||
def test_constructs_client_with_string_credential(
|
||||
self, monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock
|
||||
) -> None:
|
||||
mock_factory = MagicMock(return_value=mock_cosmos_client)
|
||||
monkeypatch.setattr(history_provider_module, "CosmosClient", mock_factory)
|
||||
|
||||
CosmosHistoryProvider(
|
||||
endpoint="https://account.documents.azure.com:443/",
|
||||
credential="key-123",
|
||||
database_name="db1",
|
||||
container_name="history",
|
||||
)
|
||||
|
||||
mock_factory.assert_called_once()
|
||||
kwargs = mock_factory.call_args.kwargs
|
||||
assert kwargs["url"] == "https://account.documents.azure.com:443/"
|
||||
assert kwargs["credential"] == "key-123"
|
||||
|
||||
|
||||
class TestCosmosHistoryProviderContainerConfig:
|
||||
async def test_provider_container_name_is_used(self, mock_cosmos_client: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(
|
||||
source_id="mem",
|
||||
cosmos_client=mock_cosmos_client,
|
||||
database_name="db1",
|
||||
container_name="custom-history",
|
||||
)
|
||||
|
||||
await provider.get_messages("session-123")
|
||||
|
||||
database_client = mock_cosmos_client.get_database_client.return_value
|
||||
assert database_client.create_container_if_not_exists.await_count == 1
|
||||
kwargs = database_client.create_container_if_not_exists.await_args.kwargs
|
||||
assert kwargs["id"] == "custom-history"
|
||||
|
||||
|
||||
class TestCosmosHistoryProviderGetMessages:
|
||||
async def test_returns_deserialized_messages(self, mock_container: MagicMock) -> None:
|
||||
msg1 = Message(role="user", contents=["Hello"])
|
||||
msg2 = Message(role="assistant", contents=["Hi"])
|
||||
mock_container.query_items.return_value = _to_async_iter([
|
||||
{"message": msg1.to_dict()},
|
||||
{"message": msg2.to_dict()},
|
||||
])
|
||||
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
messages = await provider.get_messages("s1")
|
||||
|
||||
assert len(messages) == 2
|
||||
assert messages[0].role == "user"
|
||||
assert messages[0].text == "Hello"
|
||||
assert messages[1].role == "assistant"
|
||||
assert messages[1].text == "Hi"
|
||||
query_kwargs = mock_container.query_items.call_args.kwargs
|
||||
assert query_kwargs["partition_key"] == "s1"
|
||||
assert query_kwargs["query"] == (
|
||||
"SELECT c.message FROM c "
|
||||
"WHERE c.session_id = @session_id AND c.source_id = @source_id "
|
||||
"ORDER BY c.sort_key ASC"
|
||||
)
|
||||
assert query_kwargs["parameters"] == [
|
||||
{"name": "@session_id", "value": "s1"},
|
||||
{"name": "@source_id", "value": "mem"},
|
||||
]
|
||||
|
||||
async def test_empty_returns_empty(self, mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
messages = await provider.get_messages("s1")
|
||||
|
||||
assert messages == []
|
||||
|
||||
async def test_none_session_id_generates_guid_partition_key(
|
||||
self, mock_container: MagicMock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([])
|
||||
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
with caplog.at_level("WARNING"):
|
||||
await provider.get_messages(None)
|
||||
|
||||
query_kwargs = mock_container.query_items.call_args.kwargs
|
||||
session_key = query_kwargs["partition_key"]
|
||||
assert isinstance(session_key, str)
|
||||
assert session_key != ""
|
||||
assert session_key != "default"
|
||||
uuid.UUID(session_key)
|
||||
assert query_kwargs["parameters"] == [
|
||||
{"name": "@session_id", "value": session_key},
|
||||
{"name": "@source_id", "value": "mem"},
|
||||
]
|
||||
assert "Received empty session_id" in caplog.text
|
||||
|
||||
async def test_skips_non_dict_message_payload(self, mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([{"message": "bad"}, {"message": None}])
|
||||
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
messages = await provider.get_messages("s1")
|
||||
|
||||
assert messages == []
|
||||
|
||||
|
||||
class TestCosmosHistoryProviderListSessions:
|
||||
async def test_list_sessions_returns_unique_sorted_ids(self, mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter(["s2", "s1", "s1", "s3"])
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
|
||||
sessions = await provider.list_sessions()
|
||||
|
||||
assert sessions == ["s1", "s2", "s3"]
|
||||
kwargs = mock_container.query_items.call_args.kwargs
|
||||
assert kwargs["query"] == "SELECT DISTINCT VALUE c.session_id FROM c WHERE c.source_id = @source_id"
|
||||
assert kwargs["parameters"] == [{"name": "@source_id", "value": "mem"}]
|
||||
|
||||
|
||||
class TestCosmosHistoryProviderSaveMessages:
|
||||
async def test_saves_messages(self, mock_container: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
messages = [Message(role="user", contents=["Hello"]), Message(role="assistant", contents=["Hi"])]
|
||||
|
||||
await provider.save_messages("s1", messages)
|
||||
|
||||
mock_container.execute_item_batch.assert_awaited_once()
|
||||
batch_operations = mock_container.execute_item_batch.await_args.kwargs["batch_operations"]
|
||||
assert len(batch_operations) == 2
|
||||
first_operation, first_args = batch_operations[0]
|
||||
assert first_operation == "upsert"
|
||||
first_document = first_args[0]
|
||||
assert first_document["session_id"] == "s1"
|
||||
assert first_document["message"]["role"] == "user"
|
||||
assert mock_container.execute_item_batch.await_args.kwargs["partition_key"] == "s1"
|
||||
|
||||
async def test_empty_messages_noop(self, mock_container: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
|
||||
await provider.save_messages("s1", [])
|
||||
|
||||
mock_container.execute_item_batch.assert_not_awaited()
|
||||
|
||||
async def test_batches_when_message_count_exceeds_limit(self, mock_container: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
messages = [Message(role="user", contents=[f"msg-{index}"]) for index in range(101)]
|
||||
|
||||
await provider.save_messages("s1", messages)
|
||||
|
||||
assert mock_container.execute_item_batch.await_count == 2
|
||||
first_call = mock_container.execute_item_batch.await_args_list[0].kwargs
|
||||
second_call = mock_container.execute_item_batch.await_args_list[1].kwargs
|
||||
assert len(first_call["batch_operations"]) == 100
|
||||
assert len(second_call["batch_operations"]) == 1
|
||||
assert first_call["partition_key"] == "s1"
|
||||
assert second_call["partition_key"] == "s1"
|
||||
|
||||
|
||||
class TestCosmosHistoryProviderClear:
|
||||
async def test_clear_deletes_all_session_items(self, mock_container: MagicMock) -> None:
|
||||
mock_container.query_items.return_value = _to_async_iter([{"id": "1"}, {"id": "2"}])
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
|
||||
await provider.clear("s1")
|
||||
|
||||
mock_container.execute_item_batch.assert_awaited_once()
|
||||
batch_operations = mock_container.execute_item_batch.await_args.kwargs["batch_operations"]
|
||||
assert len(batch_operations) == 2
|
||||
assert batch_operations[0] == ("delete", ("1",))
|
||||
assert batch_operations[1] == ("delete", ("2",))
|
||||
assert mock_container.execute_item_batch.await_args.kwargs["partition_key"] == "s1"
|
||||
query_kwargs = mock_container.query_items.call_args.kwargs
|
||||
assert query_kwargs["query"] == (
|
||||
"SELECT c.id FROM c WHERE c.session_id = @session_id AND c.source_id = @source_id"
|
||||
)
|
||||
assert query_kwargs["parameters"] == [
|
||||
{"name": "@session_id", "value": "s1"},
|
||||
{"name": "@source_id", "value": "mem"},
|
||||
]
|
||||
|
||||
|
||||
class TestCosmosHistoryProviderBeforeAfterRun:
|
||||
async def test_before_run_loads_history(self, mock_container: MagicMock) -> None:
|
||||
msg = Message(role="user", contents=["old msg"])
|
||||
mock_container.query_items.return_value = _to_async_iter([{"message": msg.to_dict()}])
|
||||
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
session = AgentSession(session_id="test")
|
||||
context = SessionContext(input_messages=[Message(role="user", contents=["new msg"])], session_id="s1")
|
||||
|
||||
await provider.before_run(
|
||||
agent=None, session=session, context=context, state=session.state.setdefault(provider.source_id, {})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert "mem" in context.context_messages
|
||||
assert context.context_messages["mem"][0].text == "old msg"
|
||||
|
||||
async def test_after_run_stores_input_and_response(self, mock_container: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
session = AgentSession(session_id="test")
|
||||
context = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
context._response = AgentResponse(messages=[Message(role="assistant", contents=["hello"])])
|
||||
|
||||
await provider.after_run(
|
||||
agent=None, session=session, context=context, state=session.state.setdefault(provider.source_id, {})
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
mock_container.execute_item_batch.assert_awaited_once()
|
||||
batch_operations = mock_container.execute_item_batch.await_args.kwargs["batch_operations"]
|
||||
assert len(batch_operations) == 2
|
||||
input_doc = batch_operations[0][1][0]
|
||||
response_doc = batch_operations[1][1][0]
|
||||
assert input_doc["message"]["role"] == "user"
|
||||
assert input_doc["message"]["contents"][0]["text"] == "hi"
|
||||
assert response_doc["message"]["role"] == "assistant"
|
||||
assert response_doc["message"]["contents"][0]["text"] == "hello"
|
||||
|
||||
|
||||
class TestCosmosHistoryProviderClose:
|
||||
async def test_close_closes_owned_client(
|
||||
self, monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock
|
||||
) -> None:
|
||||
mock_factory = MagicMock(return_value=mock_cosmos_client)
|
||||
monkeypatch.setattr(history_provider_module, "CosmosClient", mock_factory)
|
||||
|
||||
provider = CosmosHistoryProvider(
|
||||
endpoint="https://account.documents.azure.com:443/",
|
||||
credential="key-123",
|
||||
database_name="db1",
|
||||
container_name="history",
|
||||
)
|
||||
|
||||
await provider.close()
|
||||
|
||||
mock_cosmos_client.close.assert_awaited_once()
|
||||
|
||||
async def test_close_does_not_close_external_client(self, mock_cosmos_client: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(
|
||||
source_id="mem",
|
||||
cosmos_client=mock_cosmos_client,
|
||||
database_name="db1",
|
||||
container_name="history",
|
||||
)
|
||||
|
||||
await provider.close()
|
||||
|
||||
mock_cosmos_client.close.assert_not_awaited()
|
||||
|
||||
async def test_async_context_manager_closes_owned_client(
|
||||
self, monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock
|
||||
) -> None:
|
||||
mock_factory = MagicMock(return_value=mock_cosmos_client)
|
||||
monkeypatch.setattr(history_provider_module, "CosmosClient", mock_factory)
|
||||
|
||||
async with CosmosHistoryProvider(
|
||||
endpoint="https://account.documents.azure.com:443/",
|
||||
credential="key-123",
|
||||
database_name="db1",
|
||||
container_name="history",
|
||||
) as provider:
|
||||
assert provider is not None
|
||||
|
||||
mock_cosmos_client.close.assert_awaited_once()
|
||||
|
||||
async def test_async_context_manager_preserves_original_exception(self, mock_container: MagicMock) -> None:
|
||||
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
|
||||
|
||||
with patch.object(
|
||||
provider, "close", AsyncMock(side_effect=RuntimeError("close failed"))
|
||||
), pytest.raises(ValueError, match="inner error"):
|
||||
async with provider:
|
||||
raise ValueError("inner error")
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_cosmos_integration_tests_disabled
|
||||
async def test_cosmos_history_provider_roundtrip_with_emulator() -> None:
|
||||
endpoint = os.getenv("AZURE_COSMOS_ENDPOINT", "")
|
||||
key = os.getenv("AZURE_COSMOS_KEY", "")
|
||||
database_prefix = os.getenv("AZURE_COSMOS_DATABASE_NAME", "")
|
||||
container_prefix = os.getenv("AZURE_COSMOS_CONTAINER_NAME", "")
|
||||
unique = uuid.uuid4().hex[:8]
|
||||
database_name = f"{database_prefix}-{unique}"
|
||||
container_name = f"{container_prefix}-{unique}"
|
||||
session_id = f"session-{unique}"
|
||||
|
||||
async with CosmosClient(url=endpoint, credential=key) as cosmos_client:
|
||||
await cosmos_client.create_database_if_not_exists(id=database_name)
|
||||
provider = CosmosHistoryProvider(
|
||||
source_id="cosmos_integration",
|
||||
cosmos_client=cosmos_client,
|
||||
database_name=database_name,
|
||||
container_name=container_name,
|
||||
)
|
||||
|
||||
try:
|
||||
await provider.save_messages(
|
||||
session_id,
|
||||
[
|
||||
Message(role="user", contents=["Hello Cosmos"]),
|
||||
Message(role="assistant", contents=["Hi from Cosmos"]),
|
||||
],
|
||||
)
|
||||
|
||||
stored_messages = await provider.get_messages(session_id)
|
||||
assert [message.role for message in stored_messages] == ["user", "assistant"]
|
||||
assert [message.text for message in stored_messages] == ["Hello Cosmos", "Hi from Cosmos"]
|
||||
|
||||
sessions = await provider.list_sessions()
|
||||
assert session_id in sessions
|
||||
|
||||
await provider.clear(session_id)
|
||||
assert await provider.get_messages(session_id) == []
|
||||
finally:
|
||||
with suppress(CosmosResourceNotFoundError):
|
||||
await cosmos_client.delete_database(database_name)
|
||||
@@ -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",
|
||||
|
||||
@@ -548,6 +548,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
return []
|
||||
|
||||
all_messages: list[dict[str, Any]] = []
|
||||
pending_reasoning: Any = None
|
||||
for content in message.contents:
|
||||
# Skip approval content - it's internal framework state, not for the LLM
|
||||
if content.type in ("function_approval_request", "function_approval_response"):
|
||||
@@ -575,15 +576,33 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
# Functions returning None should still have a tool result message
|
||||
args["content"] = content.result if content.result is not None else ""
|
||||
case "text_reasoning" if (protected_data := content.protected_data) is not None:
|
||||
all_messages[-1]["reasoning_details"] = json.loads(protected_data)
|
||||
# Buffer reasoning to attach to the next message with content/tool_calls
|
||||
pending_reasoning = json.loads(protected_data)
|
||||
case _:
|
||||
if "content" not in args:
|
||||
args["content"] = []
|
||||
# this is a list to allow multi-modal content
|
||||
args["content"].append(self._prepare_content_for_openai(content)) # type: ignore
|
||||
if "content" in args or "tool_calls" in args:
|
||||
if pending_reasoning is not None:
|
||||
args["reasoning_details"] = pending_reasoning
|
||||
pending_reasoning = None
|
||||
all_messages.append(args)
|
||||
|
||||
# If reasoning was the only content, emit a valid message with empty content
|
||||
if pending_reasoning is not None:
|
||||
if all_messages:
|
||||
all_messages[-1]["reasoning_details"] = pending_reasoning
|
||||
else:
|
||||
pending_args: dict[str, Any] = {
|
||||
"role": message.role,
|
||||
"content": "",
|
||||
"reasoning_details": pending_reasoning,
|
||||
}
|
||||
if message.author_name and message.role != "tool":
|
||||
pending_args["name"] = message.author_name
|
||||
all_messages.append(pending_args)
|
||||
|
||||
# Flatten text-only content lists to plain strings for broader
|
||||
# compatibility with OpenAI-like endpoints (e.g. Foundry Local).
|
||||
# See https://github.com/microsoft/agent-framework/issues/4084
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -643,6 +643,110 @@ def test_prepare_message_with_text_reasoning_content(openai_unit_test_env: dict[
|
||||
assert prepared[0]["content"] == "The answer is 42."
|
||||
|
||||
|
||||
def test_prepare_message_with_only_text_reasoning_content(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test that a message with only text_reasoning content does not raise IndexError.
|
||||
|
||||
Regression test for https://github.com/microsoft/agent-framework/issues/4384
|
||||
Reasoning models (e.g. gpt-5-mini) may produce reasoning_details without text content,
|
||||
which previously caused an IndexError when preparing messages.
|
||||
"""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
mock_reasoning_data = {
|
||||
"effort": "high",
|
||||
"summary": "Deep analysis of the problem",
|
||||
}
|
||||
|
||||
reasoning_content = Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data))
|
||||
|
||||
# Message with only reasoning content and no text
|
||||
message = Message(
|
||||
role="assistant",
|
||||
contents=[reasoning_content],
|
||||
)
|
||||
|
||||
prepared = client._prepare_message_for_openai(message)
|
||||
|
||||
# Should have one message with reasoning_details
|
||||
assert len(prepared) == 1
|
||||
assert prepared[0]["role"] == "assistant"
|
||||
assert "reasoning_details" in prepared[0]
|
||||
assert prepared[0]["reasoning_details"] == mock_reasoning_data
|
||||
# Message should also include a content field to be a valid Chat Completions payload
|
||||
assert "content" in prepared[0]
|
||||
assert prepared[0]["content"] == ""
|
||||
|
||||
|
||||
def test_prepare_message_with_text_reasoning_before_text(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test that text_reasoning content appearing before text content is handled correctly.
|
||||
|
||||
Regression test for https://github.com/microsoft/agent-framework/issues/4384
|
||||
"""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
mock_reasoning_data = {
|
||||
"effort": "medium",
|
||||
"summary": "Quick analysis",
|
||||
}
|
||||
|
||||
reasoning_content = Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data))
|
||||
|
||||
# Reasoning appears before text content
|
||||
message = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
reasoning_content,
|
||||
Content.from_text(text="The answer is 42."),
|
||||
],
|
||||
)
|
||||
|
||||
prepared = client._prepare_message_for_openai(message)
|
||||
|
||||
# Should produce exactly one message without raising IndexError
|
||||
assert len(prepared) == 1
|
||||
|
||||
# Reasoning details should be present on the message
|
||||
assert "reasoning_details" in prepared[0]
|
||||
assert prepared[0]["reasoning_details"] == mock_reasoning_data
|
||||
assert prepared[0]["content"] == "The answer is 42."
|
||||
|
||||
|
||||
def test_prepare_message_with_text_reasoning_before_function_call(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test that text_reasoning content appearing before a function call is handled correctly.
|
||||
|
||||
Regression test for https://github.com/microsoft/agent-framework/issues/4384
|
||||
"""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
mock_reasoning_data = {
|
||||
"effort": "medium",
|
||||
"summary": "Deciding to call a function",
|
||||
}
|
||||
|
||||
reasoning_content = Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data))
|
||||
|
||||
# Reasoning appears before function call content
|
||||
message = Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
reasoning_content,
|
||||
Content.from_function_call(call_id="call_abc", name="get_weather", arguments='{"city": "Seattle"}'),
|
||||
],
|
||||
)
|
||||
|
||||
prepared = client._prepare_message_for_openai(message)
|
||||
|
||||
# Should produce exactly one message
|
||||
assert len(prepared) == 1
|
||||
|
||||
# The message should carry the reasoning details and tool_calls
|
||||
assert "reasoning_details" in prepared[0]
|
||||
assert prepared[0]["reasoning_details"] == mock_reasoning_data
|
||||
assert "tool_calls" in prepared[0]
|
||||
assert prepared[0]["tool_calls"][0]["function"]["name"] == "get_weather"
|
||||
assert prepared[0]["role"] == "assistant"
|
||||
|
||||
|
||||
def test_function_approval_content_is_skipped_in_preparation(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test that function approval request and response content are skipped."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,6 +76,7 @@ agent-framework-core = { workspace = true }
|
||||
agent-framework-a2a = { workspace = true }
|
||||
agent-framework-ag-ui = { workspace = true }
|
||||
agent-framework-azure-ai-search = { workspace = true }
|
||||
agent-framework-azure-cosmos = { workspace = true }
|
||||
agent-framework-anthropic = { workspace = true }
|
||||
agent-framework-azure-ai = { workspace = true }
|
||||
agent-framework-azurefunctions = { workspace = true }
|
||||
@@ -238,6 +239,7 @@ check = ["check-packages", "samples-lint", "samples-syntax", "test", "markdown-c
|
||||
[tool.poe.tasks.all-tests-cov]
|
||||
cmd = """
|
||||
pytest --import-mode=importlib
|
||||
-m "not integration"
|
||||
--cov=agent_framework
|
||||
--cov=agent_framework_core
|
||||
--cov=agent_framework_a2a
|
||||
@@ -265,6 +267,7 @@ pytest --import-mode=importlib
|
||||
[tool.poe.tasks.all-tests]
|
||||
cmd = """
|
||||
pytest --import-mode=importlib
|
||||
-m "not integration"
|
||||
--ignore-glob=packages/lab/**
|
||||
--ignore-glob=packages/devui/**
|
||||
-rs
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Agent, Message, 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 @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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def run_bash(command: str) -> str:
|
||||
"""Execute a bash command using subprocess and return the output."""
|
||||
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()
|
||||
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=[shell],
|
||||
)
|
||||
|
||||
query = "Use bash to print 'Hello from Anthropic shell!' and show the current working directory"
|
||||
print(f"User: {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())
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Agent, Message, 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 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.
|
||||
Only enable this when you trust the agent's actions. Consider implementing
|
||||
allowlists, sandboxing, or approval workflows for production use.
|
||||
"""
|
||||
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def run_bash(command: str) -> str:
|
||||
"""Execute a shell command locally and return stdout, stderr, and exit code."""
|
||||
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 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()
|
||||
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=[local_shell_tool],
|
||||
)
|
||||
|
||||
query = "Use the run_bash tool to execute `python --version` and show only the command output."
|
||||
print(f"User: {query}")
|
||||
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__":
|
||||
asyncio.run(main())
|
||||
@@ -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_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_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())
|
||||
Generated
+508
-444
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user