Merge branch 'main' into feature-xunit3-mtp-upgrade

This commit is contained in:
westey
2026-03-03 17:41:47 +00:00
committed by GitHub
Unverified
31 changed files with 3378 additions and 507 deletions
+47 -1
View File
@@ -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
+62
View File
@@ -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
+28
View File
@@ -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
```
+21
View File
@@ -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
+38
View File
@@ -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
+3
View File
@@ -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())
@@ -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())
+508 -444
View File
File diff suppressed because it is too large Load Diff