Python: [BREAKING] Main to core (#983)

* removed pydantic from types

* fix assistants client

* Remove Pydantic usage from workflow code.

* updated lock and test fixes

* moved main to core, and setup meta package

* updated versions

* updated lock

* fixed agents dependency

* added retry to merge tests

---------

Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
This commit is contained in:
Eduard van Valkenburg
2025-09-30 07:18:36 +00:00
committed by GitHub
co-authored by Evan Mattson
parent fc4fce7973
commit 35d2d9fe7f
137 changed files with 308 additions and 543 deletions
+15
View File
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
}
]
}
+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
+233
View File
@@ -0,0 +1,233 @@
# Get Started with Microsoft Agent Framework
Highlights
- Flexible Agent Framework: build, orchestrate, and deploy AI agents and multi-agent systems
- Multi-Agent Orchestration: Group chat, sequential, concurrent, and handoff patterns
- Plugin Ecosystem: Extend with native functions, OpenAPI, Model Context Protocol (MCP), and more
- LLM Support: OpenAI, Azure OpenAI, Azure AI, and more
- Runtime Support: In-process and distributed agent execution
- Multimodal: Text, vision, and function calling
- Cross-Platform: .NET and Python implementations
## Quick Install
```bash
pip install agent-framework[all]
# Optional: Add Azure AI integration
pip install agent-framework-azure-ai
# Optional: Both
pip install agent-framework-azure-ai agent-framework-copilotstudio
```
Supported Platforms:
- Python: 3.10+
- OS: Windows, macOS, Linux
## 1. Setup API Keys
Set as environment variables, or create a .env file at your project root:
```bash
OPENAI_API_KEY=sk-...
OPENAI_CHAT_MODEL_ID=...
OPENAI_RESPONSES_MODEL_ID=...
...
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_ENDPOINT=...
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=...
...
AZURE_AI_PROJECT_ENDPOINT=...
AZURE_AI_MODEL_DEPLOYMENT_NAME=...
```
You can also override environment variables by explicitly passing configuration parameters to the chat client constructor:
```python
from agent_framework.azure import AzureOpenAIChatClient
chat_client = AzureOpenAIChatClient(
api_key="",
endpoint="",
deployment_name="",
api_version="",
)
```
See the following [setup guide](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started) for more information.
## 2. Create a Simple Agent
Create agents and invoke them directly:
```python
import asyncio
from agent_framework import ChatAgent
from agent_framework.openai import OpenAIChatClient
async def main():
agent = ChatAgent(
chat_client=OpenAIChatClient(),
instructions="""
1) A robot may not injure a human being...
2) A robot must obey orders given it by human beings...
3) A robot must protect its own existence...
Give me the TLDR in exactly 5 words.
"""
)
result = await agent.run("Summarize the Three Laws of Robotics")
print(result)
asyncio.run(main())
# Output: Protect humans, obey, self-preserve, prioritized.
```
## 3. Directly Use Chat Clients (No Agent Required)
You can use the chat client classes directly for advanced workflows:
```python
import asyncio
from agent_framework.openai import OpenAIChatClient
from agent_framework import ChatMessage, Role
async def main():
client = OpenAIChatClient()
messages = [
ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant."),
ChatMessage(role=Role.USER, text="Write a haiku about Agent Framework.")
]
response = await client.get_response(messages)
print(response.messages[0].text)
"""
Output:
Agents work in sync,
Framework threads through each task—
Code sparks collaboration.
"""
asyncio.run(main())
```
## 4. Build an Agent with Tools and Functions
Enhance your agent with custom tools and function calling:
```python
import asyncio
from typing import Annotated
from random import randint
from pydantic import Field
from agent_framework import ChatAgent
from agent_framework.openai import OpenAIChatClient
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
def get_menu_specials() -> str:
"""Get today's menu specials."""
return """
Special Soup: Clam Chowder
Special Salad: Cobb Salad
Special Drink: Chai Tea
"""
async def main():
agent = ChatAgent(
chat_client=OpenAIChatClient(),
instructions="You are a helpful assistant that can provide weather and restaurant information.",
tools=[get_weather, get_menu_specials]
)
response = await agent.run("What's the weather in Amsterdam and what are today's specials?")
print(response)
# Output:
# The weather in Amsterdam is sunny with a high of 22°C. Today's specials include
# Clam Chowder soup, Cobb Salad, and Chai Tea as the special drink.
asyncio.run(main())
```
You can explore additional agent samples [here](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents).
## 5. Multi-Agent Orchestration
Coordinate multiple agents to collaborate on complex tasks using orchestration patterns:
```python
import asyncio
from agent_framework import ChatAgent
from agent_framework.openai import OpenAIChatClient
async def main():
# Create specialized agents
writer = ChatAgent(
chat_client=OpenAIChatClient(),
name="Writer",
instructions="You are a creative content writer. Generate and refine slogans based on feedback."
)
reviewer = ChatAgent(
chat_client=OpenAIChatClient(),
name="Reviewer",
instructions="You are a critical reviewer. Provide detailed feedback on proposed slogans."
)
# Sequential workflow: Writer creates, Reviewer provides feedback
task = "Create a slogan for a new electric SUV that is affordable and fun to drive."
# Step 1: Writer creates initial slogan
initial_result = await writer.run(task)
print(f"Writer: {initial_result}")
# Step 2: Reviewer provides feedback
feedback_request = f"Please review this slogan: {initial_result}"
feedback = await reviewer.run(feedback_request)
print(f"Reviewer: {feedback}")
# Step 3: Writer refines based on feedback
refinement_request = f"Please refine this slogan based on the feedback: {initial_result}\nFeedback: {feedback}"
final_result = await writer.run(refinement_request)
print(f"Final Slogan: {final_result}")
# Example Output:
# Writer: "Charge Forward: Affordable Adventure Awaits!"
# Reviewer: "Good energy, but 'Charge Forward' is overused in EV marketing..."
# Final Slogan: "Power Up Your Adventure: Premium Feel, Smart Price!"
if __name__ == "__main__":
asyncio.run(main())
```
**Note**: Advanced orchestration patterns like GroupChat, Sequential, and Concurrent orchestrations are coming soon.
## More Examples & Samples
- [Getting Started with Agents](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents): Basic agent creation and tool usage
- [Chat Client Examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/chat_client): Direct chat client usage patterns
- [Azure AI Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/azure-ai): Azure AI integration
- [.NET Orchestration Samples](https://github.com/microsoft/agent-framework/tree/main/dotnet/samples/GettingStarted/Orchestration): Advanced multi-agent patterns (.NET)
## Agent Framework Documentation
- [Agent Framework Repository](https://github.com/microsoft/agent-framework)
- [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python)
- [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet)
- [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design)
- Learn docs are coming soon.
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
import importlib.metadata
from typing import Final
try:
_version = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
_version = "0.0.0" # Fallback for development mode
__version__: Final[str] = _version
from ._agents import * # noqa: F403
from ._clients import * # noqa: F403
from ._logging import * # noqa: F403
from ._mcp import * # noqa: F403
from ._memory import * # noqa: F403
from ._middleware import * # noqa: F403
from ._telemetry import * # noqa: F403
from ._threads import * # noqa: F403
from ._tools import * # noqa: F403
from ._types import * # noqa: F403
from ._workflow import * # noqa: F403
@@ -0,0 +1,856 @@
# Copyright (c) Microsoft. All rights reserved.
import inspect
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack
from copy import copy
from itertools import chain
from typing import Any, ClassVar, Literal, Protocol, TypeVar, cast, runtime_checkable
from uuid import uuid4
from pydantic import BaseModel, Field, create_model
from ._clients import BaseChatClient, ChatClientProtocol
from ._logging import get_logger
from ._mcp import MCPTool
from ._memory import AggregateContextProvider, Context, ContextProvider
from ._middleware import Middleware, use_agent_middleware
from ._threads import AgentThread, ChatMessageStoreProtocol
from ._tools import FUNCTION_INVOKING_CHAT_CLIENT_MARKER, AIFunction, ToolProtocol
from ._types import (
AgentRunResponse,
AgentRunResponseUpdate,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Role,
ToolMode,
)
from .exceptions import AgentExecutionException
from .observability import use_agent_observability
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
logger = get_logger("agent_framework")
TThreadType = TypeVar("TThreadType", bound="AgentThread")
__all__ = ["AgentProtocol", "BaseAgent", "ChatAgent"]
# region Agent Protocol
@runtime_checkable
class AgentProtocol(Protocol):
"""A protocol for an agent that can be invoked."""
@property
def id(self) -> str:
"""Returns the ID of the agent."""
...
@property
def name(self) -> str | None:
"""Returns the name of the agent."""
...
@property
def display_name(self) -> str:
"""Returns the display name of the agent."""
...
@property
def description(self) -> str | None:
"""Returns the description of the agent."""
...
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentRunResponse:
"""Get a response from the agent.
This method returns the final result of the agent's execution
as a single AgentRunResponse object. The caller is blocked until
the final result is available.
Note: For streaming responses, use the run_stream method, which returns
intermediate steps and the final result as a stream of AgentRunResponseUpdate
objects. Streaming only the final result is not feasible because the timing of
the final result's availability is unknown, and blocking the caller until then
is undesirable in streaming scenarios.
Args:
messages: The message(s) to send to the agent.
thread: The conversation thread associated with the message(s).
kwargs: Additional keyword arguments.
Returns:
An agent response item.
"""
...
def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
"""Run the agent as a stream.
This method will return the intermediate steps and final results of the
agent's execution as a stream of AgentRunResponseUpdate objects to the caller.
Note: An AgentRunResponseUpdate object contains a chunk of a message.
Args:
messages: The message(s) to send to the agent.
thread: The conversation thread associated with the message(s).
kwargs: Additional keyword arguments.
Yields:
An agent response item.
"""
...
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Creates a new conversation thread for the agent."""
...
# region BaseAgent
class BaseAgent:
"""Base class for all Agent Framework agents.
Attributes:
id: The unique identifier of the agent If no id is provided,
a new UUID will be generated.
name: The name of the agent, can be None.
description: The description of the agent.
display_name: The display name of the agent, which is either the name or id.
context_providers: The collection of multiple context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
"""
def __init__(
self,
id: str | None = None,
name: str | None = None,
description: str | None = None,
context_providers: ContextProvider | Sequence[ContextProvider] | None = None,
middleware: Middleware | Sequence[Middleware] | None = None,
**kwargs: Any,
) -> None:
"""Base class for all Agent Framework agents.
Args:
id: The unique identifier of the agent If no id is provided,
a new UUID will be generated.
name: The name of the agent, can be None.
description: The description of the agent.
display_name: The display name of the agent, which is either the name or id.
context_providers: The collection of multiple context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
kwargs: will be stored in `additional_properties`
"""
if id is None:
id = str(uuid4())
self.id = id
self.name = name
self.description = description
self.context_provider = self._prepare_context_providers(context_providers)
if middleware is None or isinstance(middleware, Sequence):
self.middleware: list[Middleware] | None = cast(list[Middleware], middleware) if middleware else None
else:
self.middleware = [middleware]
self.additional_properties = kwargs
async def _notify_thread_of_new_messages(
self,
thread: AgentThread,
input_messages: ChatMessage | Sequence[ChatMessage],
response_messages: ChatMessage | Sequence[ChatMessage],
) -> None:
"""Notify the thread of new messages.
This also calls the invoked method of a potential context provider on the thread.
"""
if isinstance(input_messages, ChatMessage) or len(input_messages) > 0:
await thread.on_new_messages(input_messages)
if isinstance(response_messages, ChatMessage) or len(response_messages) > 0:
await thread.on_new_messages(response_messages)
if thread.context_provider:
await thread.context_provider.invoked(input_messages, response_messages)
@property
def display_name(self) -> str:
"""Returns the display name of the agent.
This is the name if present, otherwise the id.
"""
return self.name or self.id
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Returns AgentThread instance that is compatible with the agent."""
return AgentThread(**kwargs, context_provider=self.context_provider)
async def deserialize_thread(self, serialized_thread: Any, **kwargs: Any) -> AgentThread:
"""Deserializes the thread."""
thread: AgentThread = self.get_new_thread()
await thread.deserialize(serialized_thread, **kwargs)
return thread
def as_tool(
self,
*,
name: str | None = None,
description: str | None = None,
arg_name: str = "task",
arg_description: str | None = None,
stream_callback: Callable[[AgentRunResponseUpdate], None]
| Callable[[AgentRunResponseUpdate], Awaitable[None]]
| None = None,
) -> AIFunction[BaseModel, str]:
"""Create an AIFunction tool that wraps this agent.
Args:
name: The name for the tool. If None, uses the agent's name.
description: The description for the tool. If None, uses the agent's description or empty string.
arg_name: The name of the function argument (default: "task").
arg_description: The description for the function argument.
If None, defaults to "Input for {self.display_name}".
stream_callback: Optional callback for streaming responses. If provided, uses run_stream.
Returns:
An AIFunction that can be used as a tool by other agents.
"""
# Verify that self implements AgentProtocol
if not isinstance(self, AgentProtocol):
raise TypeError(f"Agent {self.__class__.__name__} must implement AgentProtocol to be used as a tool")
tool_name = name or self.name
if tool_name is None:
raise ValueError("Agent tool name cannot be None. Either provide a name parameter or set the agent's name.")
tool_description = description or self.description or ""
argument_description = arg_description or f"Task for {tool_name}"
# Create dynamic input model with the specified argument name
field_info = Field(..., description=argument_description)
input_model = create_model(f"{name or self.name or 'agent'}_task", **{arg_name: (str, field_info)}) # type: ignore[call-overload]
# Check if callback is async once, outside the wrapper
is_async_callback = stream_callback is not None and inspect.iscoroutinefunction(stream_callback)
async def agent_wrapper(**kwargs: Any) -> str:
"""Wrapper function that calls the agent."""
# Extract the input from kwargs using the specified arg_name
input_text = kwargs.get(arg_name, "")
if stream_callback is None:
# Use non-streaming mode
return (await self.run(input_text)).text
# Use streaming mode - accumulate updates and create final response
response_updates: list[AgentRunResponseUpdate] = []
async for update in self.run_stream(input_text):
response_updates.append(update)
if is_async_callback:
await stream_callback(update) # type: ignore[misc]
else:
stream_callback(update)
# Create final text from accumulated updates
return AgentRunResponse.from_agent_run_response_updates(response_updates).text
return AIFunction(
name=tool_name,
description=tool_description,
func=agent_wrapper,
input_model=input_model,
)
def _normalize_messages(
self,
messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None,
) -> list[ChatMessage]:
if messages is None:
return []
if isinstance(messages, str):
return [ChatMessage(role=Role.USER, text=messages)]
if isinstance(messages, ChatMessage):
return [messages]
return [ChatMessage(role=Role.USER, text=msg) if isinstance(msg, str) else msg for msg in messages]
def _prepare_context_providers(
self,
context_providers: ContextProvider | Sequence[ContextProvider] | None = None,
) -> AggregateContextProvider | None:
if not context_providers:
return None
if isinstance(context_providers, AggregateContextProvider):
return context_providers
return AggregateContextProvider(context_providers)
# region ChatAgent
@use_agent_middleware
@use_agent_observability
class ChatAgent(BaseAgent):
"""A Chat Client Agent."""
AGENT_SYSTEM_NAME: ClassVar[str] = "microsoft.agent_framework"
def __init__(
self,
chat_client: ChatClientProtocol,
instructions: str | None = None,
*,
id: str | None = None,
name: str | None = None,
description: str | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
middleware: Middleware | list[Middleware] | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
request_kwargs: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Create a ChatAgent.
Remarks:
The set of attributes from frequency_penalty to additional_properties are used to
call the chat client, they can also be passed to both run methods.
When both are set, the ones passed to the run methods take precedence.
Args:
chat_client: The chat client to use for the agent.
instructions: Optional instructions for the agent.
These will be put into the messages sent to the chat client service as a system message.
id: The unique identifier for the agent, will be created automatically if not provided.
name: The name of the agent.
description: A brief description of the agent's purpose.
chat_message_store_factory: factory function to create an instance of ChatMessageStoreProtocol.
If not provided, the default in-memory store will be used.
context_providers: The collection of multiple context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
request_kwargs: a dictionary of other values that will be passed through
to the chat_client `get_response` and `get_streaming_response` methods.
kwargs: any additional keyword arguments. Will be stored as `additional_properties`
"""
if not hasattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER) and isinstance(chat_client, BaseChatClient):
logger.warning(
"The provided chat client does not support function invoking, this might limit agent capabilities."
)
super().__init__(
id=id,
name=name,
description=description,
context_providers=context_providers,
middleware=middleware,
**kwargs,
)
self.chat_client = chat_client
self.chat_message_store_factory = chat_message_store_factory
# We ignore the MCP Servers here and store them separately,
# we add their functions to the tools list at runtime
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType]
[] if tools is None else tools if isinstance(tools, list) else [tools] # type: ignore[list-item]
)
self._local_mcp_tools = [tool for tool in normalized_tools if isinstance(tool, MCPTool)]
agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)]
self.chat_options = ChatOptions(
model_id=model,
frequency_penalty=frequency_penalty,
instructions=instructions,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=agent_tools,
top_p=top_p,
user=user,
additional_properties=request_kwargs or {}, # type: ignore
)
self._async_exit_stack = AsyncExitStack()
self._update_agent_name()
async def __aenter__(self) -> "Self":
"""Async context manager entry.
If any of the chat_client, local_mcp_tools, or context_providers are context managers,
they will be entered into the async exit stack to ensure proper cleanup.
This list might be extended in the future.
"""
for context_manager in chain([self.chat_client], self._local_mcp_tools):
if isinstance(context_manager, AbstractAsyncContextManager):
await self._async_exit_stack.enter_async_context(context_manager)
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Async context manager exit.
Close the async exit stack to ensure all context managers are exited properly.
"""
await self._async_exit_stack.aclose()
def _update_agent_name(self) -> None:
"""Update the agent name in a chat client.
Checks if there is a agent name, the implementation
should check if there is already a agent name defined, and if not
set it to this value.
"""
if hasattr(self.chat_client, "_update_agent_name") and callable(self.chat_client._update_agent_name): # type: ignore[reportAttributeAccessIssue, attr-defined]
self.chat_client._update_agent_name(self.name) # type: ignore[reportAttributeAccessIssue, attr-defined]
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> AgentRunResponse:
"""Run the agent with the given messages and options.
Remarks:
Since you won't always call the agent.run directly, but it get's called
through orchestration, it is advised to set your default values for
all the chat client parameters in the agent constructor.
If both parameters are used, the ones passed to the run methods take precedence.
Args:
messages: The messages to process.
thread: The thread to use for the agent.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request.
kwargs: Additional keyword arguments for the agent.
will only be passed to functions that are called.
"""
input_messages = self._normalize_messages(messages)
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
thread=thread, input_messages=input_messages
)
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType]
[] if tools is None else tools if isinstance(tools, list) else [tools]
)
agent_name = self._get_agent_name()
# Resolve final tool list (runtime provided tools + local MCP server tools)
final_tools: list[ToolProtocol | Callable[..., Any] | dict[str, Any]] = []
# Normalize tools argument to a list without mutating the original parameter
for tool in normalized_tools:
if isinstance(tool, MCPTool):
if not tool.is_connected:
await self._async_exit_stack.enter_async_context(tool)
final_tools.extend(tool.functions) # type: ignore
else:
final_tools.append(tool) # type: ignore
for mcp_server in self._local_mcp_tools:
if not mcp_server.is_connected:
await self._async_exit_stack.enter_async_context(mcp_server)
final_tools.extend(mcp_server.functions)
response = await self.chat_client.get_response(
messages=thread_messages,
chat_options=run_chat_options
& ChatOptions(
model_id=model,
conversation_id=thread.service_thread_id,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=final_tools,
top_p=top_p,
user=user,
additional_properties=additional_properties or {},
),
**kwargs,
)
await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
# Ensure that the author name is set for each message in the response.
for message in response.messages:
if message.author_name is None:
message.author_name = agent_name
# Only notify the thread of new messages if the chatResponse was successful
# to avoid inconsistent messages state in the thread.
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
return AgentRunResponse(
messages=response.messages,
response_id=response.response_id,
created_at=response.created_at,
usage_details=response.usage_details,
value=response.value,
raw_representation=response,
additional_properties=response.additional_properties,
)
async def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
"""Stream the agent with the given messages and options.
Remarks:
Since you won't always call the agent.run_stream directly, but it get's called
through orchestration, it is advised to set your default values for
all the chat client parameters in the agent constructor.
If both parameters are used, the ones passed to the run methods take precedence.
Args:
messages: The messages to process.
thread: The thread to use for the agent.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request.
kwargs: any additional keyword arguments.
will only be passed to functions that are called.
"""
input_messages = self._normalize_messages(messages)
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
thread=thread, input_messages=input_messages
)
agent_name = self._get_agent_name()
response_updates: list[ChatResponseUpdate] = []
# Resolve final tool list (runtime provided tools + local MCP server tools)
final_tools: list[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]] = []
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type: ignore[reportUnknownVariableType]
[] if tools is None else tools if isinstance(tools, list) else [tools]
)
# Normalize tools argument to a list without mutating the original parameter
for tool in normalized_tools:
if isinstance(tool, MCPTool):
if not tool.is_connected:
await self._async_exit_stack.enter_async_context(tool)
final_tools.extend(tool.functions) # type: ignore
else:
final_tools.append(tool)
for mcp_server in self._local_mcp_tools:
if not mcp_server.is_connected:
await self._async_exit_stack.enter_async_context(mcp_server)
final_tools.extend(mcp_server.functions)
async for update in self.chat_client.get_streaming_response(
messages=thread_messages,
chat_options=run_chat_options
& ChatOptions(
conversation_id=thread.service_thread_id,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
model_id=model,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=final_tools, # type: ignore[reportArgumentType]
top_p=top_p,
user=user,
additional_properties=additional_properties or {},
),
**kwargs,
):
response_updates.append(update)
if update.author_name is None:
update.author_name = agent_name
yield AgentRunResponseUpdate(
contents=update.contents,
role=update.role,
author_name=update.author_name,
response_id=update.response_id,
message_id=update.message_id,
created_at=update.created_at,
additional_properties=update.additional_properties,
raw_representation=update,
)
response = ChatResponse.from_chat_response_updates(response_updates)
await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
@override
def get_new_thread(
self,
*,
service_thread_id: str | None = None,
**kwargs: Any,
) -> AgentThread:
"""Get a new conversation thread for the agent.
If you supply a service_thread_id, the thread will be marked as service managed.
If you don't supply a service_thread_id but have a chat_message_store_factory configured on the agent,
that factory will be used to create a message store for the thread and the thread will be
managed locally.
When neither is present, the thread will be created without a service ID or message store,
this will be updated based on usage, when you run the agent with this thread.
If you run with store=True, the response will respond with a thread_id and that will be set.
Otherwise a messages store is created from the default factory.
Args:
service_thread_id: Optional service managed thread ID.
kwargs: not used at present.
"""
if service_thread_id is not None:
return AgentThread(
service_thread_id=service_thread_id,
context_provider=self.context_provider,
)
if self.chat_message_store_factory is not None:
return AgentThread(
message_store=self.chat_message_store_factory(),
context_provider=self.context_provider,
)
return AgentThread(context_provider=self.context_provider)
async def _update_thread_with_type_and_conversation_id(
self, thread: AgentThread, response_conversation_id: str | None
) -> None:
"""Update thread with storage type and conversation ID.
Args:
thread: The thread to update.
response_conversation_id: The conversation ID from the response, if any.
Raises:
AgentExecutionException: If conversation ID is missing for service-managed thread.
"""
if response_conversation_id is None and thread.service_thread_id is not None:
# We were passed a thread that is service managed, but we got no conversation id back from the chat client,
# meaning the service doesn't support service managed threads,
# so the thread cannot be used with this service.
raise AgentExecutionException(
"Service did not return a valid conversation id when using a service managed thread."
)
if response_conversation_id is not None:
# If we got a conversation id back from the chat client, it means that the service
# supports server side thread storage so we should update the thread with the new id.
thread.service_thread_id = response_conversation_id
if thread.context_provider:
await thread.context_provider.thread_created(thread.service_thread_id)
elif thread.message_store is None and self.chat_message_store_factory is not None:
# If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and
# the thread has no message_store yet, and we have a custom messages store, we should update the thread
# with the custom message_store so that it has somewhere to store the chat history.
thread.message_store = self.chat_message_store_factory()
async def _prepare_thread_and_messages(
self,
*,
thread: AgentThread | None,
input_messages: list[ChatMessage] | None = None,
) -> tuple[AgentThread, ChatOptions, list[ChatMessage]]:
"""Prepare the messages for agent execution.
Also updates the chat_options of the agent, with
Args:
thread: The conversation thread.
input_messages: Messages to process.
Returns:
The validated thread and normalized messages.
Raises:
AgentExecutionException: If the thread is not of the expected type.
"""
chat_options = copy(self.chat_options) if self.chat_options else ChatOptions()
thread = thread or self.get_new_thread()
if thread.service_thread_id and thread.context_provider:
await thread.context_provider.thread_created(thread.service_thread_id)
thread_messages: list[ChatMessage] = []
if thread.message_store:
thread_messages.extend(await thread.message_store.list_messages() or [])
context: Context | None = None
if self.context_provider:
async with self.context_provider:
context = await self.context_provider.invoking(input_messages or [])
if context:
if context.messages:
thread_messages.extend(context.messages)
if context.tools:
if chat_options.tools is not None:
chat_options.tools.extend(context.tools)
else:
chat_options.tools = list(context.tools)
if context.instructions:
chat_options.instructions = (
context.instructions
if not chat_options.instructions
else f"{chat_options.instructions}\n{context.instructions}"
)
thread_messages.extend(input_messages or [])
if (
thread.service_thread_id
and chat_options.conversation_id
and thread.service_thread_id != chat_options.conversation_id
):
raise AgentExecutionException(
"The conversation_id set on the agent is different from the one set on the thread, "
"only one ID can be used for a run."
)
return thread, chat_options, thread_messages
def _get_agent_name(self) -> str:
return self.name or "UnnamedAgent"
@@ -0,0 +1,528 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, Callable, MutableMapping, MutableSequence, Sequence
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel, Field
from ._logging import get_logger
from ._mcp import MCPTool
from ._memory import AggregateContextProvider, ContextProvider
from ._middleware import (
ChatMiddleware,
ChatMiddlewareCallable,
FunctionMiddleware,
FunctionMiddlewareCallable,
Middleware,
)
from ._pydantic import AFBaseModel
from ._threads import ChatMessageStoreProtocol
from ._tools import ToolProtocol
from ._types import (
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
ToolMode,
)
if TYPE_CHECKING:
from ._agents import ChatAgent
TInput = TypeVar("TInput", contravariant=True)
TEmbedding = TypeVar("TEmbedding")
TBaseChatClient = TypeVar("TBaseChatClient", bound="BaseChatClient")
logger = get_logger()
__all__ = [
"BaseChatClient",
"ChatClientProtocol",
]
# region ChatClientProtocol Protocol
@runtime_checkable
class ChatClientProtocol(Protocol):
"""A protocol for a chat client that can generate responses."""
@property
def additional_properties(self) -> dict[str, Any]:
"""Get additional properties associated with the client."""
...
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> ChatResponse:
"""Sends input and returns the response.
Args:
messages: The sequence of input messages to send.
response_format: the format of the response.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
Returns:
The response messages generated by the client.
Raises:
ValueError: If the input message sequence is `None`.
"""
...
def get_streaming_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Sends input messages and streams the response.
Args:
messages: The sequence of input messages to send.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
Yields:
An async iterable of chat response updates containing the content of the response messages
generated by the client.
Raises:
ValueError: If the input message sequence is `None`.
"""
...
# region ChatClientBase
def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]) -> list[ChatMessage]:
"""Turn the allowed input into a list of chat messages."""
if isinstance(messages, str):
return [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
return [messages]
return_messages: list[ChatMessage] = []
for msg in messages:
if isinstance(msg, str):
msg = ChatMessage(role="user", text=msg)
return_messages.append(msg)
return return_messages
class BaseChatClient(AFBaseModel, ABC):
"""Base class for chat clients."""
additional_properties: dict[str, Any] = Field(default_factory=dict)
middleware: (
ChatMiddleware
| ChatMiddlewareCallable
| FunctionMiddleware
| FunctionMiddlewareCallable
| list[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable]
| None
) = None
OTEL_PROVIDER_NAME: str = "unknown"
# This is used for OTel setup, should be overridden in subclasses
def prepare_messages(
self, messages: str | ChatMessage | list[str] | list[ChatMessage], chat_options: ChatOptions
) -> MutableSequence[ChatMessage]:
"""Turn the allowed input into a list of chat messages."""
if chat_options.instructions:
system_msg = ChatMessage(role="system", text=chat_options.instructions)
return [system_msg, *prepare_messages(messages)]
return prepare_messages(messages)
def _filter_internal_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Filter out internal framework parameters that shouldn't be passed to chat client implementations.
Args:
kwargs: The original kwargs dictionary.
Returns:
A filtered kwargs dictionary without internal parameters.
"""
return {k: v for k, v in kwargs.items() if not k.startswith("_")}
@staticmethod
def _normalize_tools(
tools: ToolProtocol
| MutableMapping[str, Any]
| Callable[..., Any]
| list[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]]
| None = None,
) -> list[ToolProtocol | dict[str, Any] | Callable[..., Any]]:
"""Normalize the tools input to a list of tools."""
final_tools: list[ToolProtocol | dict[str, Any] | Callable[..., Any]] = []
if not tools:
return final_tools
for tool in tools if isinstance(tools, list) else [tools]: # type: ignore[reportUnknownType]
if isinstance(tool, MCPTool):
final_tools.extend(tool.functions) # type: ignore
continue
final_tools.append(tool) # type: ignore
return final_tools
# region Internal methods to be implemented by the derived classes
@abstractmethod
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
"""Send a chat request to the AI service.
Args:
messages: The chat messages to send.
chat_options: The options for the request.
kwargs: Any additional keyword arguments.
Returns:
The chat response contents representing the response(s).
"""
@abstractmethod
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Send a streaming chat request to the AI service.
Args:
messages: The chat messages to send.
chat_options: The chat_options for the request.
kwargs: Any additional keyword arguments.
Yields:
ChatResponseUpdate: The streaming chat message contents.
"""
# Below is needed for mypy: https://mypy.readthedocs.io/en/stable/more_types.html#asynchronous-iterators
if False:
yield
await asyncio.sleep(0) # pragma: no cover
# This is a no-op, but it allows the method to be async and return an AsyncIterable.
# The actual implementation should yield ChatResponseUpdate instances as needed.
# endregion
# region Public method
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> ChatResponse:
"""Get a response from a chat client.
Args:
messages: the message or messages to send to the model
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request.
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
Returns:
A chat response from the model.
"""
# Should we merge chat options instead of ignoring the input params?
if "chat_options" in kwargs:
chat_options = kwargs.pop("chat_options")
if not isinstance(chat_options, ChatOptions):
raise TypeError("chat_options must be an instance of ChatOptions")
else:
chat_options = ChatOptions(
model_id=model,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=self._normalize_tools(tools), # type: ignore
user=user,
additional_properties=additional_properties or {},
)
prepped_messages = self.prepare_messages(messages, chat_options)
self._prepare_tool_choice(chat_options=chat_options)
filtered_kwargs = self._filter_internal_kwargs(kwargs)
return await self._inner_get_response(messages=prepped_messages, chat_options=chat_options, **filtered_kwargs)
async def get_streaming_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Get a streaming response from a chat client.
Args:
messages: the message or messages to send to the model
frequency_penalty: the frequency penalty to use
logit_bias: the logit bias to use
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request
kwargs: any additional keyword arguments
Yields:
A stream representing the response(s) from the LLM.
"""
# Should we merge chat options instead of ignoring the input params?
if "chat_options" in kwargs:
chat_options = kwargs.pop("chat_options")
if not isinstance(chat_options, ChatOptions):
raise TypeError("chat_options must be an instance of ChatOptions")
else:
chat_options = ChatOptions(
model_id=model,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=self._normalize_tools(tools),
user=user,
additional_properties=additional_properties or {},
)
prepped_messages = self.prepare_messages(messages, chat_options)
self._prepare_tool_choice(chat_options=chat_options)
filtered_kwargs = self._filter_internal_kwargs(kwargs)
async for update in self._inner_get_streaming_response(
messages=prepped_messages, chat_options=chat_options, **filtered_kwargs
):
yield update
def _prepare_tool_choice(self, chat_options: ChatOptions) -> None:
"""Prepare the tools and tool choice for the chat options.
This function should be overridden by subclasses to customize tool handling.
Because it currently parses only AIFunctions.
"""
chat_tool_mode = chat_options.tool_choice
if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none":
chat_options.tools = None
chat_options.tool_choice = ToolMode.NONE.mode
return
if not chat_options.tools:
chat_options.tool_choice = ToolMode.NONE.mode
else:
chat_options.tool_choice = chat_tool_mode.mode if isinstance(chat_tool_mode, ToolMode) else chat_tool_mode
def service_url(self) -> str:
"""Get the URL of the service.
Override this in the subclass to return the proper URL.
If the service does not have a URL, return None.
"""
return "Unknown"
def create_agent(
self,
*,
name: str | None = None,
instructions: str | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
middleware: Middleware | list[Middleware] | None = None,
**kwargs: Any,
) -> "ChatAgent":
"""Create an agent with the given name and instructions.
Args:
name: The name of the agent.
instructions: The instructions for the agent.
tools: Optional list of tools to associate with the agent.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
If not provided, the default in-memory store will be used.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
**kwargs: Additional keyword arguments to pass to the agent.
See ChatAgent for all the available options.
Returns:
An instance of ChatAgent.
"""
from ._agents import ChatAgent
return ChatAgent(
chat_client=self,
name=name,
instructions=instructions,
tools=tools,
chat_message_store_factory=chat_message_store_factory,
context_providers=context_providers,
middleware=middleware,
**kwargs,
)
@@ -0,0 +1,26 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from .exceptions import AgentFrameworkException
logging.basicConfig(
format="[%(asctime)s - %(pathname)s:%(lineno)d - %(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
__all__ = ["get_logger"]
def get_logger(name: str = "agent_framework") -> logging.Logger:
"""Get a logger with the specified name, defaulting to 'agent_framework'.
Args:
name (str): The name of the logger. Defaults to 'agent_framework'.
Returns:
logging.Logger: The configured logger instance.
"""
if not name.startswith("agent_framework"):
raise AgentFrameworkException("Logger name must start with 'agent_framework'.")
return logging.getLogger(name)
@@ -0,0 +1,709 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
import re
import sys
from abc import abstractmethod
from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ignore
from datetime import timedelta
from functools import partial
from typing import TYPE_CHECKING, Any
from mcp import types
from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.websocket import websocket_client
from mcp.shared.context import RequestContext
from mcp.shared.exceptions import McpError
from mcp.shared.session import RequestResponder
from pydantic import BaseModel, create_model
from ._tools import AIFunction
from ._types import ChatMessage, Contents, DataContent, Role, TextContent, UriContent
from .exceptions import ToolException, ToolExecutionException
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
if TYPE_CHECKING:
from ._clients import ChatClientProtocol
logger = logging.getLogger(__name__)
# region: Helpers
LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = {
"debug": logging.DEBUG,
"info": logging.INFO,
"notice": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
"alert": logging.CRITICAL,
"emergency": logging.CRITICAL,
}
__all__ = [
"MCPStdioTool",
"MCPStreamableHTTPTool",
"MCPWebsocketTool",
]
def _mcp_prompt_message_to_chat_message(
mcp_type: types.PromptMessage | types.SamplingMessage,
) -> ChatMessage:
"""Convert a MCP container type to a Agent Framework type."""
return ChatMessage(
role=Role(value=mcp_type.role),
contents=[_mcp_type_to_ai_content(mcp_type.content)], # type: ignore[call-arg]
raw_representation=mcp_type,
)
def _mcp_call_tool_result_to_ai_contents(
mcp_type: types.CallToolResult,
) -> list[Contents]:
"""Convert a MCP container type to a Agent Framework type."""
return [_mcp_type_to_ai_content(item) for item in mcp_type.content]
def _mcp_type_to_ai_content(
mcp_type: types.ImageContent | types.TextContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink,
) -> Contents:
"""Convert a MCP type to a Agent Framework type."""
match mcp_type:
case types.TextContent():
return TextContent(text=mcp_type.text, raw_representation=mcp_type)
case types.ImageContent() | types.AudioContent():
return DataContent(uri=mcp_type.data, media_type=mcp_type.mimeType, raw_representation=mcp_type)
case types.ResourceLink():
return UriContent(
uri=str(mcp_type.uri), media_type=mcp_type.mimeType or "application/json", raw_representation=mcp_type
)
case _:
match mcp_type.resource:
case types.TextResourceContents():
return TextContent(
text=mcp_type.resource.text,
raw_representation=mcp_type,
additional_properties=mcp_type.annotations.model_dump() if mcp_type.annotations else None,
)
case types.BlobResourceContents():
return DataContent(
uri=mcp_type.resource.blob,
media_type=mcp_type.resource.mimeType,
raw_representation=mcp_type,
additional_properties=mcp_type.annotations.model_dump() if mcp_type.annotations else None,
)
def _ai_content_to_mcp_types(
content: Contents,
) -> types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None:
"""Convert a BaseContent type to a MCP type."""
match content:
case TextContent():
return types.TextContent(type="text", text=content.text)
case DataContent():
if content.media_type and content.media_type.startswith("image/"):
return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type)
if content.media_type and content.media_type.startswith("audio/"):
return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type)
if content.media_type and content.media_type.startswith("application/"):
return types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(
blob=content.uri,
mimeType=content.media_type,
# uri's are not limited in MCP but they have to be set.
# the uri of data content, contains the data uri, which
# is not the uri meant here, UriContent would match this.
uri=content.additional_properties.get("uri", "af://binary")
if content.additional_properties
else "af://binary", # type: ignore[reportArgumentType]
),
)
return None
case UriContent():
return types.ResourceLink(
type="resource_link",
uri=content.uri, # type: ignore[reportArgumentType]
mimeType=content.media_type,
name=content.additional_properties.get("name", "Unknown")
if content.additional_properties
else "Unknown",
)
case _:
return None
def _chat_message_to_mcp_types(
content: ChatMessage,
) -> list[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink]:
"""Convert a ChatMessage to a list of MCP types."""
messages: list[
types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink
] = []
for item in content.contents:
mcp_content = _ai_content_to_mcp_types(item)
if mcp_content:
messages.append(mcp_content)
return messages
def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> type[BaseModel]:
"""Creates a Pydantic model from a prompt's parameters."""
# Check if 'arguments' is missing or empty
if not prompt.arguments:
return create_model(f"{prompt.name}_input")
field_definitions: dict[str, Any] = {}
for prompt_argument in prompt.arguments:
# For prompts, all arguments are typically required and string type
# unless specified otherwise in the prompt argument
python_type = str # Default type for prompt arguments
# Create field definition for create_model
if prompt_argument.required:
field_definitions[prompt_argument.name] = (python_type, ...)
else:
field_definitions[prompt_argument.name] = (python_type, None)
return create_model(f"{prompt.name}_input", **field_definitions)
def _get_input_model_from_mcp_tool(tool: types.Tool) -> type[BaseModel]:
"""Creates a Pydantic model from a tools parameters."""
properties = tool.inputSchema.get("properties", None)
required = tool.inputSchema.get("required", [])
# Check if 'properties' is missing or not a dictionary
if not properties:
return create_model(f"{tool.name}_input")
field_definitions: dict[str, Any] = {}
for prop_name, prop_details in properties.items():
prop_details = json.loads(prop_details) if isinstance(prop_details, str) else prop_details
# Map JSON Schema types to Python types
json_type = prop_details.get("type", "string")
python_type: type = str # default
if json_type == "integer":
python_type = int
elif json_type == "number":
python_type = float
elif json_type == "boolean":
python_type = bool
elif json_type == "array":
python_type = list
elif json_type == "object":
python_type = dict
# Create field definition for create_model
if prop_name in required:
field_definitions[prop_name] = (python_type, ...)
else:
default_value = prop_details.get("default", None)
field_definitions[prop_name] = (python_type, default_value)
return create_model(f"{tool.name}_input", **field_definitions)
def _normalize_mcp_name(name: str) -> str:
"""Normalize MCP tool/prompt names to allowed identifier pattern (A-Za-z0-9_.-)."""
return re.sub(r"[^A-Za-z0-9_.-]", "-", name)
# region: MCP Plugin
class MCPTool:
"""Main MCP class, to initialize use one of the subclasses."""
def __init__(
self,
name: str,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
load_tools: bool = True,
load_prompts: bool = True,
session: ClientSession | None = None,
request_timeout: int | None = None,
chat_client: "ChatClientProtocol | None" = None,
) -> None:
"""Initialize the MCP Plugin Base."""
self.name = name
self.description = description or ""
self.additional_properties = additional_properties
self.load_tools_flag = load_tools
self.load_prompts_flag = load_prompts
self._exit_stack = AsyncExitStack()
self.session = session
self.request_timeout = request_timeout
self.chat_client = chat_client
self.functions: list[AIFunction[Any, Any]] = []
self.is_connected: bool = False
def __str__(self) -> str:
return f"MCPTool(name={self.name}, description={self.description})"
async def connect(self) -> None:
"""Connect to the MCP server."""
if not self.session:
try:
transport = await self._exit_stack.enter_async_context(self.get_mcp_client())
except Exception as ex:
await self._exit_stack.aclose()
raise ToolException(
"Failed to connect to the MCP server. Please check your configuration.", inner_exception=ex
) from ex
try:
session = await self._exit_stack.enter_async_context(
ClientSession(
read_stream=transport[0],
write_stream=transport[1],
read_timeout_seconds=timedelta(seconds=self.request_timeout) if self.request_timeout else None,
message_handler=self.message_handler,
logging_callback=self.logging_callback,
sampling_callback=self.sampling_callback,
)
)
except Exception as ex:
await self._exit_stack.aclose()
raise ToolException(
message="Failed to create a session. Please check your configuration.", inner_exception=ex
) from ex
await session.initialize()
self.session = session
elif self.session._request_id == 0: # type: ignore[reportPrivateUsage]
# If the session is not initialized, we need to reinitialize it
await self.session.initialize()
logger.debug("Connected to MCP server: %s", self.session)
self.is_connected = True
if self.load_tools_flag:
await self.load_tools()
if self.load_prompts_flag:
await self.load_prompts()
if logger.level != logging.NOTSET:
try:
await self.session.set_logging_level(
next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
)
except Exception as exc:
logger.warning("Failed to set log level to %s", logger.level, exc_info=exc)
async def sampling_callback(
self, context: RequestContext[ClientSession, Any], params: types.CreateMessageRequestParams
) -> types.CreateMessageResult | types.ErrorData:
"""Callback function for sampling.
This function is called when the MCP server needs to get a message completed.
This is a simple version of this function, it can be overridden to allow more complex sampling.
It get's added to the session at initialization time, so overriding it is the best way to do this.
"""
if not self.chat_client:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="No chat client available. Please set a chat client.",
)
logger.debug("Sampling callback called with params: %s", params)
messages: list[ChatMessage] = []
for msg in params.messages:
messages.append(_mcp_prompt_message_to_chat_message(msg))
try:
response = await self.chat_client.get_response(
messages,
temperature=params.temperature,
max_tokens=params.maxTokens,
stop=params.stopSequences,
)
except Exception as ex:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message=f"Failed to get chat message content: {ex}",
)
if not response or not response.messages:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="Failed to get chat message content.",
)
mcp_contents = _chat_message_to_mcp_types(response.messages[0])
# grab the first content that is of type TextContent or ImageContent
mcp_content = next(
(content for content in mcp_contents if isinstance(content, (types.TextContent, types.ImageContent))),
None,
)
if not mcp_content:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="Failed to get right content types from the response.",
)
return types.CreateMessageResult(
role="assistant",
content=mcp_content,
model=response.model_id or "unknown",
)
async def logging_callback(self, params: types.LoggingMessageNotificationParams) -> None:
"""Callback function for logging.
This function is called when the MCP Server sends a log message.
By default it will log the message to the logger with the level set in the params.
Please subclass the MCP*Plugin and override this function if you want to adapt the behavior.
"""
logger.log(LOG_LEVEL_MAPPING[params.level], params.data)
async def message_handler(
self,
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
"""Handle messages from the MCP server.
By default this function will handle exceptions on the server, by logging those.
And it will trigger a reload of the tools and prompts when the list changed notification is received.
If you want to extend this behavior you can subclass the MCPPlugin and override this function,
if you want to keep the default behavior, make sure to call `super().message_handler(message)`.
"""
if isinstance(message, Exception):
logger.error("Error from MCP server: %s", message, exc_info=message)
return
if isinstance(message, types.ServerNotification):
match message.root.method:
case "notifications/tools/list_changed":
await self.load_tools()
case "notifications/prompts/list_changed":
await self.load_prompts()
case _:
logger.debug("Unhandled notification: %s", message.root.method)
async def load_prompts(self) -> None:
"""Load prompts from the MCP server."""
if not self.session:
raise ToolExecutionException("MCP server not connected, please call connect() before using this method.")
try:
prompt_list = await self.session.list_prompts()
except Exception as exc:
logger.info(
"Prompt could not be loaded, you can exclude trying to load, by setting: load_prompts=False",
exc_info=exc,
)
prompt_list = None
for prompt in prompt_list.prompts if prompt_list else []:
local_name = _normalize_mcp_name(prompt.name)
input_model = _get_input_model_from_mcp_prompt(prompt)
func: AIFunction[BaseModel, list[ChatMessage]] = AIFunction(
func=partial(self.get_prompt, prompt.name),
name=local_name,
description=prompt.description or "",
input_model=input_model,
)
self.functions.append(func)
async def load_tools(self) -> None:
"""Load tools from the MCP server."""
if not self.session:
raise ToolExecutionException("MCP server not connected, please call connect() before using this method.")
try:
tool_list = await self.session.list_tools()
except Exception as exc:
logger.info(
"Tools could not be loaded, you can exclude trying to load, by setting: load_tools=False",
exc_info=exc,
)
tool_list = None
for tool in tool_list.tools if tool_list else []:
local_name = _normalize_mcp_name(tool.name)
input_model = _get_input_model_from_mcp_tool(tool)
# Create AIFunctions out of each tool
func: AIFunction[BaseModel, list[Contents]] = AIFunction(
func=partial(self.call_tool, tool.name),
name=local_name,
description=tool.description or "",
input_model=input_model,
)
self.functions.append(func)
async def close(self) -> None:
"""Disconnect from the MCP server."""
await self._exit_stack.aclose()
self.session = None
self.is_connected = False
@abstractmethod
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP client."""
pass
async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Contents]:
"""Call a tool with the given arguments."""
if not self.session:
raise ToolExecutionException("MCP server not connected, please call connect() before using this method.")
if not self.load_tools_flag:
raise ToolExecutionException(
"Tools are not loaded for this server, please set load_tools=True in the constructor."
)
try:
return _mcp_call_tool_result_to_ai_contents(await self.session.call_tool(tool_name, arguments=kwargs))
except McpError as mcp_exc:
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
except Exception as ex:
raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex
async def get_prompt(self, prompt_name: str, **kwargs: Any) -> list[ChatMessage]:
"""Call a prompt with the given arguments."""
if not self.session:
raise ToolExecutionException("MCP server not connected, please call connect() before using this method.")
if not self.load_prompts_flag:
raise ToolExecutionException(
"Prompts are not loaded for this server, please set load_prompts=True in the constructor."
)
try:
prompt_result = await self.session.get_prompt(prompt_name, arguments=kwargs)
return [_mcp_prompt_message_to_chat_message(message) for message in prompt_result.messages]
except McpError as mcp_exc:
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
except Exception as ex:
raise ToolExecutionException(f"Failed to call prompt '{prompt_name}'.", inner_exception=ex) from ex
async def __aenter__(self) -> Self:
"""Enter the context manager."""
try:
await self.connect()
return self
except ToolException:
raise
except Exception as ex:
await self._exit_stack.aclose()
raise ToolExecutionException("Failed to enter context manager.", inner_exception=ex) from ex
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any
) -> None:
"""Exit the context manager."""
await self.close()
# region: MCP Plugin Implementations
class MCPStdioTool(MCPTool):
"""MCP stdio server configuration."""
def __init__(
self,
name: str,
command: str,
*,
load_tools: bool = True,
load_prompts: bool = True,
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
args: list[str] | None = None,
env: dict[str, str] | None = None,
encoding: str | None = None,
chat_client: "ChatClientProtocol | None" = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP stdio plugin.
The arguments are used to create a StdioServerParameters object.
Which is then used to create a stdio client.
see mcp.client.stdio.stdio_client and mcp.client.stdio.stdio_server_parameters
for more details.
Args:
name: The name of the plugin.
command: The command to run the MCP server.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
session: The session to use for the MCP connection.
description: The description of the plugin.
additional_properties: Additional properties.
args: The arguments to pass to the command.
env: The environment variables to set for the command.
encoding: The encoding to use for the command output.
chat_client: The chat client to use for sampling.
kwargs: Any extra arguments to pass to the stdio client.
"""
super().__init__(
name=name,
description=description,
additional_properties=additional_properties,
session=session,
chat_client=chat_client,
load_tools=load_tools,
load_prompts=load_prompts,
request_timeout=request_timeout,
)
self.command = command
self.args = args or []
self.env = env
self.encoding = encoding
self._client_kwargs = kwargs
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP stdio client."""
args: dict[str, Any] = {
"command": self.command,
"args": self.args,
"env": self.env,
}
if self.encoding:
args["encoding"] = self.encoding
if self._client_kwargs:
args.update(self._client_kwargs)
return stdio_client(server=StdioServerParameters(**args))
class MCPStreamableHTTPTool(MCPTool):
"""MCP streamable http server configuration."""
def __init__(
self,
name: str,
url: str,
*,
load_tools: bool = True,
load_prompts: bool = True,
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
terminate_on_close: bool | None = None,
chat_client: "ChatClientProtocol | None" = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP streamable http plugin.
The arguments are used to create a streamable http client.
see mcp.client.streamable_http.streamablehttp_client for more details.
Any extra arguments passed to the constructor will be passed to the
streamable http client constructor.
Args:
name: The name of the plugin.
url: The URL of the MCP server.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
session: The session to use for the MCP connection.
description: The description of the plugin.
additional_properties: Additional properties.
headers: The headers to send with the request.
timeout: The timeout for the request.
sse_read_timeout: The timeout for reading from the SSE stream.
terminate_on_close: Close the transport when the MCP client is terminated.
chat_client: The chat client to use for sampling.
kwargs: Any extra arguments to pass to the sse client.
"""
super().__init__(
name=name,
description=description,
additional_properties=additional_properties,
session=session,
chat_client=chat_client,
load_tools=load_tools,
load_prompts=load_prompts,
request_timeout=request_timeout,
)
self.url = url
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self.terminate_on_close = terminate_on_close
self._client_kwargs = kwargs
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP streamable http client."""
args: dict[str, Any] = {
"url": self.url,
}
if self.headers:
args["headers"] = self.headers
if self.timeout is not None:
args["timeout"] = self.timeout
if self.sse_read_timeout is not None:
args["sse_read_timeout"] = self.sse_read_timeout
if self.terminate_on_close is not None:
args["terminate_on_close"] = self.terminate_on_close
if self._client_kwargs:
args.update(self._client_kwargs)
return streamablehttp_client(**args)
class MCPWebsocketTool(MCPTool):
"""MCP websocket server configuration."""
def __init__(
self,
name: str,
url: str,
*,
load_tools: bool = True,
load_prompts: bool = True,
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
chat_client: "ChatClientProtocol | None" = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP websocket plugin.
The arguments are used to create a websocket client.
see mcp.client.websocket.websocket_client for more details.
Any extra arguments passed to the constructor will be passed to the
websocket client constructor.
Args:
name: The name of the plugin.
url: The URL of the MCP server.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
session: The session to use for the MCP connection.
description: The description of the plugin.
additional_properties: Additional properties.
chat_client: The chat client to use for sampling.
kwargs: Any extra arguments to pass to the websocket client.
"""
super().__init__(
name=name,
description=description,
additional_properties=additional_properties,
session=session,
chat_client=chat_client,
load_tools=load_tools,
load_prompts=load_prompts,
request_timeout=request_timeout,
)
self.url = url
self._client_kwargs = kwargs
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP websocket client."""
args: dict[str, Any] = {
"url": self.url,
}
if self._client_kwargs:
args.update(self._client_kwargs)
return websocket_client(**args)
@@ -0,0 +1,241 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import sys
from abc import ABC, abstractmethod
from collections.abc import MutableSequence, Sequence
from contextlib import AsyncExitStack
from types import TracebackType
from typing import Any, Final, cast
from ._tools import ToolProtocol
from ._types import ChatMessage
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
# region Context
__all__ = ["AggregateContextProvider", "Context", "ContextProvider"]
class Context:
"""A class containing any context that should be provided to the AI model as supplied by an ContextProvider.
Each ContextProvider has the ability to provide its own context for each invocation.
The Context class contains the additional context supplied by the ContextProvider.
This context will be combined with context supplied by other providers before being passed to the AI model.
This context is per invocation, and will not be stored as part of the chat history.
"""
def __init__(
self,
instructions: str | None = None,
messages: Sequence[ChatMessage] | None = None,
tools: Sequence[ToolProtocol] | None = None,
):
"""Create a new Context object.
Args:
instructions: Instructions to provide to the AI model.
messages: a list of messages.
tools: a list of tools to provide to this run.
"""
self.instructions = instructions
self.messages: Sequence[ChatMessage] = messages or []
self.tools: Sequence[ToolProtocol] = tools or []
# region ContextProvider
class ContextProvider(ABC):
"""Base class for all context providers.
A context provider is a component that can be used to enhance the AI's context management.
It can listen to changes in the conversation and provide additional context to the AI model
just before invocation.
It also has a default memory prompt that can be used by all providers.
"""
# Default prompt to be used by all context providers when assembling memories/instructions
DEFAULT_CONTEXT_PROMPT: Final[str] = "## Memories\nConsider the following memories when answering user questions:"
async def thread_created(self, thread_id: str | None) -> None:
"""Called just after a new thread is created.
Implementers can use this method to do any operations required at the creation of a new thread.
For example, checking long term storage for any data that is relevant
to the current session based on the input text.
Args:
thread_id: The ID of the new thread.
"""
pass
async def invoked(
self,
request_messages: ChatMessage | Sequence[ChatMessage],
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
invoke_exception: Exception | None = None,
**kwargs: Any,
) -> None:
"""Called after the agent has received a response from the underlying inference service.
You can inspect the request and response messages, and update the state of the context provider
Args:
request_messages: messages that were sent to the model/agent
response_messages: messages that were returned by the model/agent
invoke_exception: exception that was thrown, if any.
kwargs: not used at present.
"""
pass
@abstractmethod
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
"""Called just before the Model/Agent/etc. is invoked.
Implementers can load any additional context required at this time,
and they should return any context that should be passed to the agent.
Args:
messages: The most recent messages that the agent is being invoked with.
kwargs: not used at present.
"""
pass
async def __aenter__(self) -> "Self":
"""Async context manager entry.
Override this method to perform any setup operations when the context provider is entered.
Returns:
Self for chaining.
"""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Async context manager exit.
Override this method to perform any cleanup operations when the context provider is exited.
Args:
exc_type: Exception type if an exception occurred, None otherwise.
exc_val: Exception value if an exception occurred, None otherwise.
exc_tb: Exception traceback if an exception occurred, None otherwise.
"""
pass
# region AggregateContextProvider
class AggregateContextProvider(ContextProvider):
"""A ContextProvider that contains multiple context providers.
It delegates events to multiple context providers and aggregates responses from those events before returning.
"""
def __init__(self, context_providers: ContextProvider | Sequence[ContextProvider] | None = None) -> None:
"""Initialize the AggregateContextProvider with context providers.
Args:
context_providers: Context providers to add.
"""
if isinstance(context_providers, ContextProvider):
self.providers = [context_providers]
else:
self.providers = cast(list[ContextProvider], context_providers) or []
self._exit_stack: AsyncExitStack | None = None
def add(self, context_provider: ContextProvider) -> None:
"""Adds new context provider.
Args:
context_provider: Context provider to add.
"""
self.providers.append(context_provider)
@override
async def thread_created(self, thread_id: str | None = None) -> None:
await asyncio.gather(*[x.thread_created(thread_id) for x in self.providers])
@override
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
contexts = await asyncio.gather(*[provider.invoking(messages, **kwargs) for provider in self.providers])
instructions: str = ""
return_messages: list[ChatMessage] = []
tools: list[ToolProtocol] = []
for ctx in contexts:
if ctx.instructions:
instructions += ctx.instructions
if ctx.messages:
return_messages.extend(ctx.messages)
if ctx.tools:
tools.extend(ctx.tools)
return Context(instructions=instructions, messages=return_messages, tools=tools)
@override
async def invoked(
self,
request_messages: ChatMessage | Sequence[ChatMessage],
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
invoke_exception: Exception | None = None,
**kwargs: Any,
) -> None:
await asyncio.gather(*[
x.invoked(
request_messages=request_messages,
response_messages=response_messages,
invoke_exception=invoke_exception,
**kwargs,
)
for x in self.providers
])
@override
async def __aenter__(self) -> "Self":
"""Enter async context manager and set up all providers.
Returns:
Self for chaining.
"""
self._exit_stack = AsyncExitStack()
await self._exit_stack.__aenter__()
# Enter all context providers
for provider in self.providers:
await self._exit_stack.enter_async_context(provider)
return self
@override
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Exit async context manager and clean up all providers.
Args:
exc_type: Exception type if an exception occurred, None otherwise.
exc_val: Exception value if an exception occurred, None otherwise.
exc_tb: Exception traceback if an exception occurred, None otherwise.
"""
if self._exit_stack is not None:
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
self._exit_stack = None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Annotated, Any, ClassVar, TypeVar
from pydantic import BaseModel, ConfigDict, Field, UrlConstraints
from pydantic.networks import AnyUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
HTTPsUrl = Annotated[AnyUrl, UrlConstraints(max_length=2083, allowed_schemes=["https"])]
__all__ = ["AFBaseModel", "AFBaseSettings", "HTTPsUrl"]
class AFBaseModel(BaseModel):
"""Base class for all pydantic models in the Agent Framework."""
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, validate_assignment=True)
TSettings = TypeVar("TSettings", bound="AFBaseSettings")
class AFBaseSettings(BaseSettings):
"""Base class for all settings classes in the Agent Framework.
A subclass creates it's fields and overrides the env_prefix class variable
with the prefix for the environment variables.
In the case where a value is specified for the same Settings field in multiple ways,
the selected value is determined as follows (in descending order of priority):
- Arguments passed to the Settings class initializer.
- Environment variables, e.g. my_prefix_special_function as described above.
- Variables loaded from a dotenv (.env) file.
- Variables loaded from the secrets directory.
- The default field values for the Settings model.
"""
env_prefix: ClassVar[str] = ""
env_file_path: str | None = Field(default=None, exclude=True)
env_file_encoding: str | None = Field(default="utf-8", exclude=True)
model_config = SettingsConfigDict(
extra="ignore",
case_sensitive=False,
)
def __init__(
self,
**kwargs: Any,
) -> None:
"""Initialize the settings class."""
# Remove any None values from the kwargs so that defaults are used.
kwargs = {k: v for k, v in kwargs.items() if v is not None}
super().__init__(**kwargs)
def __new__(cls: type["TSettings"], *args: Any, **kwargs: Any) -> "TSettings":
"""Override the __new__ method to set the env_prefix."""
# for both, if supplied but None, set to default
if "env_file_encoding" in kwargs and kwargs["env_file_encoding"] is not None:
env_file_encoding = kwargs["env_file_encoding"]
else:
env_file_encoding = "utf-8"
if "env_file_path" in kwargs and kwargs["env_file_path"] is not None:
env_file_path = kwargs["env_file_path"]
else:
env_file_path = ".env"
cls.model_config.update( # type: ignore
env_prefix=cls.env_prefix,
env_file=env_file_path,
env_file_encoding=env_file_encoding,
)
cls.model_rebuild()
return super().__new__(cls) # type: ignore[return-value]
@@ -0,0 +1,59 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Any, Final
from . import __version__ as version_info
from ._logging import get_logger
logger = get_logger()
__all__ = [
"AGENT_FRAMEWORK_USER_AGENT",
"APP_INFO",
"USER_AGENT_KEY",
"USER_AGENT_TELEMETRY_DISABLED_ENV_VAR",
"prepend_agent_framework_to_user_agent",
]
# Note that if this environment variable does not exist, user agent telemetry is enabled.
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_USER_AGENT_DISABLED"
IS_TELEMETRY_ENABLED = os.environ.get(USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"]
APP_INFO = (
{
"agent-framework-version": f"python/{version_info}", # type: ignore[has-type]
}
if IS_TELEMETRY_ENABLED
else None
)
USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
"""Prepend "agent-framework" to the User-Agent in the headers.
When user agent telemetry is disabled, through the AZURE_TELEMETRY_DISABLED environment variable,
the User-Agent header will not include the agent-framework information, it will be sent back as is,
or as a empty dict when None is passed.
Args:
headers: The existing headers dictionary.
Returns:
A new dict with "User-Agent" set to "agent-framework-python/{version}" if headers is None.
The modified headers dictionary with "agent-framework-python/{version}" prepended to the User-Agent.
"""
if not IS_TELEMETRY_ENABLED:
return headers or {}
if not headers:
return {USER_AGENT_KEY: AGENT_FRAMEWORK_USER_AGENT}
headers[USER_AGENT_KEY] = (
f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}"
if USER_AGENT_KEY in headers
else AGENT_FRAMEWORK_USER_AGENT
)
return headers
@@ -0,0 +1,355 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Sequence
from typing import Any, Protocol, TypeVar
from pydantic import model_validator
from ._memory import AggregateContextProvider
from ._pydantic import AFBaseModel
from ._types import ChatMessage
from .exceptions import AgentThreadException
__all__ = ["AgentThread", "ChatMessageStore", "ChatMessageStoreProtocol"]
class ChatMessageStoreProtocol(Protocol):
"""Defines methods for storing and retrieving chat messages associated with a specific thread.
Implementations of this protocol are responsible for managing the storage of chat messages,
including handling large volumes of data by truncating or summarizing messages as necessary.
"""
async def list_messages(self) -> list[ChatMessage]:
"""Gets all the messages from the store that should be used for the next agent invocation.
Messages are returned in ascending chronological order, with the oldest message first.
If the messages stored in the store become very large, it is up to the store to
truncate, summarize or otherwise limit the number of messages returned.
When using implementations of ChatMessageStoreProtocol, a new one should be created for each thread
since they may contain state that is specific to a thread.
"""
...
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
"""Adds messages to the store."""
...
@classmethod
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "ChatMessageStoreProtocol":
"""Creates a new instance of the store from previously serialized state.
This method, together with serialize_state can be used to save and load messages from a persistent store
if this store only has messages in memory.
"""
...
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
"""Update the current ChatMessageStore instance from serialized state data.
Args:
serialized_store_state: Previously serialized state data containing messages.
**kwargs: Additional arguments for deserialization.
"""
...
async def serialize(self, **kwargs: Any) -> Any:
"""Serializes the current object's state.
This method, together with deserialize can be used to save and load messages from a persistent store
if this store only has messages in memory.
"""
...
class AgentThreadState(AFBaseModel):
"""State model for serializing and deserializing thread information.
Attributes:
service_thread_id: Optional ID of the thread managed by the agent service.
chat_message_store_state: Optional serialized state of the chat message store.
"""
service_thread_id: str | None = None
chat_message_store_state: Any | None = None
@model_validator(mode="before")
def validate_only_one(cls, values: dict[str, Any]) -> dict[str, Any]:
if (
isinstance(values, dict)
and values.get("service_thread_id") is not None
and values.get("chat_message_store_state") is not None
):
raise AgentThreadException("Only one of service_thread_id or chat_message_store_state may be set.")
return values
class ChatMessageStoreState(AFBaseModel):
"""State model for serializing and deserializing chat message store data.
Attributes:
messages: List of chat messages stored in the message store.
"""
messages: list[ChatMessage]
TChatMessageStore = TypeVar("TChatMessageStore", bound="ChatMessageStore")
class ChatMessageStore:
"""An in-memory implementation of ChatMessageStoreProtocol that stores messages in a list.
This implementation provides a simple, list-based storage for chat messages
with support for serialization and deserialization. It implements all the
required methods of the ChatMessageStoreProtocol protocol.
The store maintains messages in memory and provides methods to serialize
and deserialize the state for persistence purposes.
Args:
messages: Optional initial list of ChatMessage objects to populate the store.
"""
def __init__(self, messages: Sequence[ChatMessage] | None = None):
"""Create a ChatMessageStore for use in a thread.
Args:
messages: The messages to store.
"""
self.messages = list(messages) if messages else []
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
"""Add messages to the store.
Args:
messages: Sequence of ChatMessage objects to add to the store.
"""
self.messages.extend(messages)
async def list_messages(self) -> list[ChatMessage]:
"""Get all messages from the store in chronological order.
Returns:
List of ChatMessage objects, ordered from oldest to newest.
"""
return self.messages
@classmethod
async def deserialize(
cls: type[TChatMessageStore], serialized_store_state: Any, **kwargs: Any
) -> TChatMessageStore:
"""Create a new ChatMessageStore instance from serialized state data.
Args:
serialized_store_state: Previously serialized state data containing messages.
**kwargs: Additional arguments for deserialization.
Returns:
A new ChatMessageStore instance populated with messages from the serialized state.
"""
state = ChatMessageStoreState.model_validate(serialized_store_state, **kwargs)
if state.messages:
return cls(messages=state.messages)
return cls()
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
"""Update the current ChatMessageStore instance from serialized state data.
Args:
serialized_store_state: Previously serialized state data containing messages.
**kwargs: Additional arguments for deserialization.
"""
if not serialized_store_state:
return
state = ChatMessageStoreState.model_validate(serialized_store_state, **kwargs)
if state.messages:
self.messages = state.messages
async def serialize(self, **kwargs: Any) -> Any:
"""Serialize the current store state for persistence.
Args:
**kwargs: Additional arguments for serialization.
Returns:
Serialized state data that can be used with deserialize_state.
"""
state = ChatMessageStoreState(messages=self.messages)
return state.model_dump(**kwargs)
TAgentThread = TypeVar("TAgentThread", bound="AgentThread")
class AgentThread:
"""The Agent thread class, this can represent both a locally managed thread or a thread managed by the service."""
def __init__(
self,
*,
service_thread_id: str | None = None,
message_store: ChatMessageStoreProtocol | None = None,
context_provider: AggregateContextProvider | None = None,
) -> None:
"""Initialize an AgentThread, do not use this method manually, always use: agent.get_new_thread().
Args:
service_thread_id: Optional ID of the thread managed by the agent service.
message_store: Optional ChatMessageStore implementation for managing chat messages.
context_provider: Optional ContextProvider for the thread.
Note:
Either service_thread_id or message_store may be set, but not both.
"""
if service_thread_id is not None and message_store is not None:
raise AgentThreadException("Only the service_thread_id or message_store may be set, but not both.")
self._service_thread_id = service_thread_id
self._message_store = message_store
self.context_provider = context_provider
@property
def is_initialized(self) -> bool:
"""Indicates if the thread is initialized.
This means either the service_thread_id or the message_store is set.
"""
return self._service_thread_id is not None or self._message_store is not None
@property
def service_thread_id(self) -> str | None:
"""Gets the ID of the current thread to support cases where the thread is owned by the agent service."""
return self._service_thread_id
@service_thread_id.setter
def service_thread_id(self, service_thread_id: str | None) -> None:
"""Sets the ID of the current thread to support cases where the thread is owned by the agent service.
Note that either service_thread_id or message_store may be set, but not both.
"""
if service_thread_id is None:
return
if self._message_store is not None:
raise AgentThreadException(
"Only the service_thread_id or message_store may be set, "
"but not both and switching from one to another is not supported."
)
self._service_thread_id = service_thread_id
@property
def message_store(self) -> ChatMessageStoreProtocol | None:
"""Gets the ChatMessageStoreProtocol used by this thread."""
return self._message_store
@message_store.setter
def message_store(self, message_store: ChatMessageStoreProtocol | None) -> None:
"""Sets the ChatMessageStoreProtocol used by this thread.
Note that either service_thread_id or message_store may be set, but not both.
"""
if message_store is None:
return
if self._service_thread_id is not None:
raise AgentThreadException(
"Only the service_thread_id or message_store may be set, "
"but not both and switching from one to another is not supported."
)
self._message_store = message_store
async def on_new_messages(self, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
"""Invoked when a new message has been contributed to the chat by any participant."""
if self._service_thread_id is not None:
# If the thread messages are stored in the service there is nothing to do here,
# since invoking the service should already update the thread.
return
if self._message_store is None:
# If there is no conversation id, and no store we can
# create a default in memory store.
self._message_store = ChatMessageStore()
# If a store has been provided, we need to add the messages to the store.
if isinstance(new_messages, ChatMessage):
new_messages = [new_messages]
await self._message_store.add_messages(new_messages)
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
"""Serializes the current object's state.
Args:
**kwargs: Arguments for serialization.
"""
chat_message_store_state = None
if self._message_store is not None:
chat_message_store_state = await self._message_store.serialize(**kwargs)
state = AgentThreadState(
service_thread_id=self._service_thread_id, chat_message_store_state=chat_message_store_state
)
return state.model_dump()
@classmethod
async def deserialize(
cls: type[TAgentThread],
serialized_thread_state: dict[str, Any],
*,
message_store: ChatMessageStoreProtocol | None = None,
**kwargs: Any,
) -> TAgentThread:
"""Deserializes the state from a dictionary into a new AgentThread instance.
Args:
serialized_thread_state: The serialized thread state as a dictionary.
message_store: Optional ChatMessageStoreProtocol to use for managing messages.
If not provided, a new ChatMessageStore will be created if needed.
**kwargs: Additional arguments for deserialization.
Returns:
A new AgentThread instance with properties set from the serialized state.
"""
state = AgentThreadState.model_validate(serialized_thread_state)
if state.service_thread_id is not None:
return cls(service_thread_id=state.service_thread_id)
# If we don't have any ChatMessageStoreProtocol state return here.
if state.chat_message_store_state is None:
return cls()
if message_store is not None:
try:
await message_store.update_from_state(state.chat_message_store_state, **kwargs)
except Exception as ex:
raise AgentThreadException("Failed to deserialize the provided message store.") from ex
return cls(message_store=message_store)
try:
message_store = await ChatMessageStore.deserialize(state.chat_message_store_state, **kwargs)
except Exception as ex:
raise AgentThreadException("Failed to deserialize the message store.") from ex
return cls(message_store=message_store)
async def update_from_thread_state(
self,
serialized_thread_state: dict[str, Any],
**kwargs: Any,
) -> None:
"""Deserializes the state from a dictionary into the thread properties."""
state = AgentThreadState.model_validate(serialized_thread_state)
if state.service_thread_id is not None:
self.service_thread_id = state.service_thread_id
# Since we have an ID, we should not have a chat message store and we can return here.
return
# If we don't have any ChatMessageStoreProtocol state return here.
if state.chat_message_store_state is None:
return
if self.message_store is not None:
await self.message_store.update_from_state(state.chat_message_store_state, **kwargs)
# If we don't have a chat message store yet, create an in-memory one.
return
# Create the message store from the default.
self.message_store = await ChatMessageStore.deserialize(state.chat_message_store_state, **kwargs) # type: ignore
@@ -0,0 +1,954 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
import json
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Collection, MutableMapping, Sequence
from functools import wraps
from time import perf_counter, time_ns
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Final,
Generic,
Literal,
Protocol,
TypeVar,
get_args,
get_origin,
runtime_checkable,
)
from opentelemetry.metrics import Histogram
from pydantic import AnyUrl, BaseModel, Field, PrivateAttr, ValidationError, create_model, field_validator
from ._logging import get_logger
from ._pydantic import AFBaseModel
from .exceptions import ChatClientInitializationError, ToolException
from .observability import (
OPERATION_DURATION_BUCKET_BOUNDARIES,
OtelAttr,
capture_exception, # type: ignore
get_function_span,
get_function_span_attributes,
get_meter,
)
if TYPE_CHECKING:
from ._clients import ChatClientProtocol
from ._types import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Contents,
FunctionCallContent,
)
if sys.version_info >= (3, 12):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
logger = get_logger()
__all__ = [
"FUNCTION_INVOKING_CHAT_CLIENT_MARKER",
"AIFunction",
"HostedCodeInterpreterTool",
"HostedFileSearchTool",
"HostedMCPSpecificApproval",
"HostedMCPTool",
"HostedWebSearchTool",
"ToolProtocol",
"ai_function",
"use_function_invocation",
]
logger = get_logger()
FUNCTION_INVOKING_CHAT_CLIENT_MARKER: Final[str] = "__function_invoking_chat_client__"
DEFAULT_MAX_ITERATIONS: Final[int] = 10
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol")
# region Helpers
ArgsT = TypeVar("ArgsT", bound=BaseModel)
ReturnT = TypeVar("ReturnT")
class _NoOpHistogram:
def record(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - trivial
return None
_NOOP_HISTOGRAM = _NoOpHistogram()
def _parse_inputs(
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None",
) -> list["Contents"]:
"""Parse the inputs for a tool, ensuring they are of type Contents."""
if inputs is None:
return []
from ._types import BaseContent, DataContent, HostedFileContent, HostedVectorStoreContent, UriContent
parsed_inputs: list["Contents"] = []
if not isinstance(inputs, list):
inputs = [inputs]
for input_item in inputs:
if isinstance(input_item, str):
# If it's a string, we assume it's a URI or similar identifier.
# Convert it to a UriContent or similar type as needed.
parsed_inputs.append(UriContent(uri=input_item, media_type="text/plain"))
elif isinstance(input_item, dict):
# If it's a dict, we assume it contains properties for a specific content type.
# we check if the required keys are present to determine the type.
# for instance, if it has "uri" and "media_type", we treat it as UriContent.
# if is only has uri, then we treat it as DataContent.
# etc.
if "uri" in input_item:
parsed_inputs.append(
UriContent(**input_item) if "media_type" in input_item else DataContent(**input_item)
)
elif "file_id" in input_item:
parsed_inputs.append(HostedFileContent(**input_item))
elif "vector_store_id" in input_item:
parsed_inputs.append(HostedVectorStoreContent(**input_item))
elif "data" in input_item:
parsed_inputs.append(DataContent(**input_item))
else:
raise ValueError(f"Unsupported input type: {input_item}")
elif isinstance(input_item, BaseContent):
parsed_inputs.append(input_item)
else:
raise TypeError(f"Unsupported input type: {type(input_item).__name__}. Expected Contents or dict.")
return parsed_inputs
# region Tools
@runtime_checkable
class ToolProtocol(Protocol):
"""Represents a generic tool that can be specified to an AI service.
Parameters:
name: The name of the tool.
description: A description of the tool.
additional_properties: Additional properties associated with the tool.
"""
name: str
"""The name of the tool."""
description: str
"""A description of the tool, suitable for use in describing the purpose to a model."""
additional_properties: dict[str, Any] | None
"""Additional properties associated with the tool."""
def __str__(self) -> str:
"""Return a string representation of the tool."""
...
class BaseTool(AFBaseModel):
"""Base class for AI tools, providing common attributes and methods.
Args:
name: The name of the tool.
description: A description of the tool.
additional_properties: Additional properties associated with the tool.
"""
name: str = Field(..., kw_only=False)
description: str = ""
additional_properties: dict[str, Any] | None = None
def __str__(self) -> str:
"""Return a string representation of the tool."""
if self.description:
return f"{self.__class__.__name__}(name={self.name}, description={self.description})"
return f"{self.__class__.__name__}(name={self.name})"
class HostedCodeInterpreterTool(BaseTool):
"""Represents a hosted tool that can be specified to an AI service to enable it to execute generated code.
This tool does not implement code interpretation itself. It serves as a marker to inform a service
that it is allowed to execute generated code if the service is capable of doing so.
"""
inputs: list[Any] = Field(default_factory=list)
def __init__(
self,
*,
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None" = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the HostedCodeInterpreterTool.
Args:
inputs: A list of contents that the tool can accept as input. Defaults to None.
This should mostly be HostedFileContent or HostedVectorStoreContent.
Can also be DataContent, depending on the service used.
When supplying a list, it can contain:
- Contents instances
- dicts with properties for Contents (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- strings (which will be converted to UriContent with media_type "text/plain").
If None, defaults to an empty list.
description: A description of the tool.
additional_properties: Additional properties associated with the tool.
**kwargs: Additional keyword arguments to pass to the base class.
"""
args: dict[str, Any] = {
"name": "code_interpreter",
}
if inputs:
args["inputs"] = _parse_inputs(inputs)
if description is not None:
args["description"] = description
if additional_properties is not None:
args["additional_properties"] = additional_properties
if "name" in kwargs:
raise ValueError("The 'name' argument is reserved for the HostedCodeInterpreterTool and cannot be set.")
super().__init__(**args, **kwargs)
class HostedWebSearchTool(BaseTool):
"""Represents a web search tool that can be specified to an AI service to enable it to perform web searches."""
def __init__(
self,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize a HostedWebSearchTool.
Args:
description: A description of the tool.
additional_properties: Additional properties associated with the tool
(e.g., {"user_location": {"city": "Seattle", "country": "US"}}).
**kwargs: Additional keyword arguments to pass to the base class.
if additional_properties is not provided, any kwargs will be added to additional_properties.
"""
args: dict[str, Any] = {
"name": "web_search",
}
if additional_properties is not None:
args["additional_properties"] = additional_properties
elif kwargs:
args["additional_properties"] = kwargs
if description is not None:
args["description"] = description
super().__init__(**args)
class HostedMCPSpecificApproval(TypedDict, total=False):
"""Represents the `specific` mode for a hosted tool.
When using this mode, the user must specify which tools always or never require approval.
This is represented as a dictionary with two optional keys:
- `always_require_approval`: A sequence of tool names that always require approval.
- `never_require_approval`: A sequence of tool names that never require approval.
"""
always_require_approval: Collection[str] | None
never_require_approval: Collection[str] | None
class HostedMCPTool(BaseTool):
"""Represents a MCP tool that is managed and executed by the service."""
url: AnyUrl
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None
allowed_tools: set[str] | None = None
headers: dict[str, str] | None = None
def __init__(
self,
*,
name: str,
description: str | None = None,
url: AnyUrl | str,
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None,
allowed_tools: Collection[str] | None = None,
headers: dict[str, str] | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Create a hosted MCP tool.
Args:
name: The name of the tool.
description: A description of the tool.
url: The URL of the tool.
approval_mode: The approval mode for the tool. This can be:
- "always_require": The tool always requires approval before use.
- "never_require": The tool never requires approval before use.
- A dict with keys `always_require_approval` or `never_require_approval`,
followed by a sequence of strings with the names of the relevant tools.
allowed_tools: A list of tools that are allowed to use this tool.
headers: Headers to include in requests to the tool.
additional_properties: Additional properties to include in the tool definition.
**kwargs: Additional keyword arguments to pass to the base class.
"""
args: dict[str, Any] = {
"name": name,
"url": url,
}
if allowed_tools is not None:
args["allowed_tools"] = allowed_tools
if approval_mode is not None:
args["approval_mode"] = approval_mode
if headers is not None:
args["headers"] = headers
if description is not None:
args["description"] = description
if additional_properties is not None:
args["additional_properties"] = additional_properties
try:
super().__init__(**args, **kwargs)
except ValidationError as err:
raise ToolException(f"Error initializing HostedMCPTool: {err}", inner_exception=err) from err
@field_validator("approval_mode")
def validate_approval_mode(cls, approval_mode: str | dict[str, Any] | None) -> str | dict[str, Any] | None:
"""Validate the approval_mode field to ensure it is one of the accepted values."""
if approval_mode is None or not isinstance(approval_mode, dict):
return approval_mode
# Validate that the dict has sets
for key, value in approval_mode.items():
if not isinstance(value, set):
approval_mode[key] = set(value) # Convert to set if it's a list or other collection
return approval_mode
class HostedFileSearchTool(BaseTool):
"""Represents a file search tool that can be specified to an AI service to enable it to perform file searches."""
inputs: list[Any] | None = None
max_results: int | None = None
def __init__(
self,
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None" = None,
max_results: int | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize a FileSearchTool.
Args:
inputs: A list of contents that the tool can accept as input. Defaults to None.
This should be one or more HostedVectorStoreContents.
When supplying a list, it can contain:
- Contents instances
- dicts with properties for Contents (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- strings (which will be converted to UriContent with media_type "text/plain").
If None, defaults to an empty list.
max_results: The maximum number of results to return from the file search.
If None, max limit is applied.
description: A description of the tool.
additional_properties: Additional properties associated with the tool.
**kwargs: Additional keyword arguments to pass to the base class.
"""
args: dict[str, Any] = {
"name": "file_search",
}
if inputs:
args["inputs"] = _parse_inputs(inputs)
if max_results:
args["max_results"] = max_results
if description is not None:
args["description"] = description
if additional_properties is not None:
args["additional_properties"] = additional_properties
if "name" in kwargs:
raise ValueError("The 'name' argument is reserved for the HostedFileSearchTool and cannot be set.")
super().__init__(**args, **kwargs)
def _default_histogram() -> Histogram:
"""Get the default histogram for function invocation duration."""
from .observability import OBSERVABILITY_SETTINGS # local import to avoid circulars
if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined]
return _NOOP_HISTOGRAM # type: ignore[return-value]
meter = get_meter()
try:
return meter.create_histogram(
name=OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION,
unit=OtelAttr.DURATION_UNIT,
description="Measures the duration of a function's execution",
explicit_bucket_boundaries_advisory=OPERATION_DURATION_BUCKET_BOUNDARIES,
)
except TypeError:
return meter.create_histogram(
name=OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION,
unit=OtelAttr.DURATION_UNIT,
description="Measures the duration of a function's execution",
)
class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
"""A AITool that is callable as code.
Args:
name: The name of the function.
description: A description of the function.
additional_properties: Additional properties to set on the function.
func: The function to wrap. If None, returns a decorator.
input_model: The Pydantic model that defines the input parameters for the function.
"""
func: Callable[..., Awaitable[ReturnT] | ReturnT]
input_model: type[ArgsT]
_invocation_duration_histogram: Histogram = PrivateAttr(default_factory=_default_histogram)
def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]:
"""Call the wrapped function with the provided arguments."""
return self.func(*args, **kwargs)
async def invoke(
self,
*,
arguments: ArgsT | None = None,
**kwargs: Any,
) -> ReturnT:
"""Run the AI function with the provided arguments as a Pydantic model.
Args:
arguments: A Pydantic model instance containing the arguments for the function.
kwargs: keyword arguments to pass to the function, will not be used if `arguments` is provided.
"""
global OBSERVABILITY_SETTINGS
from .observability import OBSERVABILITY_SETTINGS
tool_call_id = kwargs.pop("tool_call_id", None)
if arguments is not None:
if not isinstance(arguments, self.input_model):
raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}")
kwargs = arguments.model_dump(exclude_none=True)
if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined]
logger.info(f"Function name: {self.name}")
logger.debug(f"Function arguments: {kwargs}")
res = self.__call__(**kwargs)
result = await res if inspect.isawaitable(res) else res
logger.info(f"Function {self.name} succeeded.")
logger.debug(f"Function result: {result or 'None'}")
return result # type: ignore[reportReturnType]
attributes = get_function_span_attributes(self, tool_call_id=tool_call_id)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
attributes.update({
OtelAttr.TOOL_ARGUMENTS: arguments.model_dump_json()
if arguments
else json.dumps(kwargs)
if kwargs
else "None"
})
with get_function_span(attributes=attributes) as span:
attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] = self.name
logger.info(f"Function name: {self.name}")
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
logger.debug(f"Function arguments: {kwargs}")
start_time_stamp = perf_counter()
end_time_stamp: float | None = None
try:
res = self.__call__(**kwargs)
result = await res if inspect.isawaitable(res) else res
end_time_stamp = perf_counter()
except Exception as exception:
end_time_stamp = perf_counter()
attributes[OtelAttr.ERROR_TYPE] = type(exception).__name__
capture_exception(span=span, exception=exception, timestamp=time_ns())
logger.error(f"Function failed. Error: {exception}")
raise
else:
logger.info(f"Function {self.name} succeeded.")
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
try:
json_result = json.dumps(result)
except (TypeError, OverflowError):
span.set_attribute(OtelAttr.TOOL_RESULT, "<non-serializable result>")
logger.debug("Function result: <non-serializable result>")
else:
span.set_attribute(OtelAttr.TOOL_RESULT, json_result)
logger.debug(f"Function result: {json_result}")
return result # type: ignore[reportReturnType]
finally:
duration = (end_time_stamp or perf_counter()) - start_time_stamp
span.set_attribute(OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION, duration)
self._invocation_duration_histogram.record(duration, attributes=attributes)
logger.info("Function duration: %fs", duration)
def parameters(self) -> dict[str, Any]:
"""Create the json schema of the parameters."""
return self.input_model.model_json_schema()
def to_json_schema_spec(self) -> dict[str, Any]:
"""Convert a AIFunction to the JSON Schema function specification format."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters(),
},
}
# region AI Function Decorator
def _parse_annotation(annotation: Any) -> Any:
"""Parse a type annotation and return the corresponding type.
If the second annotation (after the type) is a string, then we convert that to a pydantic Field description.
The rest are returned as-is, allowing for multiple annotations.
"""
origin = get_origin(annotation)
if origin is not None:
args = get_args(annotation)
# For other generics, return the origin type (e.g., list for List[int])
if len(args) > 1 and isinstance(args[1], str):
# Create a new Annotated type with the updated Field
args_list = list(args)
if len(args_list) == 2:
return Annotated[args_list[0], Field(description=args_list[1])]
return Annotated[args_list[0], Field(description=args_list[1]), tuple(args_list[2:])]
return annotation
def ai_function(
func: Callable[..., ReturnT | Awaitable[ReturnT]] | None = None,
*,
name: str | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
) -> AIFunction[Any, ReturnT]:
"""Decorate a function to turn it into a AIFunction that can be passed to models and executed automatically.
This function will create a Pydantic model from the function's signature,
which will be used to validate the arguments passed to the function.
And will be used to generate the JSON schema for the function's parameters.
In order to add descriptions to parameters, in your function signature,
use the `Annotated` type from `typing` and the `Field` class from `pydantic`:
Example:
.. code-block:: python
from typing import Annotated
from pydantic import Field
def ai_function_example(
arg1: Annotated[str, Field(description="The first argument")],
arg2: Annotated[int, Field(description="The second argument")],
) -> str:
# An example function that takes two arguments and returns a string.
return f"arg1: {arg1}, arg2: {arg2}"
Args:
func: The function to wrap. If None, returns a decorator.
name: The name of the tool. Defaults to the function's name.
description: A description of the tool. Defaults to the function's docstring.
additional_properties: Additional properties to set on the tool.
"""
def decorator(func: Callable[..., ReturnT | Awaitable[ReturnT]]) -> AIFunction[Any, ReturnT]:
@wraps(func)
def wrapper(f: Callable[..., ReturnT | Awaitable[ReturnT]]) -> AIFunction[Any, ReturnT]:
tool_name: str = name or getattr(f, "__name__", "unknown_function") # type: ignore[assignment]
tool_desc: str = description or (f.__doc__ or "")
sig = inspect.signature(f)
fields = {
pname: (
_parse_annotation(param.annotation) if param.annotation is not inspect.Parameter.empty else str,
param.default if param.default is not inspect.Parameter.empty else ...,
)
for pname, param in sig.parameters.items()
if pname not in {"self", "cls"}
}
input_model: Any = create_model(f"{tool_name}_input", **fields) # type: ignore[call-overload]
if not issubclass(input_model, BaseModel):
raise TypeError(f"Input model for {tool_name} must be a subclass of BaseModel, got {input_model}")
return AIFunction[Any, ReturnT](
name=tool_name,
description=tool_desc,
additional_properties=additional_properties or {},
func=f,
input_model=input_model,
)
return wrapper(func)
return decorator(func) if func else decorator # type: ignore[reportReturnType, return-value]
# region Function Invoking Chat Client
async def _auto_invoke_function(
function_call_content: "FunctionCallContent",
custom_args: dict[str, Any] | None = None,
*,
tool_map: dict[str, AIFunction[BaseModel, Any]],
sequence_index: int | None = None,
request_index: int | None = None,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline
) -> "Contents":
"""Invoke a function call requested by the agent, applying filters that are defined in the agent."""
from ._types import FunctionResultContent
tool: AIFunction[BaseModel, Any] | None = tool_map.get(function_call_content.name)
if tool is None:
raise KeyError(f"No tool or function named '{function_call_content.name}'")
parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
# Merge with user-supplied args; right-hand side dominates, so parsed args win on conflicts.
merged_args: dict[str, Any] = (custom_args or {}) | parsed_args
args = tool.input_model.model_validate(merged_args)
exception = None
# Execute through middleware pipeline if available
if middleware_pipeline and hasattr(middleware_pipeline, "has_middlewares") and middleware_pipeline.has_middlewares:
from ._middleware import FunctionInvocationContext
middleware_context = FunctionInvocationContext(
function=tool,
arguments=args,
kwargs=custom_args or {},
)
async def final_function_handler(context_obj: Any) -> Any:
return await tool.invoke(
arguments=context_obj.arguments,
tool_call_id=function_call_content.call_id,
)
try:
function_result = await middleware_pipeline.execute(
function=tool,
arguments=args,
context=middleware_context,
final_handler=final_function_handler,
)
except Exception as ex:
exception = ex
function_result = None
else:
# No middleware - execute directly
try:
function_result = await tool.invoke(
arguments=args,
tool_call_id=function_call_content.call_id,
) # type: ignore[arg-type]
except Exception as ex:
exception = ex
function_result = None
return FunctionResultContent(
call_id=function_call_content.call_id,
exception=exception,
result=function_result,
)
def _get_tool_map(
tools: "ToolProtocol \
| Callable[..., Any] \
| MutableMapping[str, Any] \
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
) -> dict[str, AIFunction[Any, Any]]:
ai_function_list: dict[str, AIFunction[Any, Any]] = {}
for tool in tools if isinstance(tools, list) else [tools]:
if isinstance(tool, AIFunction):
ai_function_list[tool.name] = tool
continue
if callable(tool):
# Convert to AITool if it's a function or callable
ai_tool = ai_function(tool)
ai_function_list[ai_tool.name] = ai_tool
return ai_function_list
async def execute_function_calls(
custom_args: dict[str, Any],
attempt_idx: int,
function_calls: Sequence["FunctionCallContent"],
tools: "ToolProtocol \
| Callable[..., Any] \
| MutableMapping[str, Any] \
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
) -> list["Contents"]:
tool_map = _get_tool_map(tools)
# Run all function calls concurrently
return await asyncio.gather(*[
_auto_invoke_function(
function_call_content=function_call,
custom_args=custom_args,
tool_map=tool_map,
sequence_index=seq_idx,
request_index=attempt_idx,
middleware_pipeline=middleware_pipeline,
)
for seq_idx, function_call in enumerate(function_calls)
])
def update_conversation_id(kwargs: dict[str, Any], conversation_id: str | None) -> None:
"""Update kwargs with conversation id."""
if conversation_id is None:
return
if "chat_options" in kwargs:
kwargs["chat_options"].conversation_id = conversation_id
else:
kwargs["conversation_id"] = conversation_id
def _handle_function_calls_response(
func: Callable[..., Awaitable["ChatResponse"]],
*,
max_iterations: int = 10,
) -> Callable[..., Awaitable["ChatResponse"]]:
"""Decorate the get_response method to enable function calls.
Args:
func: The get_response method to decorate.
max_iterations: The maximum number of function call iterations to perform.
"""
def decorator(
func: Callable[..., Awaitable["ChatResponse"]],
) -> Callable[..., Awaitable["ChatResponse"]]:
"""Inner decorator."""
@wraps(func)
async def function_invocation_wrapper(
self: "ChatClientProtocol",
messages: "str | ChatMessage | list[str] | list[ChatMessage]",
**kwargs: Any,
) -> "ChatResponse":
from ._clients import prepare_messages
from ._middleware import extract_and_merge_function_middleware
from ._types import ChatMessage, ChatOptions, FunctionCallContent, FunctionResultContent
# Extract and merge function middleware from chat client with kwargs pipeline
extract_and_merge_function_middleware(self, kwargs)
# Extract the middleware pipeline before calling the underlying function
# because the underlying function may not preserve it in kwargs
stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline")
prepped_messages = prepare_messages(messages)
response: "ChatResponse | None" = None
fcc_messages: "list[ChatMessage]" = []
for attempt_idx in range(max_iterations):
response = await func(self, messages=prepped_messages, **kwargs)
# if there are function calls, we will handle them first
function_results = {
it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent)
}
function_calls = [
it
for it in response.messages[0].contents
if isinstance(it, FunctionCallContent) and it.call_id not in function_results
]
if response.conversation_id is not None:
update_conversation_id(kwargs, response.conversation_id)
prepped_messages = []
tools = kwargs.get("tools")
if not tools and (chat_options := kwargs.get("chat_options")) and isinstance(chat_options, ChatOptions):
tools = chat_options.tools
if function_calls and tools:
# Use the stored middleware pipeline instead of extracting from kwargs
# because kwargs may have been modified by the underlying function
middleware_pipeline = stored_middleware_pipeline
function_results = await execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=function_calls,
tools=tools, # type: ignore
middleware_pipeline=middleware_pipeline,
)
# add a single ChatMessage to the response with the results
result_message = ChatMessage(role="tool", contents=function_results) # type: ignore[call-overload]
response.messages.append(result_message)
# response should contain 2 messages after this,
# one with function call contents
# and one with function result contents
# the amount and call_id's should match
# this runs in every but the first run
# we need to keep track of all function call messages
fcc_messages.extend(response.messages)
# and add them as additional context to the messages
if getattr(kwargs.get("chat_options"), "store", False):
prepped_messages.clear()
prepped_messages.append(result_message)
else:
prepped_messages.extend(response.messages)
continue
# If we reach this point, it means there were no function calls to handle,
# we'll add the previous function call and responses
# to the front of the list, so that the final response is the last one
# TODO (eavanvalkenburg): control this behavior?
if fcc_messages:
for msg in reversed(fcc_messages):
response.messages.insert(0, msg)
return response
# Failsafe: give up on tools, ask model for plain answer
kwargs["tool_choice"] = "none"
response = await func(self, messages=prepped_messages, **kwargs)
if fcc_messages:
for msg in reversed(fcc_messages):
response.messages.insert(0, msg)
return response
return function_invocation_wrapper # type: ignore
return decorator(func)
def _handle_function_calls_streaming_response(
func: Callable[..., AsyncIterable["ChatResponseUpdate"]],
*,
max_iterations: int = 10,
) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]:
"""Decorate the get_streaming_response method to handle function calls.
Args:
func: The get_streaming_response method to decorate.
max_iterations: The maximum number of function call iterations to perform.
"""
def decorator(
func: Callable[..., AsyncIterable["ChatResponseUpdate"]],
) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]:
"""Inner decorator."""
@wraps(func)
async def streaming_function_invocation_wrapper(
self: "ChatClientProtocol",
messages: "str | ChatMessage | list[str] | list[ChatMessage]",
**kwargs: Any,
) -> AsyncIterable["ChatResponseUpdate"]:
"""Wrap the inner get streaming response method to handle tool calls."""
from ._clients import prepare_messages
from ._middleware import extract_and_merge_function_middleware
from ._types import ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, FunctionCallContent
# Extract and merge function middleware from chat client with kwargs pipeline
extract_and_merge_function_middleware(self, kwargs)
# Extract the middleware pipeline before calling the underlying function
# because the underlying function may not preserve it in kwargs
stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline")
prepped_messages = prepare_messages(messages)
for attempt_idx in range(max_iterations):
all_updates: list["ChatResponseUpdate"] = []
async for update in func(self, messages=prepped_messages, **kwargs):
all_updates.append(update)
yield update
# efficient check for FunctionCallContent in the updates
# if there is at least one, this stops and continuous
# if there are no FCC's then it returns
if not any(isinstance(item, FunctionCallContent) for upd in all_updates for item in upd.contents):
return
# Now combining the updates to create the full response.
# Depending on the prompt, the message may contain both function call
# content and others
response: "ChatResponse" = ChatResponse.from_chat_response_updates(all_updates)
# add the response message to the previous messages
prepped_messages.append(response.messages[0])
# get the fccs
function_calls = [
item for item in response.messages[0].contents if isinstance(item, FunctionCallContent)
]
# When conversation id is present, it means that messages are hosted on the server.
# In this case, we need to update kwargs with conversation id and also clear messages
if response.conversation_id is not None:
update_conversation_id(kwargs, response.conversation_id)
prepped_messages = []
tools = kwargs.get("tools")
if not tools and (chat_options := kwargs.get("chat_options")) and isinstance(chat_options, ChatOptions):
tools = chat_options.tools
if function_calls and tools:
# Use the stored middleware pipeline instead of extracting from kwargs
# because kwargs may have been modified by the underlying function
middleware_pipeline = stored_middleware_pipeline
function_results = await execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=function_calls,
tools=tools, # type: ignore[reportArgumentType]
middleware_pipeline=middleware_pipeline,
)
function_result_msg = ChatMessage(role="tool", contents=function_results)
yield ChatResponseUpdate(contents=function_results, role="tool")
response.messages.append(function_result_msg)
prepped_messages.append(function_result_msg)
continue
# Failsafe: give up on tools, ask model for plain answer
kwargs["tool_choice"] = "none"
async for update in func(self, messages=prepped_messages, **kwargs):
yield update
return streaming_function_invocation_wrapper
return decorator(func)
def use_function_invocation(
chat_client: type[TChatClient],
) -> type[TChatClient]:
"""Class decorator that enables tool calling for a chat client."""
if getattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER, False):
return chat_client
max_iterations = DEFAULT_MAX_ITERATIONS
try:
chat_client.get_response = _handle_function_calls_response( # type: ignore
func=chat_client.get_response, # type: ignore
max_iterations=max_iterations,
)
except AttributeError as ex:
raise ChatClientInitializationError(
f"Chat client {chat_client.__name__} does not have a get_response method, cannot apply function invocation."
) from ex
try:
chat_client.get_streaming_response = _handle_function_calls_streaming_response( # type: ignore
func=chat_client.get_streaming_response,
max_iterations=max_iterations,
)
except AttributeError as ex:
raise ChatClientInitializationError(
f"Chat client {chat_client.__name__} does not have a get_streaming_response method, "
"cannot apply function invocation."
) from ex
setattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER, True)
return chat_client
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
# Get Started with Microsoft Agent Framework Workflow
Workflow capabilities now ship with the core `agent-framework` package.
```bash
pip install agent-framework
```
Optional visualization support is still available via the `viz` extra:
```bash
pip install agent-framework[viz]
```
See the [project README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information.
@@ -0,0 +1,182 @@
# Copyright (c) Microsoft. All rights reserved.
from ._agent import WorkflowAgent
from ._checkpoint import (
CheckpointStorage,
FileCheckpointStorage,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
from ._concurrent import ConcurrentBuilder
from ._const import (
DEFAULT_MAX_ITERATIONS,
)
from ._edge import (
Case,
Default,
Edge,
FanInEdgeGroup,
FanOutEdgeGroup,
SingleEdgeGroup,
SwitchCaseEdgeGroup,
SwitchCaseEdgeGroupCase,
SwitchCaseEdgeGroupDefault,
)
from ._edge_runner import create_edge_runner
from ._events import (
AgentRunEvent,
AgentRunUpdateEvent,
ExecutorCompletedEvent,
ExecutorEvent,
ExecutorFailedEvent,
ExecutorInvokedEvent,
RequestInfoEvent,
WorkflowErrorDetails,
WorkflowEvent,
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowLifecycleEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStartedEvent,
WorkflowStatusEvent,
)
from ._executor import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
handler,
)
from ._function_executor import FunctionExecutor, executor
from ._magentic import (
MagenticAgentDeltaEvent,
MagenticAgentExecutor,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticCallbackEvent,
MagenticCallbackMode,
MagenticContext,
MagenticFinalResultEvent,
MagenticManagerBase,
MagenticOrchestratorExecutor,
MagenticOrchestratorMessageEvent,
MagenticPlanReviewDecision,
MagenticPlanReviewReply,
MagenticPlanReviewRequest,
MagenticProgressLedger,
MagenticProgressLedgerItem,
MagenticRequestMessage,
MagenticResponseMessage,
MagenticStartMessage,
StandardMagenticManager,
)
from ._runner import Runner
from ._runner_context import (
InProcRunnerContext,
Message,
RunnerContext,
)
from ._sequential import SequentialBuilder
from ._shared_state import SharedState
from ._validation import (
EdgeDuplicationError,
ExecutorDuplicationError,
GraphConnectivityError,
TypeCompatibilityError,
ValidationTypeEnum,
WorkflowValidationError,
validate_workflow_graph,
)
from ._viz import WorkflowViz
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
from ._workflow_context import WorkflowContext
from ._workflow_executor import WorkflowExecutor
__all__ = [
"DEFAULT_MAX_ITERATIONS",
"AgentExecutor",
"AgentExecutorRequest",
"AgentExecutorResponse",
"AgentRunEvent",
"AgentRunUpdateEvent",
"Case",
"CheckpointStorage",
"ConcurrentBuilder",
"Default",
"Edge",
"EdgeDuplicationError",
"Executor",
"ExecutorCompletedEvent",
"ExecutorDuplicationError",
"ExecutorEvent",
"ExecutorFailedEvent",
"ExecutorInvokedEvent",
"FanInEdgeGroup",
"FanOutEdgeGroup",
"FileCheckpointStorage",
"FunctionExecutor",
"GraphConnectivityError",
"InMemoryCheckpointStorage",
"InProcRunnerContext",
"MagenticAgentDeltaEvent",
"MagenticAgentExecutor",
"MagenticAgentMessageEvent",
"MagenticBuilder",
"MagenticCallbackEvent",
"MagenticCallbackMode",
"MagenticContext",
"MagenticFinalResultEvent",
"MagenticManagerBase",
"MagenticOrchestratorExecutor",
"MagenticOrchestratorMessageEvent",
"MagenticPlanReviewDecision",
"MagenticPlanReviewReply",
"MagenticPlanReviewRequest",
"MagenticProgressLedger",
"MagenticProgressLedgerItem",
"MagenticRequestMessage",
"MagenticResponseMessage",
"MagenticStartMessage",
"Message",
"RequestInfoEvent",
"RequestInfoExecutor",
"RequestInfoMessage",
"RequestResponse",
"Runner",
"RunnerContext",
"SequentialBuilder",
"SharedState",
"SingleEdgeGroup",
"StandardMagenticManager",
"SwitchCaseEdgeGroup",
"SwitchCaseEdgeGroupCase",
"SwitchCaseEdgeGroupDefault",
"TypeCompatibilityError",
"ValidationTypeEnum",
"Workflow",
"WorkflowAgent",
"WorkflowBuilder",
"WorkflowCheckpoint",
"WorkflowContext",
"WorkflowErrorDetails",
"WorkflowEvent",
"WorkflowEventSource",
"WorkflowExecutor",
"WorkflowFailedEvent",
"WorkflowLifecycleEvent",
"WorkflowOutputEvent",
"WorkflowRunResult",
"WorkflowRunState",
"WorkflowStartedEvent",
"WorkflowStatusEvent",
"WorkflowValidationError",
"WorkflowViz",
"create_edge_runner",
"executor",
"handler",
"validate_workflow_graph",
]
@@ -0,0 +1,180 @@
# Copyright (c) Microsoft. All rights reserved.
from ._agent import WorkflowAgent
from ._checkpoint import (
CheckpointStorage,
FileCheckpointStorage,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
from ._concurrent import ConcurrentBuilder
from ._const import DEFAULT_MAX_ITERATIONS
from ._edge import (
Case,
Default,
Edge,
FanInEdgeGroup,
FanOutEdgeGroup,
SingleEdgeGroup,
SwitchCaseEdgeGroup,
SwitchCaseEdgeGroupCase,
SwitchCaseEdgeGroupDefault,
)
from ._edge_runner import create_edge_runner
from ._events import (
AgentRunEvent,
AgentRunUpdateEvent,
ExecutorCompletedEvent,
ExecutorEvent,
ExecutorFailedEvent,
ExecutorInvokedEvent,
RequestInfoEvent,
WorkflowErrorDetails,
WorkflowEvent,
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowLifecycleEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStartedEvent,
WorkflowStatusEvent,
)
from ._executor import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
handler,
)
from ._function_executor import FunctionExecutor, executor
from ._magentic import (
MagenticAgentDeltaEvent,
MagenticAgentExecutor,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticCallbackEvent,
MagenticCallbackMode,
MagenticContext,
MagenticFinalResultEvent,
MagenticManagerBase,
MagenticOrchestratorExecutor,
MagenticOrchestratorMessageEvent,
MagenticPlanReviewDecision,
MagenticPlanReviewReply,
MagenticPlanReviewRequest,
MagenticProgressLedger,
MagenticProgressLedgerItem,
MagenticRequestMessage,
MagenticResponseMessage,
MagenticStartMessage,
StandardMagenticManager,
)
from ._runner import Runner
from ._runner_context import (
InProcRunnerContext,
Message,
RunnerContext,
)
from ._sequential import SequentialBuilder
from ._shared_state import SharedState
from ._validation import (
EdgeDuplicationError,
ExecutorDuplicationError,
GraphConnectivityError,
TypeCompatibilityError,
ValidationTypeEnum,
WorkflowValidationError,
validate_workflow_graph,
)
from ._viz import WorkflowViz
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
from ._workflow_context import WorkflowContext
from ._workflow_executor import WorkflowExecutor
__all__ = [
"DEFAULT_MAX_ITERATIONS",
"AgentExecutor",
"AgentExecutorRequest",
"AgentExecutorResponse",
"AgentRunEvent",
"AgentRunUpdateEvent",
"Case",
"CheckpointStorage",
"ConcurrentBuilder",
"Default",
"Edge",
"EdgeDuplicationError",
"Executor",
"ExecutorCompletedEvent",
"ExecutorDuplicationError",
"ExecutorEvent",
"ExecutorFailedEvent",
"ExecutorInvokedEvent",
"FanInEdgeGroup",
"FanOutEdgeGroup",
"FileCheckpointStorage",
"FunctionExecutor",
"GraphConnectivityError",
"InMemoryCheckpointStorage",
"InProcRunnerContext",
"MagenticAgentDeltaEvent",
"MagenticAgentExecutor",
"MagenticAgentMessageEvent",
"MagenticBuilder",
"MagenticCallbackEvent",
"MagenticCallbackMode",
"MagenticContext",
"MagenticFinalResultEvent",
"MagenticManagerBase",
"MagenticOrchestratorExecutor",
"MagenticOrchestratorMessageEvent",
"MagenticPlanReviewDecision",
"MagenticPlanReviewReply",
"MagenticPlanReviewRequest",
"MagenticProgressLedger",
"MagenticProgressLedgerItem",
"MagenticRequestMessage",
"MagenticResponseMessage",
"MagenticStartMessage",
"Message",
"RequestInfoEvent",
"RequestInfoExecutor",
"RequestInfoMessage",
"RequestResponse",
"Runner",
"RunnerContext",
"SequentialBuilder",
"SharedState",
"SingleEdgeGroup",
"StandardMagenticManager",
"SwitchCaseEdgeGroup",
"SwitchCaseEdgeGroupCase",
"SwitchCaseEdgeGroupDefault",
"TypeCompatibilityError",
"ValidationTypeEnum",
"Workflow",
"WorkflowAgent",
"WorkflowBuilder",
"WorkflowCheckpoint",
"WorkflowContext",
"WorkflowErrorDetails",
"WorkflowEvent",
"WorkflowEventSource",
"WorkflowExecutor",
"WorkflowFailedEvent",
"WorkflowLifecycleEvent",
"WorkflowOutputEvent",
"WorkflowRunResult",
"WorkflowRunState",
"WorkflowStartedEvent",
"WorkflowStatusEvent",
"WorkflowValidationError",
"WorkflowViz",
"create_edge_runner",
"executor",
"handler",
"validate_workflow_graph",
]
@@ -0,0 +1,472 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
import uuid
from collections.abc import AsyncIterable, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
BaseAgent,
ChatMessage,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
UsageDetails,
)
from ..exceptions import AgentExecutionException
from ._events import (
AgentRunUpdateEvent,
RequestInfoEvent,
WorkflowEvent,
)
if TYPE_CHECKING:
from ._workflow import Workflow
logger = logging.getLogger(__name__)
class WorkflowAgent(BaseAgent):
"""An `Agent` subclass that wraps a workflow and exposes it as an agent."""
# Class variable for the request info function name
REQUEST_INFO_FUNCTION_NAME: ClassVar[str] = "request_info"
@dataclass
class RequestInfoFunctionArgs:
request_id: str
data: Any
def to_dict(self) -> dict[str, Any]:
return {"request_id": self.request_id, "data": self.data}
def to_json(self) -> str:
return json.dumps(self.to_dict())
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "WorkflowAgent.RequestInfoFunctionArgs":
return cls(request_id=payload.get("request_id", ""), data=payload.get("data"))
@classmethod
def from_json(cls, raw: str) -> "WorkflowAgent.RequestInfoFunctionArgs":
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("RequestInfoFunctionArgs JSON payload must decode to a mapping")
return cls.from_dict(data)
def __init__(
self,
workflow: "Workflow",
*,
id: str | None = None,
name: str | None = None,
description: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize the WorkflowAgent.
Args:
workflow: The workflow to wrap as an agent.
id: Unique identifier for the agent. If None, will be generated.
name: Optional name for the agent.
description: Optional description of the agent.
**kwargs: Additional keyword arguments passed to BaseAgent.
"""
if id is None:
id = f"WorkflowAgent_{uuid.uuid4().hex[:8]}"
# Initialize with standard BaseAgent parameters first
# Validate the workflow's start executor can handle agent-facing message inputs
try:
start_executor = workflow.get_start_executor()
except KeyError as exc: # Defensive: workflow lacks a configured entry point
raise ValueError("Workflow's start executor is not defined.") from exc
if list[ChatMessage] not in start_executor.input_types:
raise ValueError("Workflow's start executor cannot handle list[ChatMessage]")
super().__init__(id=id, name=name, description=description, **kwargs)
self._workflow: "Workflow" = workflow
self._pending_requests: dict[str, RequestInfoEvent] = {}
@property
def workflow(self) -> "Workflow":
return self._workflow
@property
def pending_requests(self) -> dict[str, RequestInfoEvent]:
return self._pending_requests
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentRunResponse:
"""Get a response from the workflow agent (non-streaming).
This method collects all streaming updates and merges them into a single response.
Args:
messages: The message(s) to send to the workflow.
thread: The conversation thread. If None, a new thread will be created.
**kwargs: Additional keyword arguments.
Returns:
The final workflow response as an AgentRunResponse.
"""
# Collect all streaming updates
response_updates: list[AgentRunResponseUpdate] = []
input_messages = self._normalize_messages(messages)
thread = thread or self.get_new_thread()
response_id = str(uuid.uuid4())
async for update in self._run_stream_impl(input_messages, response_id):
response_updates.append(update)
# Convert updates to final response.
response = self.merge_updates(response_updates, response_id)
# Notify thread of new messages (both input and response messages)
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
return response
async def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
"""Stream response updates from the workflow agent.
Args:
messages: The message(s) to send to the workflow.
thread: The conversation thread. If None, a new thread will be created.
**kwargs: Additional keyword arguments.
Yields:
AgentRunResponseUpdate objects representing the workflow execution progress.
"""
input_messages = self._normalize_messages(messages)
thread = thread or self.get_new_thread()
response_updates: list[AgentRunResponseUpdate] = []
response_id = str(uuid.uuid4())
async for update in self._run_stream_impl(input_messages, response_id):
response_updates.append(update)
yield update
# Convert updates to final response.
response = self.merge_updates(response_updates, response_id)
# Notify thread of new messages (both input and response messages)
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
async def _run_stream_impl(
self,
input_messages: list[ChatMessage],
response_id: str,
) -> AsyncIterable[AgentRunResponseUpdate]:
"""Internal implementation of streaming execution.
Args:
input_messages: Normalized input messages to process.
response_id: The unique response ID for this workflow execution.
Yields:
AgentRunResponseUpdate objects representing the workflow execution progress.
"""
# Determine the event stream based on whether we have function responses
if bool(self.pending_requests):
# This is a continuation - use send_responses_streaming to send function responses back
logger.info(f"Continuing workflow to address {len(self.pending_requests)} requests")
# Extract function responses from input messages, and ensure that
# only function responses are present in messages if there is any
# pending request.
function_responses = self._extract_function_responses(input_messages)
# Pop pending requests if fulfilled.
for request_id in list(self.pending_requests.keys()):
if request_id in function_responses:
self.pending_requests.pop(request_id)
# NOTE: It is possible that some pending requests are not fulfilled,
# and we will let the workflow to handle this -- the agent does not
# have an opinion on this.
event_stream = self.workflow.send_responses_streaming(function_responses)
else:
# Execute workflow with streaming (initial run or no function responses)
# Pass the new input messages directly to the workflow
event_stream = self.workflow.run_stream(input_messages)
# Process events from the stream
async for event in event_stream:
# Convert workflow event to agent update
update = self._convert_workflow_event_to_agent_update(response_id, event)
if update:
yield update
def _normalize_messages(
self,
messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None,
) -> list[ChatMessage]:
"""Normalize input messages to a list of ChatMessage objects."""
if messages is None:
return []
if isinstance(messages, str):
return [ChatMessage(role=Role.USER, contents=[TextContent(text=messages)])]
if isinstance(messages, ChatMessage):
return [messages]
normalized: list[ChatMessage] = []
for msg in messages:
if isinstance(msg, str):
normalized.append(ChatMessage(role=Role.USER, contents=[TextContent(text=msg)]))
elif isinstance(msg, ChatMessage):
normalized.append(msg)
return normalized
def _convert_workflow_event_to_agent_update(
self,
response_id: str,
event: WorkflowEvent,
) -> AgentRunResponseUpdate | None:
"""Convert a workflow event to an AgentRunResponseUpdate.
Only AgentRunUpdateEvent and RequestInfoEvent are processed and the rest
are not relevant. Returns None if the event is not relevant.
"""
match event:
case AgentRunUpdateEvent(data=update):
# Direct pass-through of update in an agent streaming event
if update:
return cast(AgentRunResponseUpdate, update)
return None
case RequestInfoEvent(request_id=request_id):
# Store the pending request for later correlation
self.pending_requests[request_id] = event
# Convert to function call content
# TODO(ekzhu): update this to FunctionApprovalRequestContent
# monitor: https://github.com/microsoft/agent-framework/issues/285
function_call = FunctionCallContent(
call_id=request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments=self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict(),
)
return AgentRunResponseUpdate(
contents=[function_call],
role=Role.ASSISTANT,
author_name=self.name,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
case _:
# Ignore non-agent workflow events
pass
# We only care about the above two events and discard the rest.
return None
def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict[str, Any]:
"""Extract function responses from input messages."""
function_responses: dict[str, Any] = {}
for message in input_messages:
for content in message.contents:
# TODO(ekzhu): update this to FunctionApprovalResponseContent
# monitor: https://github.com/microsoft/agent-framework/issues/285
if isinstance(content, FunctionResultContent):
request_id = content.call_id
# Check if we have a pending request for this call_id
if request_id in self.pending_requests:
response_data = content.result if hasattr(content, "result") else str(content)
function_responses[request_id] = response_data
elif bool(self.pending_requests):
# Function result for unknown request when we have pending requests - this is an error
raise AgentExecutionException(
"Only FunctionResultContent for pending requests is allowed in input messages "
"when there are pending requests."
)
else:
if bool(self.pending_requests):
# Non-function content when we have pending requests - this is an error
raise AgentExecutionException(
"Only FunctionResultContent is allowed in input messages when there are pending requests."
)
return function_responses
class _ResponseState(TypedDict):
"""State for grouping response updates by message_id."""
by_msg: dict[str, list[AgentRunResponseUpdate]]
dangling: list[AgentRunResponseUpdate]
@staticmethod
def merge_updates(updates: list[AgentRunResponseUpdate], response_id: str) -> AgentRunResponse:
"""Merge streaming updates into a single AgentRunResponse.
Behavior:
- Group updates by response_id; within each response_id, group by message_id and keep a dangling bucket for
updates without message_id.
- Convert each group (per message and dangling) into an intermediate AgentRunResponse via
AgentRunResponse.from_agent_run_response_updates, then sort by created_at and merge.
- Append messages from updates without any response_id at the end (global dangling), while aggregating metadata.
Args:
updates: The list of AgentRunResponseUpdate objects to merge.
response_id: The response identifier to set on the returned AgentRunResponse.
Returns:
An AgentRunResponse with messages in processing order and aggregated metadata.
"""
# PHASE 1: GROUP UPDATES BY RESPONSE_ID AND MESSAGE_ID
states: dict[str, WorkflowAgent._ResponseState] = {}
global_dangling: list[AgentRunResponseUpdate] = []
for u in updates:
if u.response_id:
state = states.setdefault(u.response_id, {"by_msg": {}, "dangling": []})
by_msg = state["by_msg"]
dangling = state["dangling"]
if u.message_id:
by_msg.setdefault(u.message_id, []).append(u)
else:
dangling.append(u)
else:
global_dangling.append(u)
# HELPER FUNCTIONS
def _parse_dt(value: str | None) -> tuple[int, datetime | str | None]:
if not value:
return (1, None)
v = value
if v.endswith("Z"):
v = v[:-1] + "+00:00"
try:
return (0, datetime.fromisoformat(v))
except Exception:
return (0, v)
def _sum_usage(a: UsageDetails | None, b: UsageDetails | None) -> UsageDetails | None:
if a is None:
return b
if b is None:
return a
return a + b
def _merge_responses(current: AgentRunResponse | None, incoming: AgentRunResponse) -> AgentRunResponse:
if current is None:
return incoming
raw_list: list[object] = []
def _add_raw(value: object) -> None:
if isinstance(value, list):
raw_list.extend(cast(list[object], value))
else:
raw_list.append(value)
if current.raw_representation is not None:
_add_raw(current.raw_representation)
if incoming.raw_representation is not None:
_add_raw(incoming.raw_representation)
return AgentRunResponse(
messages=(current.messages or []) + (incoming.messages or []),
response_id=current.response_id or incoming.response_id,
created_at=incoming.created_at or current.created_at,
usage_details=_sum_usage(current.usage_details, incoming.usage_details),
raw_representation=raw_list if raw_list else None,
additional_properties=incoming.additional_properties or current.additional_properties,
)
# PHASE 2: CONVERT GROUPED UPDATES TO RESPONSES AND MERGE
final_messages: list[ChatMessage] = []
merged_usage: UsageDetails | None = None
latest_created_at: str | None = None
merged_additional_properties: dict[str, Any] | None = None
raw_representations: list[object] = []
for grouped_response_id in states:
state = states[grouped_response_id]
by_msg = state["by_msg"]
dangling = state["dangling"]
per_message_responses: list[AgentRunResponse] = []
for _, msg_updates in by_msg.items():
if msg_updates:
per_message_responses.append(AgentRunResponse.from_agent_run_response_updates(msg_updates))
if dangling:
per_message_responses.append(AgentRunResponse.from_agent_run_response_updates(dangling))
per_message_responses.sort(key=lambda r: _parse_dt(r.created_at))
aggregated: AgentRunResponse | None = None
for resp in per_message_responses:
if resp.response_id and grouped_response_id and resp.response_id != grouped_response_id:
resp.response_id = grouped_response_id
aggregated = _merge_responses(aggregated, resp)
if aggregated:
final_messages.extend(aggregated.messages)
if aggregated.usage_details:
merged_usage = _sum_usage(merged_usage, aggregated.usage_details)
if aggregated.created_at and (
not latest_created_at or _parse_dt(aggregated.created_at) > _parse_dt(latest_created_at)
):
latest_created_at = aggregated.created_at
if aggregated.additional_properties:
if merged_additional_properties is None:
merged_additional_properties = {}
merged_additional_properties.update(aggregated.additional_properties)
raw_value = aggregated.raw_representation
if raw_value:
cast_value = cast(object | list[object], raw_value)
if isinstance(cast_value, list):
raw_representations.extend(cast(list[object], cast_value))
else:
raw_representations.append(cast_value)
# PHASE 3: HANDLE GLOBAL DANGLING UPDATES (NO RESPONSE_ID)
if global_dangling:
flattened = AgentRunResponse.from_agent_run_response_updates(global_dangling)
final_messages.extend(flattened.messages)
if flattened.usage_details:
merged_usage = _sum_usage(merged_usage, flattened.usage_details)
if flattened.created_at and (
not latest_created_at or _parse_dt(flattened.created_at) > _parse_dt(latest_created_at)
):
latest_created_at = flattened.created_at
if flattened.additional_properties:
if merged_additional_properties is None:
merged_additional_properties = {}
merged_additional_properties.update(flattened.additional_properties)
flat_raw = flattened.raw_representation
if flat_raw:
cast_flat = cast(object | list[object], flat_raw)
if isinstance(cast_flat, list):
raw_representations.extend(cast(list[object], cast_flat))
else:
raw_representations.append(cast_flat)
# PHASE 4: CONSTRUCT FINAL RESPONSE WITH INPUT RESPONSE_ID
return AgentRunResponse(
messages=final_messages,
response_id=response_id,
created_at=latest_created_at,
usage_details=merged_usage,
raw_representation=raw_representations if raw_representations else None,
additional_properties=merged_additional_properties,
)
@@ -0,0 +1,200 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import logging
import os
import uuid
from collections.abc import Mapping
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Protocol
from ._const import DEFAULT_MAX_ITERATIONS
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class WorkflowCheckpoint:
"""Represents a complete checkpoint of workflow state."""
checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4()))
workflow_id: str = ""
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
# Core workflow state
messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc]
shared_state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
executor_states: dict[str, dict[str, Any]] = field(default_factory=dict) # type: ignore[misc]
# Runtime state
iteration_count: int = 0
max_iterations: int = DEFAULT_MAX_ITERATIONS
# Metadata
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
version: str = "1.0"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "WorkflowCheckpoint":
return cls(**data)
class CheckpointStorage(Protocol):
"""Protocol for checkpoint storage backends."""
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
"""Save a checkpoint and return its ID."""
...
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Load a checkpoint by ID."""
...
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
...
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
...
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
"""Delete a checkpoint by ID."""
...
class InMemoryCheckpointStorage:
"""In-memory checkpoint storage for testing and development."""
def __init__(self) -> None:
"""Initialize the memory storage."""
self._checkpoints: dict[str, WorkflowCheckpoint] = {}
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
"""Save a checkpoint and return its ID."""
self._checkpoints[checkpoint.checkpoint_id] = checkpoint
logger.debug(f"Saved checkpoint {checkpoint.checkpoint_id} to memory")
return checkpoint.checkpoint_id
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Load a checkpoint by ID."""
checkpoint = self._checkpoints.get(checkpoint_id)
if checkpoint:
logger.debug(f"Loaded checkpoint {checkpoint_id} from memory")
return checkpoint
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
if workflow_id is None:
return list(self._checkpoints.keys())
return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
if workflow_id is None:
return list(self._checkpoints.values())
return [cp for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
"""Delete a checkpoint by ID."""
if checkpoint_id in self._checkpoints:
del self._checkpoints[checkpoint_id]
logger.debug(f"Deleted checkpoint {checkpoint_id} from memory")
return True
return False
class FileCheckpointStorage:
"""File-based checkpoint storage for persistence."""
def __init__(self, storage_path: str | Path):
"""Initialize the file storage."""
self.storage_path = Path(storage_path)
self.storage_path.mkdir(parents=True, exist_ok=True)
logger.info(f"Initialized file checkpoint storage at {self.storage_path}")
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
"""Save a checkpoint and return its ID."""
file_path = self.storage_path / f"{checkpoint.checkpoint_id}.json"
checkpoint_dict = asdict(checkpoint)
def _write_atomic() -> None:
tmp_path = file_path.with_suffix(".json.tmp")
with open(tmp_path, "w") as f:
json.dump(checkpoint_dict, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, file_path)
await asyncio.to_thread(_write_atomic)
logger.info(f"Saved checkpoint {checkpoint.checkpoint_id} to {file_path}")
return checkpoint.checkpoint_id
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Load a checkpoint by ID."""
file_path = self.storage_path / f"{checkpoint_id}.json"
if not file_path.exists():
return None
def _read() -> dict[str, Any]:
with open(file_path) as f:
return json.load(f) # type: ignore[no-any-return]
checkpoint_dict = await asyncio.to_thread(_read)
checkpoint = WorkflowCheckpoint(**checkpoint_dict)
logger.info(f"Loaded checkpoint {checkpoint_id} from {file_path}")
return checkpoint
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
def _list_ids() -> list[str]:
checkpoint_ids: list[str] = []
for file_path in self.storage_path.glob("*.json"):
try:
with open(file_path) as f:
data = json.load(f)
if workflow_id is None or data.get("workflow_id") == workflow_id:
checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
except Exception as e:
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
return checkpoint_ids
return await asyncio.to_thread(_list_ids)
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
def _list_checkpoints() -> list[WorkflowCheckpoint]:
checkpoints: list[WorkflowCheckpoint] = []
for file_path in self.storage_path.glob("*.json"):
try:
with open(file_path) as f:
data = json.load(f)
if workflow_id is None or data.get("workflow_id") == workflow_id:
checkpoints.append(WorkflowCheckpoint.from_dict(data))
except Exception as e:
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
return checkpoints
return await asyncio.to_thread(_list_checkpoints)
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
"""Delete a checkpoint by ID."""
file_path = self.storage_path / f"{checkpoint_id}.json"
def _delete() -> bool:
if file_path.exists():
file_path.unlink()
logger.info(f"Deleted checkpoint {checkpoint_id} from {file_path}")
return True
return False
return await asyncio.to_thread(_delete)
@@ -0,0 +1,324 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
import logging
from collections.abc import Callable, Sequence
from typing import Any
from typing_extensions import Never
from agent_framework import AgentProtocol, ChatMessage, Role
from ._checkpoint import CheckpointStorage
from ._executor import AgentExecutorRequest, AgentExecutorResponse, Executor, handler
from ._workflow import Workflow, WorkflowBuilder
from ._workflow_context import WorkflowContext
logger = logging.getLogger(__name__)
"""Concurrent builder for agent-only fan-out/fan-in workflows.
This module provides a high-level, agent-focused API to quickly assemble a
parallel workflow with:
- a default dispatcher that broadcasts the input to all agent participants
- a default aggregator that combines all agent conversations and completes the workflow
Notes:
- Participants should be AgentProtocol instances or Executors.
- A custom aggregator can be provided as:
- an Executor instance (it should handle list[AgentExecutorResponse],
yield output), or
- a callback function with signature:
def cb(results: list[AgentExecutorResponse]) -> Any | None
def cb(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None
The callback is wrapped in _CallbackAggregator.
If the callback returns a non-None value, _CallbackAggregator yields that as output.
If it returns None, the callback may have already yielded an output via ctx, so no further action is taken.
"""
class _DispatchToAllParticipants(Executor):
"""Broadcasts input to all downstream participants (via fan-out edges)."""
@handler
async def from_request(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# No explicit target: edge routing delivers to all connected participants.
await ctx.send_message(request)
@handler
async def from_str(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
request = AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True)
await ctx.send_message(request)
@handler
async def from_message(self, message: ChatMessage, ctx: WorkflowContext[AgentExecutorRequest]) -> None: # type: ignore[name-defined]
request = AgentExecutorRequest(messages=[message], should_respond=True)
await ctx.send_message(request)
@handler
async def from_messages(self, messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorRequest]) -> None: # type: ignore[name-defined]
request = AgentExecutorRequest(messages=list(messages), should_respond=True)
await ctx.send_message(request)
class _AggregateAgentConversations(Executor):
"""Aggregates agent responses and completes with combined ChatMessages.
Emits a list[ChatMessage] shaped as:
[ single_user_prompt?, agent1_final_assistant, agent2_final_assistant, ... ]
- Extracts a single user prompt (first user message seen across results).
- For each result, selects the final assistant message (prefers agent_run_response.messages).
- Avoids duplicating the same user message per agent.
"""
@handler
async def aggregate(
self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, list[ChatMessage]]
) -> None:
if not results:
logger.error("Concurrent aggregator received empty results list")
raise ValueError("Aggregation failed: no results provided")
def _is_role(msg: Any, role: Role) -> bool:
r = getattr(msg, "role", None)
if r is None:
return False
# Normalize both r and role to lowercase strings for comparison
r_str = str(r).lower() if isinstance(r, str) or hasattr(r, "__str__") else r
role_str = getattr(role, "value", None)
if role_str is None:
role_str = str(role)
role_str = role_str.lower()
return r_str == role_str
prompt_message: ChatMessage | None = None
assistant_replies: list[ChatMessage] = []
for r in results:
resp_messages = list(getattr(r.agent_run_response, "messages", []) or [])
conv = r.full_conversation if r.full_conversation is not None else resp_messages
logger.debug(
f"Aggregating executor {getattr(r, 'executor_id', '<unknown>')}: "
f"{len(resp_messages)} response msgs, {len(conv)} conversation msgs"
)
# Capture a single user prompt (first encountered across any conversation)
if prompt_message is None:
found_user = next((m for m in conv if _is_role(m, Role.USER)), None)
if found_user is not None:
prompt_message = found_user
# Pick the final assistant message from the response; fallback to conversation search
final_assistant = next((m for m in reversed(resp_messages) if _is_role(m, Role.ASSISTANT)), None)
if final_assistant is None:
final_assistant = next((m for m in reversed(conv) if _is_role(m, Role.ASSISTANT)), None)
if final_assistant is not None:
assistant_replies.append(final_assistant)
else:
logger.warning(
f"No assistant reply found for executor {getattr(r, 'executor_id', '<unknown>')}; skipping"
)
if not assistant_replies:
logger.error(f"Aggregation failed: no assistant replies found across {len(results)} results")
raise RuntimeError("Aggregation failed: no assistant replies found")
output: list[ChatMessage] = []
if prompt_message is not None:
output.append(prompt_message)
else:
logger.warning("No user prompt found in any conversation; emitting assistants only")
output.extend(assistant_replies)
await ctx.yield_output(output)
class _CallbackAggregator(Executor):
"""Wraps a Python callback as an aggregator.
Accepts either an async or sync callback with one of the signatures:
- (results: list[AgentExecutorResponse]) -> Any | None
- (results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None
Notes:
- Async callbacks are awaited directly.
- Sync callbacks are executed via asyncio.to_thread to avoid blocking the event loop.
- If the callback returns a non-None value, it is yielded as an output.
"""
def __init__(self, callback: Callable[..., Any], id: str | None = None) -> None:
derived_id = getattr(callback, "__name__", "") or ""
if not derived_id or derived_id == "<lambda>":
derived_id = f"{type(self).__name__}_unnamed"
super().__init__(id or derived_id)
self._callback = callback
self._param_count = len(inspect.signature(callback).parameters)
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, Any]) -> None:
# Call according to provided signature, always non-blocking for sync callbacks
if self._param_count >= 2:
if inspect.iscoroutinefunction(self._callback):
ret = await self._callback(results, ctx) # type: ignore[misc]
else:
ret = await asyncio.to_thread(self._callback, results, ctx)
else:
if inspect.iscoroutinefunction(self._callback):
ret = await self._callback(results) # type: ignore[misc]
else:
ret = await asyncio.to_thread(self._callback, results)
# If the callback returned a value, finalize the workflow with it
if ret is not None:
await ctx.yield_output(ret)
class ConcurrentBuilder:
r"""High-level builder for concurrent agent workflows.
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor.
- `build()` wires: dispatcher -> fan-out -> participants -> fan-in -> aggregator.
- `with_custom_aggregator(...)` overrides the default aggregator with an Executor or callback.
Usage:
```python
from agent_framework import ConcurrentBuilder
# Minimal: use default aggregator (returns list[ChatMessage])
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).build()
# Custom aggregator via callback (sync or async). The callback receives
# list[AgentExecutorResponse] and its return value becomes the workflow's output.
def summarize(results):
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_custom_aggregator(summarize).build()
# Enable checkpoint persistence so runs can resume
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_checkpointing(storage).build()
```
"""
def __init__(self) -> None:
self._participants: list[AgentProtocol | Executor] = []
self._aggregator: Executor | None = None
self._checkpoint_storage: CheckpointStorage | None = None
def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "ConcurrentBuilder":
r"""Define the parallel participants for this concurrent workflow.
Accepts AgentProtocol instances (e.g., created by a chat client) or Executor
instances. Each participant is wired as a parallel branch using fan-out edges
from an internal dispatcher.
Raises:
ValueError: if `participants` is empty or contains duplicates
TypeError: if any entry is not AgentProtocol or Executor
Example:
```python
wf = ConcurrentBuilder().participants([researcher_agent, marketer_agent, legal_agent]).build()
# Mixing agent(s) and executor(s) is supported
wf2 = ConcurrentBuilder().participants([researcher_agent, my_custom_executor]).build()
```
"""
if not participants:
raise ValueError("participants cannot be empty")
# Defensive duplicate detection
seen_agent_ids: set[int] = set()
seen_executor_ids: set[str] = set()
for p in participants:
if isinstance(p, Executor):
if p.id in seen_executor_ids:
raise ValueError(f"Duplicate executor participant detected: id '{p.id}'")
seen_executor_ids.add(p.id)
elif isinstance(p, AgentProtocol):
pid = id(p)
if pid in seen_agent_ids:
raise ValueError("Duplicate agent participant detected (same agent instance provided twice)")
seen_agent_ids.add(pid)
else:
raise TypeError(f"participants must be AgentProtocol or Executor instances; got {type(p).__name__}")
self._participants = list(participants)
return self
def with_aggregator(self, aggregator: Executor | Callable[..., Any]) -> "ConcurrentBuilder":
r"""Override the default aggregator with an Executor or a callback.
- Executor: must handle `list[AgentExecutorResponse]` and
yield output using `ctx.yield_output(...)` and add a
output and the workflow becomes idle.
- Callback: sync or async callable with one of the signatures:
`(results: list[AgentExecutorResponse]) -> Any | None` or
`(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None`.
If the callback returns a non-None value, it becomes the workflow's output.
Example:
```python
# Callback-based aggregator (string result)
async def summarize(results):
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
wf = ConcurrentBuilder().participants([a1, a2, a3]).with_custom_aggregator(summarize).build()
```
"""
if isinstance(aggregator, Executor):
self._aggregator = aggregator
elif callable(aggregator):
self._aggregator = _CallbackAggregator(aggregator)
else:
raise TypeError("aggregator must be an Executor or a callable")
return self
def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "ConcurrentBuilder":
"""Enable checkpoint persistence using the provided storage backend."""
self._checkpoint_storage = checkpoint_storage
return self
def build(self) -> Workflow:
r"""Build and validate the concurrent workflow.
Wiring pattern:
- Dispatcher (internal) fans out the input to all `participants`
- Fan-in aggregator collects `AgentExecutorResponse` objects
- Aggregator yields output and the workflow becomes idle. The output is either:
- list[ChatMessage] (default aggregator: one user + one assistant per agent)
- custom payload from the provided callback/executor
Returns:
Workflow: a ready-to-run workflow instance
Raises:
ValueError: if no participants were defined
Example:
```python
workflow = ConcurrentBuilder().participants([agent1, agent2]).build()
```
"""
if not self._participants:
raise ValueError("No participants provided. Call .participants([...]) first.")
dispatcher = _DispatchToAllParticipants(id="dispatcher")
aggregator = self._aggregator or _AggregateAgentConversations(id="aggregator")
builder = WorkflowBuilder()
builder.set_start_executor(dispatcher)
builder.add_fan_out_edges(dispatcher, list(self._participants))
builder.add_fan_in_edges(list(self._participants), aggregator)
if self._checkpoint_storage is not None:
builder = builder.with_checkpointing(self._checkpoint_storage)
return builder.build()
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
DEFAULT_MAX_ITERATIONS = 100 # Default maximum iterations for workflow execution.
@@ -0,0 +1,867 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import uuid
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from typing import Any, ClassVar
from ._executor import Executor
from ._model_utils import DictConvertible, encode_value
logger = logging.getLogger(__name__)
def _extract_function_name(func: Callable[..., Any]) -> str:
"""Map a Python callable to a concise, human-focused identifier.
The workflow graph persists references to callables by recording only an
identifier. This helper inspects standard callable metadata and picks a
stable value so that serialized representations remain intelligible when
they are later rendered in logs or reconstructed during deserialization.
Example:
```python
def threshold(value: float) -> bool:
return value > 0.5
assert _extract_function_name(threshold) == "threshold"
```
"""
if hasattr(func, "__name__"):
name = func.__name__
return name if name != "<lambda>" else "<lambda>"
return "<callable>"
def _missing_callable(name: str) -> Callable[..., Any]:
"""Create a defensive placeholder for callables that cannot be restored.
When a workflow is deserialized in an environment that lacks the original
Python callable, we install a proxy that fails loudly. Surfacing the error
at invocation time preserves a clean separation between I/O concerns and
runtime execution, while making it obvious which callable needs to be
re-registered.
Example:
```python
guard = _missing_callable("transform_price")
try:
guard()
except RuntimeError as exc:
assert "transform_price" in str(exc)
```
"""
def _raise(*_: Any, **__: Any) -> Any:
raise RuntimeError(f"Callable '{name}' is unavailable after serialization")
return _raise
@dataclass(init=False)
class Edge(DictConvertible):
"""Model a directed, optionally-conditional hand-off between two executors.
Each `Edge` captures the minimal metadata required to move a message from
one executor to another inside the workflow graph. It optionally embeds a
boolean predicate that decides if the edge should be taken at runtime. By
serialising the edge down to primitives we can reconstruct the topology of
a workflow irrespective of the original Python process.
Example:
```python
edge = Edge(source_id="ingest", target_id="score", condition=lambda payload: payload["ready"])
assert edge.should_route({"ready": True}) is True
assert edge.should_route({"ready": False}) is False
```
"""
ID_SEPARATOR: ClassVar[str] = "->"
source_id: str
target_id: str
condition_name: str | None
_condition: Callable[[Any], bool] | None = field(default=None, repr=False, compare=False)
def __init__(
self,
source_id: str,
target_id: str,
condition: Callable[[Any], bool] | None = None,
*,
condition_name: str | None = None,
) -> None:
"""Initialize a fully-specified edge between two workflow executors.
Parameters
----------
source_id:
Canonical identifier of the upstream executor instance.
target_id:
Canonical identifier of the downstream executor instance.
condition:
Optional predicate that receives the message payload and returns
`True` when the edge should be traversed. When omitted, the edge is
considered unconditionally active.
condition_name:
Optional override that pins a human-friendly name for the condition
when the callable cannot be introspected (for example after
deserialization).
Example:
```python
edge = Edge("fetch", "parse", condition=lambda data: data.is_valid)
assert edge.source_id == "fetch"
assert edge.target_id == "parse"
```
"""
if not source_id:
raise ValueError("Edge source_id must be a non-empty string")
if not target_id:
raise ValueError("Edge target_id must be a non-empty string")
self.source_id = source_id
self.target_id = target_id
self._condition = condition
self.condition_name = _extract_function_name(condition) if condition is not None else condition_name
@property
def id(self) -> str:
"""Return the stable identifier used to reference this edge.
The identifier combines the source and target executor identifiers with
a deterministic separator. This allows other graph structures such as
adjacency lists or visualisations to refer to an edge without carrying
the full object.
Example:
```python
edge = Edge("reader", "writer")
assert edge.id == "reader->writer"
```
"""
return f"{self.source_id}{self.ID_SEPARATOR}{self.target_id}"
def should_route(self, data: Any) -> bool:
"""Evaluate the edge predicate against an incoming payload.
When the edge was defined without an explicit predicate the method
returns `True`, signalling an unconditional routing rule. Otherwise the
user-supplied callable decides whether the message should proceed along
this edge. Any exception raised by the callable is deliberately allowed
to surface to the caller to avoid masking logic bugs.
Example:
```python
edge = Edge("stage1", "stage2", condition=lambda payload: payload["score"] > 0.8)
assert edge.should_route({"score": 0.9}) is True
assert edge.should_route({"score": 0.4}) is False
```
"""
if self._condition is None:
return True
return self._condition(data)
def to_dict(self) -> dict[str, Any]:
"""Produce a JSON-serialisable view of the edge metadata.
The representation includes the source and target executor identifiers
plus the condition name when it is known. Serialisation intentionally
omits the live callable to keep payloads transport-friendly.
Example:
```python
edge = Edge("reader", "writer", condition=lambda payload: payload["ok"])
snapshot = edge.to_dict()
assert snapshot == {"source_id": "reader", "target_id": "writer", "condition_name": "<lambda>"}
```
"""
payload = {"source_id": self.source_id, "target_id": self.target_id}
if self.condition_name is not None:
payload["condition_name"] = self.condition_name
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Edge":
"""Reconstruct an `Edge` from its serialised dictionary form.
The deserialised edge will lack the executable predicate because we do
not attempt to hydrate Python callables from storage. Instead, the
stored `condition_name` is preserved so that downstream consumers can
detect missing callables and re-register them where appropriate.
Example:
```python
payload = {"source_id": "reader", "target_id": "writer", "condition_name": "is_ready"}
edge = Edge.from_dict(payload)
assert edge.source_id == "reader"
assert edge.condition_name == "is_ready"
```
"""
return cls(
source_id=data["source_id"],
target_id=data["target_id"],
condition=None,
condition_name=data.get("condition_name"),
)
@dataclass
class Case:
"""Runtime wrapper combining a switch-case predicate with its target.
Each `Case` couples a boolean predicate with the executor that should
handle the message when the predicate evaluates to `True`. The runtime
keeps this lightweight container separate from the serialisable
`SwitchCaseEdgeGroupCase` so that execution can operate with live callables
without polluting persisted state.
Example:
```python
class JsonExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="json", defer_discovery=True)
processor = JsonExecutor()
case = Case(condition=lambda payload: payload["kind"] == "json", target=processor)
assert case.target.id == "json"
```
"""
condition: Callable[[Any], bool]
target: Executor
@dataclass
class Default:
"""Runtime representation of the default branch in a switch-case group.
The default branch is invoked only when no other case predicates match. In
practice it is guaranteed to exist so that routing never produces an empty
target.
Example:
```python
class DeadLetterExecutor(Executor):
def __init__(self) -> None:
super().__init__(id="dead_letter", defer_discovery=True)
fallback = Default(target=DeadLetterExecutor())
assert fallback.target.id == "dead_letter"
```
"""
target: Executor
@dataclass(init=False)
class EdgeGroup(DictConvertible):
"""Bundle edges that share a common routing semantics under a single id.
The workflow runtime manipulates `EdgeGroup` instances rather than raw
edges so it can reason about higher-order routing behaviours such as
fan-out, fan-in, switch-case, and other graph patterns. The base class stores the
identifying information and handles serialisation duties so specialised
groups need only maintain their additional state.
Example:
```python
group = EdgeGroup([Edge("source", "sink")])
assert group.source_executor_ids == ["source"]
```
"""
id: str
type: str
edges: list[Edge]
from builtins import type as builtin_type
_TYPE_REGISTRY: ClassVar[dict[str, builtin_type["EdgeGroup"]]] = {}
def __init__(
self,
edges: Sequence[Edge] | None = None,
*,
id: str | None = None,
type: str | None = None,
) -> None:
"""Construct an edge group shell around a set of `Edge` instances.
Parameters
----------
edges:
Sequence of edges that participate in this group. When omitted we
start from an empty list so subclasses can append later.
id:
Stable identifier for the group. Defaults to a random UUID so
serialised graphs remain uniquely addressable.
type:
Logical discriminator used to recover the appropriate subclass when
de-serialising.
Example:
```python
edges = [Edge("validate", "persist")]
group = EdgeGroup(edges, id="stage", type="Custom")
assert group.to_dict()["type"] == "Custom"
```
"""
self.id = id or f"{self.__class__.__name__}/{uuid.uuid4()}"
self.type = type or self.__class__.__name__
self.edges = list(edges) if edges is not None else []
@property
def source_executor_ids(self) -> list[str]:
"""Return the deduplicated list of upstream executor ids.
The property preserves order-of-first-appearance so the caller can rely
on deterministic iteration when reconstructing graph topology.
Example:
```python
group = EdgeGroup([Edge("read", "write"), Edge("read", "archive")])
assert group.source_executor_ids == ["read"]
```
"""
return list(dict.fromkeys(edge.source_id for edge in self.edges))
@property
def target_executor_ids(self) -> list[str]:
"""Return the ordered, deduplicated list of downstream executor ids.
Example:
```python
group = EdgeGroup([Edge("read", "write"), Edge("read", "archive")])
assert group.target_executor_ids == ["write", "archive"]
```
"""
return list(dict.fromkeys(edge.target_id for edge in self.edges))
def to_dict(self) -> dict[str, Any]:
"""Serialise the group metadata and contained edges into primitives.
The payload captures each edge through its own `to_dict` call, enabling
round-tripping through formats such as JSON without leaking Python
objects.
Example:
```python
group = EdgeGroup([Edge("read", "write")])
snapshot = group.to_dict()
assert snapshot["edges"][0]["source_id"] == "read"
```
"""
return {
"id": self.id,
"type": self.type,
"edges": [edge.to_dict() for edge in self.edges],
}
@classmethod
def register(cls, subclass: builtin_type["EdgeGroup"]) -> builtin_type["EdgeGroup"]:
"""Register a subclass so deserialisation can recover the right type.
Registration is typically performed via the decorator syntax applied to
each concrete edge group. The registry stores classes by their
`__name__`, which must therefore remain stable across versions when
persisted workflows are in circulation.
Example:
```python
@EdgeGroup.register
class CustomGroup(EdgeGroup):
pass
assert EdgeGroup._TYPE_REGISTRY["CustomGroup"] is CustomGroup
```
"""
cls._TYPE_REGISTRY[subclass.__name__] = subclass
return subclass
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EdgeGroup":
"""Hydrate the correct `EdgeGroup` subclass from serialised state.
The method inspects the `type` field, allocates the corresponding class
without executing subclass `__init__`, and then manually restores any
subtype-specific attributes. This keeps deserialisation deterministic
even for complex group types that configure additional runtime
callables.
Example:
```python
payload = {"type": "EdgeGroup", "edges": [{"source_id": "a", "target_id": "b"}]}
group = EdgeGroup.from_dict(payload)
assert isinstance(group, EdgeGroup)
```
"""
group_type = data.get("type", "EdgeGroup")
target_cls = cls._TYPE_REGISTRY.get(group_type, EdgeGroup)
edges = [Edge.from_dict(entry) for entry in data.get("edges", [])]
obj = target_cls.__new__(target_cls) # type: ignore[misc]
EdgeGroup.__init__(obj, edges=edges, id=data.get("id"), type=group_type)
# Handle FanOutEdgeGroup-specific attributes
if isinstance(obj, FanOutEdgeGroup):
obj.selection_func_name = data.get("selection_func_name") # type: ignore[attr-defined]
obj._selection_func = ( # type: ignore[attr-defined]
None
if obj.selection_func_name is None # type: ignore[attr-defined]
else _missing_callable(obj.selection_func_name) # type: ignore[attr-defined]
)
obj._target_ids = [edge.target_id for edge in obj.edges] # type: ignore[attr-defined]
# Handle SwitchCaseEdgeGroup-specific attributes
if isinstance(obj, SwitchCaseEdgeGroup):
cases_payload = data.get("cases", [])
restored_cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = []
for case_data in cases_payload:
case_type = case_data.get("type")
if case_type == "Default":
restored_cases.append(SwitchCaseEdgeGroupDefault.from_dict(case_data))
else:
restored_cases.append(SwitchCaseEdgeGroupCase.from_dict(case_data))
obj.cases = restored_cases # type: ignore[attr-defined]
obj._selection_func = _missing_callable("switch_case_selection") # type: ignore[attr-defined]
return obj
@EdgeGroup.register
@dataclass(init=False)
class SingleEdgeGroup(EdgeGroup):
"""Convenience wrapper for a solitary edge, keeping the group API uniform."""
def __init__(
self,
source_id: str,
target_id: str,
condition: Callable[[Any], bool] | None = None,
*,
id: str | None = None,
) -> None:
"""Create a one-to-one edge group between two executors.
Example:
```python
group = SingleEdgeGroup("ingest", "validate")
assert group.edges[0].source_id == "ingest"
```
"""
edge = Edge(source_id=source_id, target_id=target_id, condition=condition)
super().__init__([edge], id=id, type=self.__class__.__name__)
@EdgeGroup.register
@dataclass(init=False)
class FanOutEdgeGroup(EdgeGroup):
"""Represent a broadcast-style edge group with optional selection logic.
A fan-out forwards a message produced by a single source executor to one
or more downstream executors. At runtime we may further narrow the targets
by executing a `selection_func` that inspects the payload and returns the
subset of ids that should receive the message.
"""
selection_func_name: str | None
_selection_func: Callable[[Any, list[str]], list[str]] | None
_target_ids: list[str]
def __init__(
self,
source_id: str,
target_ids: Sequence[str],
selection_func: Callable[[Any, list[str]], list[str]] | None = None,
*,
selection_func_name: str | None = None,
id: str | None = None,
) -> None:
"""Create a fan-out mapping from a single source to many targets.
Parameters
----------
source_id:
Identifier of the upstream executor broadcasting the message.
target_ids:
Ordered set of downstream executor identifiers that may receive the
message. At least two targets are required to preserve the fan-out
semantics.
selection_func:
Optional callable that returns the subset of `target_ids` that
should be active for a given payload. The callable receives the
original message plus a copy of all configured target ids.
selection_func_name:
Static identifier used when persisting the fan-out. Needed when the
callable cannot be introspected or is unavailable during
deserialisation.
id:
Stable identifier for the group; defaults to an autogenerated UUID.
Example:
```python
def choose_targets(message: dict[str, Any], available: list[str]) -> list[str]:
return [target for target in available if message.get(target)]
group = FanOutEdgeGroup("sensor", ["db", "cache"], selection_func=choose_targets)
assert group.selection_func is choose_targets
```
"""
if len(target_ids) <= 1:
raise ValueError("FanOutEdgeGroup must contain at least two targets.")
edges = [Edge(source_id=source_id, target_id=target) for target in target_ids]
super().__init__(edges, id=id, type=self.__class__.__name__)
self._target_ids = list(target_ids)
self._selection_func = selection_func
self.selection_func_name = (
_extract_function_name(selection_func) if selection_func is not None else selection_func_name
)
@property
def target_ids(self) -> list[str]:
"""Return a shallow copy of the configured downstream executor ids.
The list is defensively copied to prevent callers from mutating the
internal state while still providing deterministic ordering.
Example:
```python
group = FanOutEdgeGroup("node", ["alpha", "beta"])
assert group.target_ids == ["alpha", "beta"]
```
"""
return list(self._target_ids)
@property
def selection_func(self) -> Callable[[Any, list[str]], list[str]] | None:
"""Expose the runtime callable used to select active fan-out targets.
When no selection function was supplied the property returns `None`,
signalling that all targets must receive the payload.
Example:
```python
group = FanOutEdgeGroup("source", ["x", "y"], selection_func=None)
assert group.selection_func is None
```
"""
return self._selection_func
def to_dict(self) -> dict[str, Any]:
"""Serialise the fan-out group while preserving selection metadata.
In addition to the base `EdgeGroup` payload we embed the human-friendly
name of the selection function. The callable itself is not persisted.
Example:
```python
group = FanOutEdgeGroup("source", ["a", "b"], selection_func=lambda *_: ["a"])
snapshot = group.to_dict()
assert snapshot["selection_func_name"] == "<lambda>"
```
"""
payload = super().to_dict()
payload["selection_func_name"] = self.selection_func_name
return payload
@EdgeGroup.register
@dataclass(init=False)
class FanInEdgeGroup(EdgeGroup):
"""Represent a converging set of edges that feed a single downstream executor.
Fan-in groups are typically used when multiple upstream stages independently
produce messages that should all arrive at the same downstream processor.
"""
def __init__(self, source_ids: Sequence[str], target_id: str, *, id: str | None = None) -> None:
"""Build a fan-in mapping that merges several sources into one target.
Parameters
----------
source_ids:
Sequence of upstream executor identifiers contributing messages.
target_id:
Downstream executor that receives every message emitted by the
sources.
id:
Optional explicit identifier for the edge group.
Example:
```python
group = FanInEdgeGroup(["parser", "enricher"], target_id="writer")
assert group.to_dict()["edges"][0]["target_id"] == "writer"
```
"""
if len(source_ids) <= 1:
raise ValueError("FanInEdgeGroup must contain at least two sources.")
edges = [Edge(source_id=source, target_id=target_id) for source in source_ids]
super().__init__(edges, id=id, type=self.__class__.__name__)
@dataclass(init=False)
class SwitchCaseEdgeGroupCase(DictConvertible):
"""Persistable description of a single conditional branch in a switch-case.
Unlike the runtime `Case` object this serialisable variant stores only the
target identifier and a descriptive name for the predicate. When the
underlying callable is unavailable during deserialisation we substitute a
proxy placeholder that fails loudly, ensuring the missing dependency is
immediately visible.
"""
target_id: str
condition_name: str | None
type: str
_condition: Callable[[Any], bool] = field(repr=False, compare=False)
def __init__(
self,
condition: Callable[[Any], bool] | None,
target_id: str,
*,
condition_name: str | None = None,
) -> None:
"""Record the routing metadata for a conditional case branch.
Parameters
----------
condition:
Optional live predicate. When omitted we fall back to a placeholder
that raises at runtime to highlight missing registrations.
target_id:
Identifier of the executor that should handle messages when the
predicate succeeds.
condition_name:
Human-friendly label for the predicate used for diagnostics and
on-disk persistence.
Example:
```python
case = SwitchCaseEdgeGroupCase(lambda payload: payload["type"] == "csv", target_id="csv_handler")
assert case.condition_name == "<lambda>"
```
"""
if not target_id:
raise ValueError("SwitchCaseEdgeGroupCase requires a target_id")
self.target_id = target_id
self.type = "Case"
if condition is not None:
self._condition = condition
self.condition_name = _extract_function_name(condition)
else:
safe_name = condition_name or "<missing_condition>"
self._condition = _missing_callable(safe_name)
self.condition_name = condition_name
@property
def condition(self) -> Callable[[Any], bool]:
"""Return the predicate associated with this case.
The placeholder installed during deserialisation raises a
`RuntimeError` when invoked so that workflow authors are forced to
provide the missing callable explicitly.
Example:
```python
case = SwitchCaseEdgeGroupCase(None, target_id="missing", condition_name="needs_registration")
guard = case.condition
try:
guard({})
except RuntimeError:
pass
```
"""
return self._condition
def to_dict(self) -> dict[str, Any]:
"""Serialise the case metadata without the executable predicate.
Example:
```python
case = SwitchCaseEdgeGroupCase(lambda _: True, target_id="handler")
assert case.to_dict()["target_id"] == "handler"
```
"""
payload = {"target_id": self.target_id, "type": self.type}
if self.condition_name is not None:
payload["condition_name"] = self.condition_name
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SwitchCaseEdgeGroupCase":
"""Instantiate a case from its serialised dictionary payload.
Example:
```python
payload = {"target_id": "handler", "condition_name": "is_ready"}
case = SwitchCaseEdgeGroupCase.from_dict(payload)
assert case.target_id == "handler"
```
"""
return cls(
condition=None,
target_id=data["target_id"],
condition_name=data.get("condition_name"),
)
@dataclass(init=False)
class SwitchCaseEdgeGroupDefault(DictConvertible):
"""Persistable descriptor for the fallback branch of a switch-case group.
The default branch is guaranteed to exist and is invoked when every other
case predicate fails to match the payload.
"""
target_id: str
type: str
def __init__(self, target_id: str) -> None:
"""Point the default branch toward the given executor identifier.
Example:
```python
fallback = SwitchCaseEdgeGroupDefault(target_id="dead_letter")
assert fallback.target_id == "dead_letter"
```
"""
if not target_id:
raise ValueError("SwitchCaseEdgeGroupDefault requires a target_id")
self.target_id = target_id
self.type = "Default"
def to_dict(self) -> dict[str, Any]:
"""Serialise the default branch metadata for persistence or logging.
Example:
```python
fallback = SwitchCaseEdgeGroupDefault("dead_letter")
assert fallback.to_dict()["type"] == "Default"
```
"""
return {"target_id": self.target_id, "type": self.type}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SwitchCaseEdgeGroupDefault":
"""Recreate the default branch from its persisted form.
Example:
```python
payload = {"target_id": "dead_letter", "type": "Default"}
fallback = SwitchCaseEdgeGroupDefault.from_dict(payload)
assert fallback.target_id == "dead_letter"
```
"""
return cls(target_id=data["target_id"])
@EdgeGroup.register
@dataclass(init=False)
class SwitchCaseEdgeGroup(FanOutEdgeGroup):
"""Fan-out variant that mimics a traditional switch/case control flow.
Each case inspects the message payload and decides whether it should handle
the message. Exactly one case-or the default branch-returns a target at
runtime, preserving single-dispatch semantics.
"""
cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault]
def __init__(
self,
source_id: str,
cases: Sequence[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault],
*,
id: str | None = None,
) -> None:
"""Configure a switch/case routing structure for a single source executor.
Parameters
----------
source_id:
Identifier of the executor producing the message to be routed.
cases:
Ordered sequence of case descriptors concluding with a
`SwitchCaseEdgeGroupDefault`. Ordering matters because the runtime
evaluates each branch sequentially until one matches.
id:
Optional explicit identifier for the edge group.
Example:
```python
cases = [
SwitchCaseEdgeGroupCase(lambda payload: payload["kind"] == "csv", target_id="process_csv"),
SwitchCaseEdgeGroupDefault(target_id="process_default"),
]
group = SwitchCaseEdgeGroup("router", cases)
encoded = group.to_dict()
assert encoded["cases"][0]["type"] == "Case"
```
"""
if len(cases) < 2:
raise ValueError("SwitchCaseEdgeGroup must contain at least two cases (including the default case).")
default_cases = [case for case in cases if isinstance(case, SwitchCaseEdgeGroupDefault)]
if len(default_cases) != 1:
raise ValueError("SwitchCaseEdgeGroup must contain exactly one default case.")
if not isinstance(cases[-1], SwitchCaseEdgeGroupDefault):
logger.warning(
"Default case in the switch-case edge group is not the last case. "
"This may result in unexpected behavior."
)
def selection_func(message: Any, targets: list[str]) -> list[str]:
for case in cases:
if isinstance(case, SwitchCaseEdgeGroupDefault):
return [case.target_id]
try:
if case.condition(message):
return [case.target_id]
except Exception as exc: # pragma: no cover - defensive logging
logger.warning("Error evaluating condition for case %s: %s", case.target_id, exc)
raise RuntimeError("No matching case found in SwitchCaseEdgeGroup")
target_ids = [case.target_id for case in cases]
# Call FanOutEdgeGroup constructor directly to avoid type checking issues
edges = [Edge(source_id=source_id, target_id=target) for target in target_ids]
EdgeGroup.__init__(self, edges, id=id, type=self.__class__.__name__)
# Initialize FanOutEdgeGroup-specific attributes
self._target_ids = list(target_ids) # type: ignore[attr-defined]
self._selection_func = selection_func # type: ignore[attr-defined]
self.selection_func_name = None # type: ignore[attr-defined]
self.cases = list(cases)
def to_dict(self) -> dict[str, Any]:
"""Serialise the switch-case group, capturing all case descriptors.
Each case is converted using `encode_value` to respect dataclass
semantics as well as any nested serialisable structures.
Example:
```python
group = SwitchCaseEdgeGroup(
"router",
[
SwitchCaseEdgeGroupCase(lambda _: True, target_id="handler"),
SwitchCaseEdgeGroupDefault(target_id="fallback"),
],
)
snapshot = group.to_dict()
assert len(snapshot["cases"]) == 2
```
"""
payload = super().to_dict()
payload["cases"] = [encode_value(case) for case in self.cases]
return payload
@@ -0,0 +1,385 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Callable
from typing import Any, cast
from ..observability import EdgeGroupDeliveryStatus, OtelAttr, create_edge_group_processing_span
from ._edge import Edge, EdgeGroup, FanInEdgeGroup, FanOutEdgeGroup, SingleEdgeGroup, SwitchCaseEdgeGroup
from ._executor import Executor
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
logger = logging.getLogger(__name__)
class EdgeRunner(ABC):
"""Abstract base class for edge runners that handle message delivery."""
def __init__(self, edge_group: EdgeGroup, executors: dict[str, Executor]) -> None:
"""Initialize the edge runner with an edge group and executor map.
Args:
edge_group: The edge group to run.
executors: Map of executor IDs to executor instances.
"""
self._edge_group = edge_group
self._executors = executors
@abstractmethod
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
"""Send a message through the edge group.
Args:
message: The message to send.
shared_state: The shared state to use for holding data.
ctx: The context for the runner.
Returns:
bool: True if the message was processed successfully,
False if the target executor cannot handle the message.
"""
raise NotImplementedError
def _can_handle(self, executor_id: str, message_data: Any) -> bool:
"""Check if an executor can handle the given message data."""
if executor_id not in self._executors:
return False
return self._executors[executor_id].can_handle(message_data)
async def _execute_on_target(
self,
target_id: str,
source_ids: list[str],
message: Message,
shared_state: SharedState,
ctx: RunnerContext,
) -> None:
"""Execute a message on a target executor with trace context."""
if target_id not in self._executors:
raise RuntimeError(f"Target executor {target_id} not found.")
target_executor = self._executors[target_id]
# Execute with trace context parameters
await target_executor.execute(
message.data,
source_ids, # source_executor_ids
shared_state, # shared_state
ctx, # runner_context
trace_contexts=message.trace_contexts, # Pass trace contexts
source_span_ids=message.source_span_ids, # Pass source span IDs for linking
)
class SingleEdgeRunner(EdgeRunner):
"""Runner for single edge groups."""
def __init__(self, edge_group: SingleEdgeGroup, executors: dict[str, Executor]) -> None:
super().__init__(edge_group, executors)
self._edge = edge_group.edges[0]
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
"""Send a message through the single edge."""
should_execute = False
target_id = None
source_id = None
with create_edge_group_processing_span(
self._edge_group.__class__.__name__,
edge_group_id=self._edge_group.id,
message_source_id=message.source_id,
message_target_id=message.target_id,
source_trace_contexts=message.trace_contexts,
source_span_ids=message.source_span_ids,
) as span:
try:
if message.target_id and message.target_id != self._edge.target_id:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH.value,
})
return False
if self._can_handle(self._edge.target_id, message.data):
if self._edge.should_route(message.data):
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: True,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value,
})
should_execute = True
target_id = self._edge.target_id
source_id = self._edge.source_id
else:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_CONDITION_FALSE.value,
})
# Return True here because message was processed, just condition failed
return True
else:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value,
})
return False
except Exception as e:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.EXCEPTION.value,
})
raise e
# Execute outside the span
if should_execute and target_id and source_id:
await self._execute_on_target(target_id, [source_id], message, shared_state, ctx)
return True
return False
class FanOutEdgeRunner(EdgeRunner):
"""Runner for fan-out edge groups."""
def __init__(self, edge_group: FanOutEdgeGroup, executors: dict[str, Executor]) -> None:
super().__init__(edge_group, executors)
self._edges = edge_group.edges
self._target_ids = edge_group.target_executor_ids
self._target_map = {edge.target_id: edge for edge in self._edges}
self._selection_func = cast(
Callable[[Any, list[str]], list[str]] | None, getattr(edge_group, "selection_func", None)
)
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
"""Send a message through all edges in the fan-out edge group."""
deliverable_edges = []
single_target_edge = None
# Process routing logic within span
with create_edge_group_processing_span(
self._edge_group.__class__.__name__,
edge_group_id=self._edge_group.id,
message_source_id=message.source_id,
message_target_id=message.target_id,
source_trace_contexts=message.trace_contexts,
source_span_ids=message.source_span_ids,
) as span:
try:
selection_results = (
self._selection_func(message.data, self._target_ids) if self._selection_func else self._target_ids
)
if not self._validate_selection_result(selection_results):
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.EXCEPTION.value,
})
raise RuntimeError(
f"Invalid selection result: {selection_results}. "
f"Expected selections to be a subset of valid target executor IDs: {self._target_ids}."
)
if message.target_id:
# If the target ID is specified and the selection result contains it, send the message to that edge
if message.target_id in selection_results:
edge = self._target_map.get(message.target_id)
if edge and self._can_handle(edge.target_id, message.data):
if edge.should_route(message.data):
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: True,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value,
})
single_target_edge = edge
else:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_CONDITION_FALSE.value, # noqa: E501
})
# For targeted messages with condition failure, return True (message was processed)
return True
else:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value, # noqa: E501
})
# For targeted messages that can't be handled, return False
return False
else:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH.value,
})
# For targeted messages not in selection, return False
return False
else:
# If no target ID, send the message to the selected targets
for target_id in selection_results:
edge = self._target_map[target_id]
if self._can_handle(edge.target_id, message.data) and edge.should_route(message.data):
deliverable_edges.append(edge)
if len(deliverable_edges) > 0:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: True,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value,
})
else:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value,
})
except Exception as e:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.EXCEPTION.value,
})
raise e
# Execute outside the span
if single_target_edge:
await self._execute_on_target(
single_target_edge.target_id, [single_target_edge.source_id], message, shared_state, ctx
)
return True
if deliverable_edges:
async def send_to_edge(edge: Edge) -> bool:
await self._execute_on_target(edge.target_id, [edge.source_id], message, shared_state, ctx)
return True
tasks = [send_to_edge(edge) for edge in deliverable_edges]
results = await asyncio.gather(*tasks)
return any(results)
# If we get here, it's a broadcast message with no deliverable edges
return False
def _validate_selection_result(self, selection_results: list[str]) -> bool:
"""Validate the selection results to ensure all IDs are valid target executor IDs."""
return all(result in self._target_ids for result in selection_results)
class FanInEdgeRunner(EdgeRunner):
"""Runner for fan-in edge groups."""
def __init__(self, edge_group: FanInEdgeGroup, executors: dict[str, Executor]) -> None:
super().__init__(edge_group, executors)
self._edges = edge_group.edges
# Buffer to hold messages before sending them to the target executor
# Key is the source executor ID, value is a list of messages
self._buffer: dict[str, list[Message]] = defaultdict(list)
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
"""Send a message through all edges in the fan-in edge group."""
execution_data: dict[str, Any] | None = None
with create_edge_group_processing_span(
self._edge_group.__class__.__name__,
edge_group_id=self._edge_group.id,
message_source_id=message.source_id,
message_target_id=message.target_id,
source_trace_contexts=message.trace_contexts,
source_span_ids=message.source_span_ids,
) as span:
try:
if message.target_id and message.target_id != self._edges[0].target_id:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TARGET_MISMATCH.value,
})
return False
# Check if target can handle list of message data (fan-in aggregates multiple messages)
if self._can_handle(self._edges[0].target_id, [message.data]):
# If the edge can handle the data, buffer the message
self._buffer[message.source_id].append(message)
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: True,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.BUFFERED.value,
})
else:
# If the edge cannot handle the data, return False
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value,
})
return False
if self._is_ready_to_send():
# If all edges in the group have data, prepare for execution
messages_to_send = [msg for edge in self._edges for msg in self._buffer[edge.source_id]]
self._buffer.clear()
# Send aggregated data to target
aggregated_data = [msg.data for msg in messages_to_send]
# Collect all trace contexts and source span IDs for fan-in linking
trace_contexts = [msg.trace_context for msg in messages_to_send if msg.trace_context]
source_span_ids = [msg.source_span_id for msg in messages_to_send if msg.source_span_id]
# Create a new Message object for the aggregated data
aggregated_message = Message(
data=aggregated_data,
source_id=self._edge_group.__class__.__name__, # This won't be used in self._execute_on_target.
trace_contexts=trace_contexts,
source_span_ids=source_span_ids,
)
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: True,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value,
})
# Store execution data for later
execution_data = {
"target_id": self._edges[0].target_id,
"source_ids": [edge.source_id for edge in self._edges],
"message": aggregated_message,
}
except Exception as e:
span.set_attributes({
OtelAttr.EDGE_GROUP_DELIVERED: False,
OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.EXCEPTION.value,
})
raise e
# Execute outside the span if needed
if execution_data:
await self._execute_on_target(
execution_data["target_id"], execution_data["source_ids"], execution_data["message"], shared_state, ctx
)
return True
return True # Return True for buffered messages (waiting for more)
def _is_ready_to_send(self) -> bool:
"""Check if all edges in the group have data to send."""
return all(self._buffer[edge.source_id] for edge in self._edges)
class SwitchCaseEdgeRunner(FanOutEdgeRunner):
"""Runner for switch-case edge groups (inherits from FanOutEdgeRunner)."""
def __init__(self, edge_group: SwitchCaseEdgeGroup, executors: dict[str, Executor]) -> None:
super().__init__(edge_group, executors)
def create_edge_runner(edge_group: EdgeGroup, executors: dict[str, Executor]) -> EdgeRunner:
"""Factory function to create the appropriate edge runner for an edge group.
Args:
edge_group: The edge group to create a runner for.
executors: Map of executor IDs to executor instances.
Returns:
The appropriate EdgeRunner instance.
"""
if isinstance(edge_group, SingleEdgeGroup):
return SingleEdgeRunner(edge_group, executors)
if isinstance(edge_group, SwitchCaseEdgeGroup):
return SwitchCaseEdgeRunner(edge_group, executors)
if isinstance(edge_group, FanOutEdgeGroup):
return FanOutEdgeRunner(edge_group, executors)
if isinstance(edge_group, FanInEdgeGroup):
return FanInEdgeRunner(edge_group, executors)
raise ValueError(f"Unsupported edge group type: {type(edge_group)}")
@@ -0,0 +1,330 @@
# Copyright (c) Microsoft. All rights reserved.
import traceback as _traceback
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, TypeAlias
from agent_framework import AgentRunResponse, AgentRunResponseUpdate
if TYPE_CHECKING:
from ._executor import RequestInfoMessage
class WorkflowEventSource(str, Enum):
"""Identifies whether a workflow event came from the framework or an executor.
Use `FRAMEWORK` for events emitted by built-in orchestration paths—even when the
code that raises them lives in runner-related modules—and `EXECUTOR` for events
surfaced by developer-provided executor implementations.
"""
FRAMEWORK = "FRAMEWORK" # Framework-owned orchestration, regardless of module location
EXECUTOR = "EXECUTOR" # User-supplied executor code and callbacks
_event_origin_context: ContextVar[WorkflowEventSource] = ContextVar(
"workflow_event_origin", default=WorkflowEventSource.EXECUTOR
)
def _current_event_origin() -> WorkflowEventSource:
"""Return the origin to associate with newly created workflow events."""
return _event_origin_context.get()
@contextmanager
def _framework_event_origin() -> Iterator[None]: # pyright: ignore[reportUnusedFunction]
"""Temporarily mark subsequently created events as originating from the framework (internal)."""
token = _event_origin_context.set(WorkflowEventSource.FRAMEWORK)
try:
yield
finally:
_event_origin_context.reset(token)
class WorkflowEvent:
"""Base class for workflow events."""
def __init__(self, data: Any | None = None):
"""Initialize the workflow event with optional data."""
self.data = data
self.origin = _current_event_origin()
def __repr__(self) -> str:
"""Return a string representation of the workflow event."""
data_repr = self.data if self.data is not None else "None"
return f"{self.__class__.__name__}(origin={self.origin}, data={data_repr})"
class WorkflowStartedEvent(WorkflowEvent):
"""Built-in lifecycle event emitted when a workflow run begins."""
...
class WorkflowWarningEvent(WorkflowEvent):
"""Executor-origin event signaling a warning surfaced by user code."""
def __init__(self, data: str):
"""Initialize the workflow warning event with optional data and warning message."""
super().__init__(data)
def __repr__(self) -> str:
"""Return a string representation of the workflow warning event."""
return f"{self.__class__.__name__}(message={self.data}, origin={self.origin})"
class WorkflowErrorEvent(WorkflowEvent):
"""Executor-origin event signaling an error surfaced by user code."""
def __init__(self, data: Exception):
"""Initialize the workflow error event with optional data and error message."""
super().__init__(data)
def __repr__(self) -> str:
"""Return a string representation of the workflow error event."""
return f"{self.__class__.__name__}(exception={self.data}, origin={self.origin})"
class WorkflowRunState(str, Enum):
"""Run-level state of a workflow execution.
Semantics:
- STARTED: Run has been initiated and the workflow context has been created.
This is an initial state before any meaningful work is performed. In this
codebase we emit a dedicated `WorkflowStartedEvent` for telemetry, and
typically advance the status directly to `IN_PROGRESS`. Consumers may
still rely on `STARTED` for state machines that need an explicit pre-work
phase.
- IN_PROGRESS: The workflow is actively executing (e.g., the initial
message has been delivered to the start executor or a superstep is
running). This status is emitted at the beginning of a run and can be
followed by other statuses as the run progresses.
- IN_PROGRESS_PENDING_REQUESTS: Active execution while one or more
request-for-information operations are outstanding. New work may still
be scheduled while requests are in flight.
- IDLE: The workflow is quiescent with no outstanding requests and no more
work to do. This is the normal terminal state for workflows that have
finished executing, potentially having produced outputs along the way.
- IDLE_WITH_PENDING_REQUESTS: The workflow is paused awaiting external
input (e.g., emitted a `RequestInfoEvent`). This is a non-terminal
state; the workflow can resume when responses are supplied.
- FAILED: Terminal state indicating an error surfaced. Accompanied by a
`WorkflowFailedEvent` with structured error details.
- CANCELLED: Terminal state indicating the run was cancelled by a caller
or orchestrator. Not currently emitted by default runner paths but
included for integrators/orchestrators that support cancellation.
"""
STARTED = "STARTED" # Explicit pre-work phase (rarely emitted as status; see note above)
IN_PROGRESS = "IN_PROGRESS" # Active execution is underway
IN_PROGRESS_PENDING_REQUESTS = "IN_PROGRESS_PENDING_REQUESTS" # Active execution with outstanding requests
IDLE = "IDLE" # No active work and no outstanding requests
IDLE_WITH_PENDING_REQUESTS = "IDLE_WITH_PENDING_REQUESTS" # Paused awaiting external responses
FAILED = "FAILED" # Finished with an error
CANCELLED = "CANCELLED" # Finished due to cancellation
class WorkflowStatusEvent(WorkflowEvent):
"""Built-in lifecycle event emitted for workflow run state transitions."""
def __init__(
self,
state: WorkflowRunState,
data: Any | None = None,
):
"""Initialize the workflow status event with a new state and optional data.
Args:
state: The new state of the workflow run.
data: Optional additional data associated with the state change.
"""
super().__init__(data)
self.state = state
def __repr__(self) -> str: # pragma: no cover - representation only
return f"{self.__class__.__name__}(state={self.state}, data={self.data!r}, origin={self.origin})"
@dataclass
class WorkflowErrorDetails:
"""Structured error information to surface in error events/results."""
error_type: str
message: str
traceback: str | None = None
executor_id: str | None = None
extra: dict[str, Any] | None = None
@classmethod
def from_exception(
cls,
exc: BaseException,
*,
executor_id: str | None = None,
extra: dict[str, Any] | None = None,
) -> "WorkflowErrorDetails":
tb = None
try:
tb = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
except Exception:
tb = None
return cls(
error_type=exc.__class__.__name__,
message=str(exc),
traceback=tb,
executor_id=executor_id,
extra=extra,
)
class WorkflowFailedEvent(WorkflowEvent):
"""Built-in lifecycle event emitted when a workflow run terminates with an error."""
def __init__(
self,
details: WorkflowErrorDetails,
data: Any | None = None,
):
super().__init__(data)
self.details = details
def __repr__(self) -> str: # pragma: no cover - representation only
return f"{self.__class__.__name__}(details={self.details}, data={self.data!r}, origin={self.origin})"
class RequestInfoEvent(WorkflowEvent):
"""Event triggered when a workflow executor requests external information."""
def __init__(
self,
request_id: str,
source_executor_id: str,
request_type: type,
request_data: "RequestInfoMessage",
):
"""Initialize the request info event.
Args:
request_id: Unique identifier for the request.
source_executor_id: ID of the executor that made the request.
request_type: Type of the request (e.g., a specific data type).
request_data: The data associated with the request.
"""
super().__init__(request_data)
self.request_id = request_id
self.source_executor_id = source_executor_id
self.request_type = request_type
def __repr__(self) -> str:
"""Return a string representation of the request info event."""
return (
f"{self.__class__.__name__}("
f"request_id={self.request_id}, "
f"source_executor_id={self.source_executor_id}, "
f"request_type={self.request_type.__name__}, "
f"data={self.data})"
)
class WorkflowOutputEvent(WorkflowEvent):
"""Event triggered when a workflow executor yields output."""
def __init__(
self,
data: Any,
source_executor_id: str,
):
"""Initialize the workflow output event.
Args:
data: The output yielded by the executor.
source_executor_id: ID of the executor that yielded the output.
"""
super().__init__(data)
self.source_executor_id = source_executor_id
def __repr__(self) -> str:
"""Return a string representation of the workflow output event."""
return f"{self.__class__.__name__}(data={self.data}, source_executor_id={self.source_executor_id})"
class ExecutorEvent(WorkflowEvent):
"""Base class for executor events."""
def __init__(self, executor_id: str, data: Any | None = None):
"""Initialize the executor event with an executor ID and optional data."""
super().__init__(data)
self.executor_id = executor_id
def __repr__(self) -> str:
"""Return a string representation of the executor event."""
return f"{self.__class__.__name__}(executor_id={self.executor_id}, data={self.data})"
class ExecutorInvokedEvent(ExecutorEvent):
"""Event triggered when an executor handler is invoked."""
def __repr__(self) -> str:
"""Return a string representation of the executor handler invoke event."""
return f"{self.__class__.__name__}(executor_id={self.executor_id}, data={self.data})"
class ExecutorCompletedEvent(ExecutorEvent):
"""Event triggered when an executor handler is completed."""
def __repr__(self) -> str:
"""Return a string representation of the executor handler complete event."""
return f"{self.__class__.__name__}(executor_id={self.executor_id}, data={self.data})"
class ExecutorFailedEvent(ExecutorEvent):
"""Event triggered when an executor handler raises an error."""
def __init__(
self,
executor_id: str,
details: WorkflowErrorDetails,
):
super().__init__(executor_id, details)
self.details = details
def __repr__(self) -> str: # pragma: no cover - representation only
return f"{self.__class__.__name__}(executor_id={self.executor_id}, details={self.details})"
class AgentRunUpdateEvent(ExecutorEvent):
"""Event triggered when an agent is streaming messages."""
def __init__(self, executor_id: str, data: AgentRunResponseUpdate | None = None):
"""Initialize the agent streaming event."""
super().__init__(executor_id, data)
def __repr__(self) -> str:
"""Return a string representation of the agent streaming event."""
return f"{self.__class__.__name__}(executor_id={self.executor_id}, messages={self.data})"
class AgentRunEvent(ExecutorEvent):
"""Event triggered when an agent run is completed."""
def __init__(self, executor_id: str, data: AgentRunResponse | None = None):
"""Initialize the agent run event."""
super().__init__(executor_id, data)
def __repr__(self) -> str:
"""Return a string representation of the agent run event."""
return f"{self.__class__.__name__}(executor_id={self.executor_id}, data={self.data})"
WorkflowLifecycleEvent: TypeAlias = WorkflowStartedEvent | WorkflowStatusEvent | WorkflowFailedEvent
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
# Copyright (c) Microsoft. All rights reserved.
"""Function-based Executor and decorator utilities.
This module provides:
- FunctionExecutor: an Executor subclass that wraps a user-defined function
with signature (message) or (message, ctx: WorkflowContext[T]). Both sync and async functions are supported.
Synchronous functions are executed in a thread pool using asyncio.to_thread() to avoid blocking the event loop.
- executor decorator: converts such a function into a ready-to-use Executor instance
with proper type validation and handler registration.
"""
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any, overload
from ._executor import Executor
from ._workflow_context import WorkflowContext, validate_function_signature
class FunctionExecutor(Executor):
"""Executor that wraps a user-defined function.
This executor allows users to define simple functions (both sync and async) and use them
as workflow executors without needing to create full executor classes.
Synchronous functions are executed in a thread pool using asyncio.to_thread() to avoid
blocking the event loop.
"""
@staticmethod
def _validate_function(func: Callable[..., Any]) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
"""Validate that the function has the correct signature for an executor.
Args:
func: The function to validate (can be sync or async)
Returns:
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
Raises:
ValueError: If the function signature is incorrect
"""
return validate_function_signature(func, "Function")
def __init__(self, func: Callable[..., Any], id: str | None = None):
"""Initialize the FunctionExecutor with a user-defined function.
Args:
func: The function to wrap as an executor (can be sync or async)
id: Optional executor ID. If None, uses the function name.
"""
# Validate function signature and extract types
message_type, ctx_annotation, output_types, workflow_output_types = self._validate_function(func)
# Determine if function has WorkflowContext parameter
has_context = ctx_annotation is not None
is_async = asyncio.iscoroutinefunction(func)
# Initialize parent WITHOUT calling _discover_handlers yet
# We'll manually set up the attributes first
executor_id = str(id or getattr(func, "__name__", "FunctionExecutor"))
kwargs = {"type": "FunctionExecutor"}
super().__init__(id=executor_id, defer_discovery=True, **kwargs)
self._handlers = {}
self._handler_specs = []
# Store the original function and whether it has context
self._original_func = func
self._has_context = has_context
self._is_async = is_async
# Create a wrapper function that always accepts both message and context
if has_context and is_async:
# Async function with context - already has the right signature
wrapped_func: Callable[[Any, WorkflowContext[Any]], Awaitable[Any]] = func # type: ignore
elif has_context and not is_async:
# Sync function with context - wrap to make async using thread pool
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
# Call the sync function with both parameters in a thread
return await asyncio.to_thread(func, message, ctx) # type: ignore
elif not has_context and is_async:
# Async function without context - wrap to ignore context
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
# Call the async function with just the message
return await func(message) # type: ignore
else:
# Sync function without context - wrap to make async and ignore context using thread pool
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
# Call the sync function with just the message in a thread
return await asyncio.to_thread(func, message) # type: ignore
# Now register our instance handler
self._register_instance_handler(
name=func.__name__,
func=wrapped_func,
message_type=message_type,
ctx_annotation=ctx_annotation,
output_types=output_types,
workflow_output_types=workflow_output_types,
)
# Now we can safely call _discover_handlers (it won't find any class-level handlers)
self._discover_handlers()
if not self._handlers:
raise ValueError(
f"FunctionExecutor {self.__class__.__name__} failed to register handler for {func.__name__}"
)
@overload
def executor(func: Callable[..., Any]) -> FunctionExecutor: ...
@overload
def executor(*, id: str | None = None) -> Callable[[Callable[..., Any]], FunctionExecutor]: ...
def executor(
func: Callable[..., Any] | None = None, *, id: str | None = None
) -> Callable[[Callable[..., Any]], FunctionExecutor] | FunctionExecutor:
"""Decorator that converts a function into a FunctionExecutor instance.
Supports both synchronous and asynchronous functions. Synchronous functions
are executed in a thread pool to avoid blocking the event loop.
Usage:
.. code-block:: python
# With arguments (async function):
@executor(id="upper_case")
async def to_upper(text: str, ctx: WorkflowContext[str]):
await ctx.send_message(text.upper())
# Without parentheses (sync function - runs in thread pool):
@executor
def process_data(data: str):
# Process data without sending messages
return data.upper()
# Sync function with context (runs in thread pool):
@executor
def sync_with_context(data: int, ctx: WorkflowContext[int]):
# Note: sync functions can still use context
return data * 2
Returns:
An Executor instance that can be wired into a Workflow.
"""
def wrapper(func: Callable[..., Any]) -> FunctionExecutor:
return FunctionExecutor(func, id=id)
# If func is provided, this means @executor was used without parentheses
if func is not None:
return wrapper(func)
# Otherwise, return the wrapper for @executor() or @executor(id="...")
return wrapper
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
# Copyright (c) Microsoft. All rights reserved.
import copy
import sys
from typing import Any, TypeVar
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
TModel = TypeVar("TModel", bound="DictConvertible")
class DictConvertible:
"""Mixin providing conversion helpers for plain Python models."""
def to_dict(self) -> dict[str, Any]:
raise NotImplementedError
@classmethod
def from_dict(cls: type[TModel], data: dict[str, Any]) -> TModel:
return cls(**data) # type: ignore[arg-type]
def clone(self, *, deep: bool = True) -> Self:
return copy.deepcopy(self) if deep else copy.copy(self) # type: ignore[return-value]
def to_json(self) -> str:
import json
return json.dumps(self.to_dict())
@classmethod
def from_json(cls: type[TModel], raw: str) -> TModel:
import json
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError("JSON payload must decode to a mapping")
return cls.from_dict(data)
def encode_value(value: Any) -> Any:
"""Recursively encode values for JSON-friendly serialization."""
if isinstance(value, DictConvertible):
return value.to_dict()
if isinstance(value, dict):
return {k: encode_value(v) for k, v in value.items()} # type: ignore[misc]
if isinstance(value, (list, tuple, set)):
return [encode_value(v) for v in value] # type: ignore[misc]
return value
@@ -0,0 +1,447 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from collections import defaultdict
from collections.abc import AsyncGenerator, Sequence
from typing import TYPE_CHECKING, Any
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
from ._events import WorkflowEvent, WorkflowOutputEvent, _framework_event_origin
from ._executor import Executor
from ._runner_context import (
_DATACLASS_MARKER, # type: ignore
_MODEL_MARKER, # type: ignore
CheckpointState,
Message,
RunnerContext,
_decode_checkpoint_value, # type: ignore
)
from ._shared_state import SharedState
if TYPE_CHECKING:
from ._executor import RequestInfoExecutor
logger = logging.getLogger(__name__)
class Runner:
"""A class to run a workflow in Pregel supersteps."""
def __init__(
self,
edge_groups: Sequence[EdgeGroup],
executors: dict[str, Executor],
shared_state: SharedState,
ctx: RunnerContext,
max_iterations: int = 100,
workflow_id: str | None = None,
) -> None:
"""Initialize the runner with edges, shared state, and context.
Args:
edge_groups: The edge groups of the workflow.
executors: Map of executor IDs to executor instances.
shared_state: The shared state for the workflow.
ctx: The runner context for the workflow.
max_iterations: The maximum number of iterations to run.
workflow_id: The workflow ID for checkpointing.
"""
self._executors = executors
self._edge_runners = [create_edge_runner(group, executors) for group in edge_groups]
self._edge_runner_map = self._parse_edge_runners(self._edge_runners)
self._ctx = ctx
self._iteration = 0
self._max_iterations = max_iterations
self._shared_state = shared_state
self._workflow_id = workflow_id
self._running = False
self._resumed_from_checkpoint = False # Track whether we resumed
self.graph_signature_hash: str | None = None
# Set workflow ID in context if provided
if workflow_id:
self._ctx.set_workflow_id(workflow_id)
@property
def context(self) -> RunnerContext:
"""Get the workflow context."""
return self._ctx
def mark_resumed(self, iteration: int | None = None, max_iterations: int | None = None) -> None:
"""Mark the runner as having resumed from a checkpoint.
Optionally set the current iteration and max iterations.
"""
self._resumed_from_checkpoint = True
if iteration is not None:
self._iteration = iteration
if max_iterations is not None:
self._max_iterations = max_iterations
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
"""Run the workflow until no more messages are sent."""
if self._running:
raise RuntimeError("Runner is already running.")
self._running = True
try:
# Emit any events already produced prior to entering loop
if await self._ctx.has_events():
logger.info("Yielding pre-loop events")
for event in await self._ctx.drain_events():
yield event
# Create first checkpoint if there are messages from initial execution
if await self._ctx.has_messages() and self._ctx.has_checkpointing():
if not self._resumed_from_checkpoint:
logger.info("Creating checkpoint after initial execution")
await self._create_checkpoint_if_enabled("after_initial_execution")
else:
logger.info("Skipping 'after_initial_execution' checkpoint because we resumed from a checkpoint")
# Initialize context with starting iteration state
await self._update_context_with_shared_state()
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
# Run iteration concurrently with live event streaming: we poll
# for new events while the iteration coroutine progresses.
iteration_task = asyncio.create_task(self._run_iteration())
while not iteration_task.done():
try:
# Wait briefly for any new event; timeout allows progress checks
event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05)
yield event
except asyncio.TimeoutError:
# Periodically continue to let iteration advance
continue
# Propagate errors from iteration, but first surface any pending events
try:
await iteration_task
except Exception:
# Make sure failure-related events (like ExecutorFailedEvent) are surfaced
if await self._ctx.has_events():
for event in await self._ctx.drain_events():
yield event
raise
self._iteration += 1
# Drain any straggler events emitted at tail end
if await self._ctx.has_events():
for event in await self._ctx.drain_events():
yield event
# Update context with current iteration state immediately
await self._update_context_with_shared_state()
logger.info(f"Completed superstep {self._iteration}")
# Create checkpoint after each superstep iteration
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
if not await self._ctx.has_messages():
break
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
raise RuntimeError(f"Runner did not converge after {self._max_iterations} iterations.")
logger.info(f"Workflow completed after {self._iteration} supersteps")
self._iteration = 0
self._resumed_from_checkpoint = False # Reset resume flag for next run
finally:
self._running = False
async def _run_iteration(self) -> None:
async def _deliver_messages(source_executor_id: str, messages: list[Message]) -> None:
"""Outer loop to concurrently deliver messages from all sources to their targets."""
async def _deliver_message_inner(edge_runner: EdgeRunner, message: Message) -> bool:
"""Inner loop to deliver a single message through an edge runner."""
return await edge_runner.send_message(message, self._shared_state, self._ctx)
def _normalize_message_payload(message: Message) -> None:
data = message.data
if not isinstance(data, dict):
return
if _MODEL_MARKER not in data and _DATACLASS_MARKER not in data:
return
try:
decoded = _decode_checkpoint_value(data)
except Exception as exc: # pragma: no cover - defensive
logger.debug("Failed to decode checkpoint payload during delivery: %s", exc)
return
message.data = decoded
# Route all messages through normal workflow edges
associated_edge_runners = self._edge_runner_map.get(source_executor_id, [])
for message in messages:
_normalize_message_payload(message)
# Deliver a message through all edge runners associated with the source executor concurrently.
tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners]
if not tasks:
# No outgoing edges. If this is an AgentExecutorResponse, treat it as an
# intentional terminal emission and emit a WorkflowOutputEvent here.
# (Previously this relied on the executor to emit, but AgentExecutor only
# sends an AgentExecutorResponse message; centralized completion keeps the
# contract consistent with other executors.)
try: # Local import to avoid circular dependencies at module import time.
from ._executor import AgentExecutorResponse # type: ignore
if isinstance(message.data, AgentExecutorResponse):
final_messages = message.data.agent_run_response.messages
final_text = final_messages[-1].text if final_messages else "(no content)"
with _framework_event_origin():
# TODO(moonbox3): does user expect this event to contain the final text?
output_event = WorkflowOutputEvent(data=final_text, source_executor_id="<Runner>")
await self._ctx.add_event(output_event)
continue # Terminal handled
except Exception as exc: # pragma: no cover - defensive
logger.debug("Suppressed exception during terminal message type check: %s", exc)
# Otherwise keep prior behavior (emit warning for unexpected undelivered message).
logger.warning(
f"Message {message} could not be delivered (no outgoing edges). "
"Add a downstream executor or remove the send if this is unexpected."
)
continue
results = await asyncio.gather(*tasks)
if not any(results):
# Outgoing edges exist but none accepted the message. If this is an
# AgentExecutorResponse, treat as natural terminal and emit completion.
try:
from ._executor import AgentExecutorResponse # type: ignore
if isinstance(message.data, AgentExecutorResponse):
# Emit a single completion event with final text (best-effort extraction)
final_messages = message.data.agent_run_response.messages
final_text = final_messages[-1].text if final_messages else "(no content)"
with _framework_event_origin():
# TODO(moonbox3): does user expect this event to contain the final text?
output_event = WorkflowOutputEvent(data=final_text, source_executor_id="<Runner>")
await self._ctx.add_event(output_event)
continue
except Exception as exc: # pragma: no cover
logger.debug("Terminal completion emission failed: %s", exc)
logger.warning(
f"Message {message} could not be delivered. "
"This may be due to type incompatibility or no matching targets."
)
messages = await self._ctx.drain_messages()
tasks = [_deliver_messages(source_executor_id, messages) for source_executor_id, messages in messages.items()]
await asyncio.gather(*tasks)
async def _create_checkpoint_if_enabled(self, checkpoint_type: str) -> str | None:
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
if not self._ctx.has_checkpointing():
return None
try:
# Auto-snapshot executor states
await self._auto_snapshot_executor_states()
await self._update_context_with_shared_state()
checkpoint_category = "initial" if checkpoint_type == "after_initial_execution" else "superstep"
metadata = {
"superstep": self._iteration,
"checkpoint_type": checkpoint_category,
}
if self.graph_signature_hash:
metadata["graph_signature"] = self.graph_signature_hash
checkpoint_id = await self._ctx.create_checkpoint(metadata=metadata)
logger.info(f"Created {checkpoint_type} checkpoint: {checkpoint_id}")
return checkpoint_id
except Exception as e:
logger.warning(f"Failed to create {checkpoint_type} checkpoint: {e}")
return None
async def _auto_snapshot_executor_states(self) -> None:
"""Populate executor state by calling snapshot hooks on executors if available.
Convention:
- If an executor defines an async or sync method `snapshot_state(self) -> dict`, use it.
- Else if it has a plain attribute `state` that is a dict, use that.
Only JSON-serializable dicts should be provided by executors.
"""
for exec_id, executor in self._executors.items():
state_dict: dict[str, Any] | None = None
snapshot = getattr(executor, "snapshot_state", None)
try:
if callable(snapshot):
maybe = snapshot()
if asyncio.iscoroutine(maybe): # type: ignore[arg-type]
maybe = await maybe # type: ignore[assignment]
if isinstance(maybe, dict):
state_dict = maybe # type: ignore[assignment]
else:
state_attr = getattr(executor, "state", None)
if isinstance(state_attr, dict):
state_dict = state_attr # type: ignore[assignment]
except Exception as ex: # pragma: no cover
logger.debug(f"Executor {exec_id} snapshot_state failed: {ex}")
if state_dict is not None:
try:
await self._ctx.set_state(exec_id, state_dict)
except Exception as ex: # pragma: no cover
logger.debug(f"Failed to persist state for executor {exec_id}: {ex}")
async def _update_context_with_shared_state(self) -> None:
if not self._ctx.has_checkpointing():
return
try:
current_state = await self._ctx.get_checkpoint_state()
shared_state_data = {}
async with self._shared_state.hold():
if hasattr(self._shared_state, "_state"):
shared_state_data = dict(self._shared_state._state) # type: ignore[attr-defined]
current_state["shared_state"] = shared_state_data
current_state["iteration_count"] = self._iteration
current_state["max_iterations"] = self._max_iterations
await self._ctx.set_checkpoint_state(current_state)
except Exception as e:
logger.warning(f"Failed to update context with shared state: {e}")
async def restore_from_checkpoint(
self,
checkpoint_id: str,
checkpoint_storage: CheckpointStorage | None = None,
) -> bool:
"""Restore workflow state from a checkpoint.
Args:
checkpoint_id: The ID of the checkpoint to restore from
checkpoint_storage: Optional storage to load checkpoints from when the
runner context itself is not configured with checkpointing.
Returns:
True if restoration was successful, False otherwise
"""
try:
checkpoint: WorkflowCheckpoint | None
if self._ctx.has_checkpointing():
checkpoint = await self._ctx.load_checkpoint(checkpoint_id)
elif checkpoint_storage is not None:
checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id)
else:
logger.warning("Context does not support checkpointing and no external storage was provided")
return False
if not checkpoint:
logger.error(f"Checkpoint {checkpoint_id} not found")
return False
graph_hash = getattr(self, "graph_signature_hash", None)
checkpoint_hash = (checkpoint.metadata or {}).get("graph_signature")
if graph_hash and checkpoint_hash and graph_hash != checkpoint_hash:
raise ValueError(
"Workflow graph has changed since the checkpoint was created. "
"Please rebuild the original workflow before resuming."
)
if graph_hash and not checkpoint_hash:
logger.warning(
"Checkpoint %s does not include graph signature metadata; skipping topology validation.",
checkpoint_id,
)
state = self._checkpoint_to_state(checkpoint)
await self._ctx.set_checkpoint_state(state)
if checkpoint.workflow_id:
self._ctx.set_workflow_id(checkpoint.workflow_id)
self._workflow_id = checkpoint.workflow_id
await self._restore_shared_state_from_context()
self.mark_resumed(
iteration=checkpoint.iteration_count,
max_iterations=checkpoint.max_iterations,
)
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
return True
except ValueError:
raise
except Exception as e:
logger.error(f"Failed to restore from checkpoint {checkpoint_id}: {e}")
return False
async def _restore_shared_state_from_context(self) -> None:
try:
restored_state = await self._ctx.get_checkpoint_state()
shared_state_data = restored_state.get("shared_state", {})
if shared_state_data and hasattr(self._shared_state, "_state"):
async with self._shared_state.hold():
self._shared_state._state.clear() # type: ignore[attr-defined]
self._shared_state._state.update(shared_state_data) # type: ignore[attr-defined]
self._iteration = restored_state.get("iteration_count", 0)
self._max_iterations = restored_state.get("max_iterations", self._max_iterations)
except Exception as e:
logger.warning(f"Failed to restore shared state from context: {e}")
@staticmethod
def _checkpoint_to_state(checkpoint: WorkflowCheckpoint) -> CheckpointState:
return {
"messages": checkpoint.messages,
"shared_state": checkpoint.shared_state,
"executor_states": checkpoint.executor_states,
"iteration_count": checkpoint.iteration_count,
"max_iterations": checkpoint.max_iterations,
}
def _parse_edge_runners(self, edge_runners: list[EdgeRunner]) -> dict[str, list[EdgeRunner]]:
"""Parse the edge runners of the workflow into a mapping where each source executor ID maps to its edge runners.
Args:
edge_runners: A list of edge runners in the workflow.
Returns:
A dictionary mapping each source executor ID to a list of edge runners.
"""
parsed: defaultdict[str, list[EdgeRunner]] = defaultdict(list)
for runner in edge_runners:
# Accessing protected attribute (_edge_group) intentionally for internal wiring.
for source_executor_id in runner._edge_group.source_executor_ids: # type: ignore[attr-defined]
parsed[source_executor_id].append(runner)
return parsed
def _find_request_info_executor(self) -> "RequestInfoExecutor | None":
"""Find the RequestInfoExecutor instance in this workflow.
Returns:
The RequestInfoExecutor instance if found, None otherwise.
"""
from ._executor import RequestInfoExecutor
for executor in self._executors.values():
if isinstance(executor, RequestInfoExecutor):
return executor
return None
def _is_message_to_request_info_executor(self, msg: "Message") -> bool:
"""Check if message targets any RequestInfoExecutor in this workflow.
Args:
msg: The message to check.
Returns:
True if the message targets a RequestInfoExecutor, False otherwise.
"""
from ._executor import RequestInfoExecutor
if not msg.target_id:
return False
# Check all executors to see if target_id matches a RequestInfoExecutor
for executor in self._executors.values():
if executor.id == msg.target_id and isinstance(executor, RequestInfoExecutor):
return True
return False
@@ -0,0 +1,643 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import contextlib
import importlib
import logging
import sys
import uuid
from copy import copy
from dataclasses import dataclass, fields, is_dataclass
from typing import Any, Protocol, TypedDict, TypeVar, cast, runtime_checkable
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._const import DEFAULT_MAX_ITERATIONS
from ._events import AgentRunUpdateEvent, WorkflowEvent
from ._shared_state import SharedState
logger = logging.getLogger(__name__)
T = TypeVar("T")
@dataclass
class Message:
"""A class representing a message in the workflow."""
data: Any
source_id: str
target_id: str | None = None
# OpenTelemetry trace context fields for message propagation
# These are plural to support fan-in scenarios where multiple messages are aggregated
trace_contexts: list[dict[str, str]] | None = None # W3C Trace Context headers from multiple sources
source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources
# Backward compatibility properties
@property
def trace_context(self) -> dict[str, str] | None:
"""Get the first trace context for backward compatibility."""
return self.trace_contexts[0] if self.trace_contexts else None
@property
def source_span_id(self) -> str | None:
"""Get the first source span ID for backward compatibility."""
return self.source_span_ids[0] if self.source_span_ids else None
class CheckpointState(TypedDict):
messages: dict[str, list[dict[str, Any]]]
shared_state: dict[str, Any]
executor_states: dict[str, dict[str, Any]]
iteration_count: int
max_iterations: int
# Checkpoint serialization helpers
_MODEL_MARKER = "__af_model__"
_DATACLASS_MARKER = "__af_dataclass__"
_AF_MARKER = "__af__"
# Guards to prevent runaway recursion while encoding arbitrary user data
_MAX_ENCODE_DEPTH = 100
_CYCLE_SENTINEL = "<cycle>"
def _instantiate_checkpoint_dataclass(cls: type[Any], payload: Any) -> Any | None:
if not isinstance(cls, type):
logger.debug(f"Checkpoint decoder received non-type dataclass reference: {cls!r}")
return None
if isinstance(payload, dict):
try:
return cls(**payload) # type: ignore[arg-type]
except TypeError as exc:
logger.debug(f"Checkpoint decoder could not call {cls.__name__}(**payload): {exc}")
except Exception as exc:
logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}(**payload): {exc}")
try:
instance = object.__new__(cls)
except Exception as exc:
logger.debug(f"Checkpoint decoder could not allocate {cls.__name__} without __init__: {exc}")
return None
for key, val in payload.items(): # type: ignore[attr-defined]
try:
setattr(instance, key, val) # type: ignore[arg-type]
except Exception as exc:
logger.debug(f"Checkpoint decoder could not set attribute {key} on {cls.__name__}: {exc}")
return instance
try:
return cls(payload) # type: ignore[call-arg]
except TypeError as exc:
logger.debug(f"Checkpoint decoder could not call {cls.__name__}({payload!r}): {exc}")
except Exception as exc:
logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}({payload!r}): {exc}")
return None
def _supports_model_protocol(obj: object) -> bool:
"""Detect objects that expose dictionary serialization hooks."""
try:
obj_type: type[Any] = type(obj)
except Exception:
return False
has_to_dict = hasattr(obj, "to_dict") and callable(getattr(obj, "to_dict", None)) # type: ignore[arg-type]
has_from_dict = hasattr(obj_type, "from_dict") and callable(getattr(obj_type, "from_dict", None))
has_to_json = hasattr(obj, "to_json") and callable(getattr(obj, "to_json", None)) # type: ignore[arg-type]
has_from_json = hasattr(obj_type, "from_json") and callable(getattr(obj_type, "from_json", None))
return (has_to_dict and has_from_dict) or (has_to_json and has_from_json)
def _import_qualified_name(qualname: str) -> type[Any] | None:
if ":" not in qualname:
return None
module_name, class_name = qualname.split(":", 1)
module = sys.modules.get(module_name)
if module is None:
module = importlib.import_module(module_name)
attr: Any = module
for part in class_name.split("."):
attr = getattr(attr, part)
return attr if isinstance(attr, type) else None
def _encode_checkpoint_value(value: Any) -> Any:
"""Recursively encode values into JSON-serializable structures.
- Objects exposing to_dict/to_json -> { _MODEL_MARKER: "module:Class", value: encoded }
- dataclass instances -> { _DATACLASS_MARKER: "module:Class", value: {field: encoded} }
- dict -> encode keys as str and values recursively
- list/tuple/set -> list of encoded items
- other -> returned as-is if already JSON-serializable
Includes cycle and depth protection to avoid infinite recursion.
"""
def _enc(v: Any, stack: set[int], depth: int) -> Any:
# Depth guard
if depth > _MAX_ENCODE_DEPTH:
logger.debug(f"Max encode depth reached at depth={depth} for type={type(v)}")
return "<max_depth>"
# Structured model handling (objects exposing to_dict/to_json)
if _supports_model_protocol(v):
cls = cast(type[Any], type(v)) # type: ignore
try:
if hasattr(v, "to_dict") and callable(getattr(v, "to_dict", None)):
raw = v.to_dict() # type: ignore[attr-defined]
strategy = "to_dict"
elif hasattr(v, "to_json") and callable(getattr(v, "to_json", None)):
serialized = v.to_json() # type: ignore[attr-defined]
if isinstance(serialized, (bytes, bytearray)):
try:
serialized = serialized.decode()
except Exception:
serialized = serialized.decode(errors="replace")
raw = serialized
strategy = "to_json"
else:
raise AttributeError("Structured model lacks serialization hooks")
return {
_MODEL_MARKER: f"{cls.__module__}:{cls.__name__}",
"strategy": strategy,
"value": _enc(raw, stack, depth + 1),
}
except Exception as exc: # best-effort fallback
logger.debug(f"Structured model serialization failed for {cls}: {exc}")
return str(v)
# Dataclasses (instances only)
if is_dataclass(v) and not isinstance(v, type):
oid = id(v)
if oid in stack:
logger.debug("Cycle detected while encoding dataclass instance")
return _CYCLE_SENTINEL
stack.add(oid)
try:
# type(v) already narrows sufficiently; cast was redundant
dc_cls: type[Any] = type(v)
field_values: dict[str, Any] = {}
for f in fields(v): # type: ignore[arg-type]
field_values[f.name] = _enc(getattr(v, f.name), stack, depth + 1)
return {
_DATACLASS_MARKER: f"{dc_cls.__module__}:{dc_cls.__name__}",
"value": field_values,
}
finally:
stack.remove(oid)
# Collections
if isinstance(v, dict):
v_dict = cast("dict[object, object]", v)
oid = id(v_dict)
if oid in stack:
logger.debug("Cycle detected while encoding dict")
return _CYCLE_SENTINEL
stack.add(oid)
try:
json_dict: dict[str, Any] = {}
for k_any, val_any in v_dict.items(): # type: ignore[assignment]
k_str: str = str(k_any)
json_dict[k_str] = _enc(val_any, stack, depth + 1)
return json_dict
finally:
stack.remove(oid)
if isinstance(v, (list, tuple, set)):
iterable_v = cast("list[object] | tuple[object, ...] | set[object]", v)
oid = id(iterable_v)
if oid in stack:
logger.debug("Cycle detected while encoding iterable")
return _CYCLE_SENTINEL
stack.add(oid)
try:
seq: list[object] = list(iterable_v)
encoded_list: list[Any] = []
for item in seq:
encoded_list.append(_enc(item, stack, depth + 1))
return encoded_list
finally:
stack.remove(oid)
# Primitives (or unknown objects): ensure JSON-serializable
if isinstance(v, (str, int, float, bool)) or v is None:
return v
# Fallback: stringify unknown objects to avoid JSON serialization errors
try:
return str(v)
except Exception:
return f"<{type(v).__name__}>"
return _enc(value, set(), 0)
def _decode_checkpoint_value(value: Any) -> Any:
"""Recursively decode values previously encoded by _encode_checkpoint_value."""
if isinstance(value, dict):
value_dict = cast(dict[str, Any], value) # encoded form always uses string keys
# Structured model marker handling
if _MODEL_MARKER in value_dict and "value" in value_dict:
type_key: str | None = value_dict.get(_MODEL_MARKER) # type: ignore[assignment]
strategy: str | None = value_dict.get("strategy") # type: ignore[assignment]
raw_encoded: Any = value_dict.get("value")
decoded_payload = _decode_checkpoint_value(raw_encoded)
if isinstance(type_key, str):
try:
cls = _import_qualified_name(type_key)
except Exception as exc:
logger.debug(f"Failed to import structured model {type_key}: {exc}")
cls = None
if cls is not None:
if strategy == "to_dict" and hasattr(cls, "from_dict"):
with contextlib.suppress(Exception):
return cls.from_dict(decoded_payload)
if strategy == "to_json" and hasattr(cls, "from_json"):
if isinstance(decoded_payload, (str, bytes, bytearray)):
with contextlib.suppress(Exception):
return cls.from_json(decoded_payload)
if isinstance(decoded_payload, dict) and hasattr(cls, "from_dict"):
with contextlib.suppress(Exception):
return cls.from_dict(decoded_payload)
return decoded_payload
# Dataclass marker handling
if _DATACLASS_MARKER in value_dict and "value" in value_dict:
type_key_dc: str | None = value_dict.get(_DATACLASS_MARKER) # type: ignore[assignment]
raw_dc: Any = value_dict.get("value")
decoded_raw = _decode_checkpoint_value(raw_dc)
if isinstance(type_key_dc, str):
try:
module_name, class_name = type_key_dc.split(":", 1)
module = sys.modules.get(module_name)
if module is None:
module = importlib.import_module(module_name)
cls_dc: Any = getattr(module, class_name)
constructed = _instantiate_checkpoint_dataclass(cls_dc, decoded_raw)
if constructed is not None:
return constructed
except Exception as exc:
logger.debug(f"Failed to decode dataclass {type_key_dc}: {exc}; returning raw value")
return decoded_raw
# Regular dict: decode recursively
decoded: dict[str, Any] = {}
for k_any, v_any in value_dict.items():
decoded[k_any] = _decode_checkpoint_value(v_any)
return decoded
if isinstance(value, list):
# After isinstance check, treat value as list[Any] for decoding
value_list: list[Any] = value # type: ignore[assignment]
return [_decode_checkpoint_value(v_any) for v_any in value_list]
return value
@runtime_checkable
class RunnerContext(Protocol):
"""Protocol for the execution context used by the runner.
A single context that supports messaging, events, and optional checkpointing.
If checkpoint storage is not configured, checkpoint methods may raise.
"""
async def send_message(self, message: Message) -> None:
"""Send a message from the executor to the context.
Args:
message: The message to be sent.
"""
...
async def drain_messages(self) -> dict[str, list[Message]]:
"""Drain all messages from the context.
Returns:
A dictionary mapping executor IDs to lists of messages.
"""
...
async def has_messages(self) -> bool:
"""Check if there are any messages in the context.
Returns:
True if there are messages, False otherwise.
"""
...
async def add_event(self, event: WorkflowEvent) -> None:
"""Add an event to the execution context.
Args:
event: The event to be added.
"""
...
async def drain_events(self) -> list[WorkflowEvent]:
"""Drain all events from the context.
Returns:
A list of events that were added to the context.
"""
...
async def has_events(self) -> bool:
"""Check if there are any events in the context.
Returns:
True if there are events, False otherwise.
"""
...
async def next_event(self) -> WorkflowEvent: # pragma: no cover - interface only
"""Wait for and return the next event emitted by the workflow run."""
...
async def set_state(self, executor_id: str, state: dict[str, Any]) -> None:
"""Set the state for a specific executor.
Args:
executor_id: The ID of the executor whose state is being set.
state: The state data to be set for the executor.
"""
...
async def get_state(self, executor_id: str) -> dict[str, Any] | None:
"""Get the state for a specific executor.
Args:
executor_id: The ID of the executor whose state is being retrieved.
Returns:
The state data for the executor, or None if not found.
"""
...
# Checkpointing capability
def has_checkpointing(self) -> bool:
"""Check if the context supports checkpointing.
Returns:
True if checkpointing is supported, False otherwise.
"""
...
# Checkpointing APIs (optional, enabled by storage)
def set_workflow_id(self, workflow_id: str) -> None:
"""Set the workflow ID for the context."""
...
def reset_for_new_run(self, workflow_shared_state: SharedState | None = None) -> None:
"""Reset the context for a new workflow run."""
...
async def create_checkpoint(self, metadata: dict[str, Any] | None = None) -> str:
"""Create a checkpoint of the current workflow state.
Args:
metadata: Optional metadata to associate with the checkpoint.
"""
...
async def restore_from_checkpoint(self, checkpoint_id: str) -> bool:
"""Restore the context from a checkpoint.
Args:
checkpoint_id: The ID of the checkpoint to restore from.
Returns:
True if the restoration was successful, False otherwise.
"""
...
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Load a checkpoint without mutating the current context state."""
...
async def get_checkpoint_state(self) -> CheckpointState:
"""Get the current state of the context suitable for checkpointing."""
...
async def set_checkpoint_state(self, state: CheckpointState) -> None:
"""Set the state of the context from a checkpoint.
Args:
state: The state data to set for the context.
"""
...
class InProcRunnerContext:
"""In-process execution context for local execution and optional checkpointing."""
def __init__(self, checkpoint_storage: CheckpointStorage | None = None):
"""Initialize the in-process execution context.
Args:
checkpoint_storage: Optional storage to enable checkpointing.
"""
self._messages: dict[str, list[Message]] = {}
# Event queue for immediate streaming of events (e.g., AgentRunUpdateEvent)
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
# Checkpointing configuration/state
self._checkpoint_storage = checkpoint_storage
self._workflow_id: str | None = None
self._shared_state: dict[str, Any] = {}
self._executor_states: dict[str, dict[str, Any]] = {}
self._iteration_count: int = 0
self._max_iterations: int = 100
async def send_message(self, message: Message) -> None:
self._messages.setdefault(message.source_id, [])
self._messages[message.source_id].append(message)
async def drain_messages(self) -> dict[str, list[Message]]:
messages = copy(self._messages)
self._messages.clear()
return messages
async def has_messages(self) -> bool:
return bool(self._messages)
async def add_event(self, event: WorkflowEvent) -> None:
"""Add an event to the context immediately.
Events are enqueued so runners can stream them in real time instead of
waiting for superstep boundaries.
"""
# Filter out empty AgentRunUpdateEvent updates to avoid emitting None/empty chunks
try:
if isinstance(event, AgentRunUpdateEvent):
update = getattr(event, "data", None)
# Skip if no update payload
if not update:
return
# Robust emptiness check: allow either top-level text or any text-bearing content
text_val = getattr(update, "text", None)
contents = getattr(update, "contents", None)
has_text_content = False
if contents:
for c in contents:
if getattr(c, "text", None):
has_text_content = True
break
if not (text_val or has_text_content):
return
except Exception as exc: # pragma: no cover - defensive logging path
# Best-effort filtering only; never block event delivery on filtering errors
logger.debug(f"Error while filtering event {event!r}: {exc}", exc_info=True)
await self._event_queue.put(event)
async def drain_events(self) -> list[WorkflowEvent]:
"""Drain all currently queued events without blocking for new ones."""
events: list[WorkflowEvent] = []
while True:
try:
events.append(self._event_queue.get_nowait())
except asyncio.QueueEmpty: # type: ignore[attr-defined]
break
return events
async def has_events(self) -> bool:
return not self._event_queue.empty()
async def next_event(self) -> WorkflowEvent:
"""Wait for and return the next event.
Used by the runner to interleave event emission with ongoing iteration work.
"""
return await self._event_queue.get()
async def set_state(self, executor_id: str, state: dict[str, Any]) -> None:
self._executor_states[executor_id] = state
async def get_state(self, executor_id: str) -> dict[str, Any] | None:
return self._executor_states.get(executor_id)
def has_checkpointing(self) -> bool:
return self._checkpoint_storage is not None
def set_workflow_id(self, workflow_id: str) -> None:
self._workflow_id = workflow_id
def reset_for_new_run(self, workflow_shared_state: SharedState | None = None) -> None:
self._messages.clear()
# Clear any pending events (best-effort) by recreating the queue
self._event_queue = asyncio.Queue()
self._shared_state.clear()
self._executor_states.clear()
self._iteration_count = 0
if workflow_shared_state is not None and hasattr(workflow_shared_state, "_state"):
workflow_shared_state._state.clear() # type: ignore[attr-defined]
async def create_checkpoint(self, metadata: dict[str, Any] | None = None) -> str:
if not self._checkpoint_storage:
raise ValueError("Checkpoint storage not configured")
wf_id = self._workflow_id or str(uuid.uuid4())
self._workflow_id = wf_id
state = await self.get_checkpoint_state()
checkpoint = WorkflowCheckpoint(
workflow_id=wf_id,
messages=state["messages"],
shared_state=state.get("shared_state", {}),
executor_states=state.get("executor_states", {}),
iteration_count=state.get("iteration_count", 0),
max_iterations=state.get("max_iterations", DEFAULT_MAX_ITERATIONS),
metadata=metadata or {},
)
checkpoint_id = await self._checkpoint_storage.save_checkpoint(checkpoint)
logger.info(f"Created checkpoint {checkpoint_id} for workflow {wf_id}'")
return checkpoint_id
async def restore_from_checkpoint(self, checkpoint_id: str) -> bool:
if not self._checkpoint_storage:
raise ValueError("Checkpoint storage not configured")
checkpoint = await self._checkpoint_storage.load_checkpoint(checkpoint_id)
if not checkpoint:
logger.error(f"Checkpoint {checkpoint_id} not found")
return False
state: CheckpointState = {
"messages": checkpoint.messages,
"shared_state": checkpoint.shared_state,
"executor_states": checkpoint.executor_states,
"iteration_count": checkpoint.iteration_count,
"max_iterations": checkpoint.max_iterations,
}
await self.set_checkpoint_state(state)
self._workflow_id = checkpoint.workflow_id
logger.info(f"Restored state from checkpoint {checkpoint_id}'")
return True
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
if not self._checkpoint_storage:
raise ValueError("Checkpoint storage not configured")
return await self._checkpoint_storage.load_checkpoint(checkpoint_id)
async def get_checkpoint_state(self) -> CheckpointState:
serializable_messages: dict[str, list[dict[str, Any]]] = {}
for source_id, message_list in self._messages.items():
serializable_messages[source_id] = [
{
"data": _encode_checkpoint_value(msg.data),
"source_id": msg.source_id,
"target_id": msg.target_id,
"trace_contexts": msg.trace_contexts,
"source_span_ids": msg.source_span_ids,
}
for msg in message_list
]
return {
"messages": serializable_messages,
"shared_state": _encode_checkpoint_value(self._shared_state),
"executor_states": _encode_checkpoint_value(self._executor_states),
"iteration_count": self._iteration_count,
"max_iterations": self._max_iterations,
}
async def set_checkpoint_state(self, state: CheckpointState) -> None:
self._messages.clear()
messages_data = state.get("messages", {})
for source_id, message_list in messages_data.items():
self._messages[source_id] = [
Message(
data=_decode_checkpoint_value(msg.get("data")),
source_id=msg.get("source_id", ""),
target_id=msg.get("target_id"),
trace_contexts=msg.get("trace_contexts"),
source_span_ids=msg.get("source_span_ids"),
)
for msg in message_list
]
# Restore shared_state
decoded_shared_raw = _decode_checkpoint_value(state.get("shared_state", {}))
if isinstance(decoded_shared_raw, dict):
self._shared_state = cast(dict[str, Any], decoded_shared_raw)
else: # fallback to empty dict if corrupted
self._shared_state = {}
# Restore executor_states ensuring value types are dicts
decoded_exec_raw = _decode_checkpoint_value(state.get("executor_states", {}))
if isinstance(decoded_exec_raw, dict):
typed_exec: dict[str, dict[str, Any]] = {}
for k_raw, v_raw in decoded_exec_raw.items(): # type: ignore[assignment]
if isinstance(k_raw, str) and isinstance(v_raw, dict):
# Filter inner dict to string keys only (best-effort)
inner: dict[str, Any] = {}
for inner_k, inner_v in v_raw.items(): # type: ignore[assignment]
if isinstance(inner_k, str):
inner[inner_k] = inner_v
typed_exec[k_raw] = inner
self._executor_states = typed_exec
else:
self._executor_states = {}
self._iteration_count = state.get("iteration_count", 0)
self._max_iterations = state.get("max_iterations", 100)
@@ -0,0 +1,198 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sequential builder for agent/executor workflows with shared conversation context.
This module provides a high-level, agent-focused API to assemble a sequential
workflow where:
- Participants are a sequence of AgentProtocol instances or Executors
- A shared conversation context (list[ChatMessage]) is passed along the chain
- Agents append their assistant messages to the context
- Custom executors can transform or summarize and return a refined context
- The workflow finishes with the final context produced by the last participant
Typical wiring:
input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> ... -> participantN -> _EndWithConversation
Notes:
- Participants can mix AgentProtocol and Executor objects
- Agents are auto-wrapped by WorkflowBuilder as AgentExecutor
- AgentExecutor produces AgentExecutorResponse; _ResponseToConversation converts this to list[ChatMessage]
- Non-agent executors must define a handler that consumes `list[ChatMessage]` and sends back
the updated `list[ChatMessage]` via their workflow context
Why include the small internal adapter executors?
- Input normalization ("input-conversation"): ensures the workflow always starts with a
`list[ChatMessage]` regardless of whether callers pass a `str`, a single `ChatMessage`,
or a list. This keeps the first hop strongly typed and avoids boilerplate in participants.
- Agent response adaptation ("to-conversation:<participant>"): agents (via AgentExecutor)
emit `AgentExecutorResponse`. The adapter converts that to a `list[ChatMessage]`
using `full_conversation` so original prompts aren't lost when chaining.
- Result output ("end"): yields the final conversation list and the workflow becomes idle
giving a consistent terminal payload shape for both agents and custom executors.
These adapters are first-class executors by design so they are type-checked at edges,
observable (ExecutorInvoke/Completed events), and easily testable/reusable. Their IDs are
deterministic and self-describing (for example, "to-conversation:writer") to reduce event-log
confusion and to mirror how the concurrent builder uses explicit dispatcher/aggregator nodes.
""" # noqa: E501
import logging
from collections.abc import Sequence
from typing import Any
from agent_framework import AgentProtocol, ChatMessage, Role
from ._checkpoint import CheckpointStorage
from ._executor import (
AgentExecutor,
AgentExecutorResponse,
Executor,
handler,
)
from ._workflow import Workflow, WorkflowBuilder
from ._workflow_context import WorkflowContext
logger = logging.getLogger(__name__)
class _InputToConversation(Executor):
"""Normalizes initial input into a list[ChatMessage] conversation."""
@handler
async def from_str(self, prompt: str, ctx: WorkflowContext[list[ChatMessage]]) -> None:
await ctx.send_message([ChatMessage(Role.USER, text=prompt)])
@handler
async def from_message(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None: # type: ignore[name-defined]
await ctx.send_message([message])
@handler
async def from_messages(self, messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: # type: ignore[name-defined]
# Make a copy to avoid mutation downstream
await ctx.send_message(list(messages))
class _ResponseToConversation(Executor):
"""Converts AgentExecutorResponse to list[ChatMessage] conversation for chaining."""
@handler
async def convert(self, response: AgentExecutorResponse, ctx: WorkflowContext[list[ChatMessage]]) -> None:
# Always use full_conversation; AgentExecutor guarantees it is populated.
if response.full_conversation is None: # Defensive: indicates a contract violation
raise RuntimeError("AgentExecutorResponse.full_conversation missing. AgentExecutor must populate it.")
await ctx.send_message(list(response.full_conversation))
class _EndWithConversation(Executor):
"""Terminates the workflow by emitting the final conversation context."""
@handler
async def end(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any, list[ChatMessage]]) -> None:
await ctx.yield_output(list(conversation))
class SequentialBuilder:
r"""High-level builder for sequential agent/executor workflows with shared context.
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor
- The workflow wires participants in order, passing a list[ChatMessage] down the chain
- Agents append their assistant messages to the conversation
- Custom executors can transform/summarize and return a list[ChatMessage]
- The final output is the conversation produced by the last participant
Usage:
```python
from agent_framework import SequentialBuilder
workflow = SequentialBuilder().participants([agent1, agent2, summarizer_exec]).build()
# Enable checkpoint persistence
workflow = SequentialBuilder().participants([agent1, agent2]).with_checkpointing(storage).build()
```
"""
def __init__(self) -> None:
self._participants: list[AgentProtocol | Executor] = []
self._checkpoint_storage: CheckpointStorage | None = None
def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "SequentialBuilder":
"""Define the ordered participants for this sequential workflow.
Accepts AgentProtocol instances (auto-wrapped as AgentExecutor) or Executor instances.
Raises if empty or duplicates are provided for clarity.
"""
if not participants:
raise ValueError("participants cannot be empty")
# Defensive duplicate detection
seen_agent_ids: set[int] = set()
seen_executor_ids: set[str] = set()
for p in participants:
if isinstance(p, Executor):
if p.id in seen_executor_ids:
raise ValueError(f"Duplicate executor participant detected: id '{p.id}'")
seen_executor_ids.add(p.id)
else:
# Treat non-Executor as agent-like (AgentProtocol). Structural checks can be brittle at runtime.
pid = id(p)
if pid in seen_agent_ids:
raise ValueError("Duplicate agent participant detected (same agent instance provided twice)")
seen_agent_ids.add(pid)
self._participants = list(participants)
return self
def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "SequentialBuilder":
"""Enable checkpointing for the built workflow using the provided storage."""
self._checkpoint_storage = checkpoint_storage
return self
def build(self) -> Workflow:
"""Build and validate the sequential workflow.
Wiring pattern:
- _InputToConversation normalizes the initial input into list[ChatMessage]
- For each participant in order:
- If Agent (or AgentExecutor): pass conversation to the agent, then convert response
to conversation via _ResponseToConversation
- Else (custom Executor): pass conversation directly to the executor
- _EndWithConversation yields the final conversation and the workflow becomes idle
"""
if not self._participants:
raise ValueError("No participants provided. Call .participants([...]) first.")
# Internal nodes
input_conv = _InputToConversation(id="input-conversation")
end = _EndWithConversation(id="end")
builder = WorkflowBuilder()
builder.set_start_executor(input_conv)
# Start of the chain is the input normalizer
prior: Executor | AgentProtocol = input_conv
for p in self._participants:
# Agent-like branch: either explicitly an AgentExecutor or any non-AgentExecutor
if not (isinstance(p, Executor) and not isinstance(p, AgentExecutor)):
# input conversation -> (agent) -> response -> conversation
builder.add_edge(prior, p)
# Give the adapter a deterministic, self-describing id
label: str
label = p.id if isinstance(p, Executor) else getattr(p, "name", None) or p.__class__.__name__
resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}")
builder.add_edge(p, resp_to_conv)
prior = resp_to_conv
elif isinstance(p, Executor):
# Custom executor operates on list[ChatMessage]
builder.add_edge(prior, p)
prior = p
else: # pragma: no cover - defensive
raise TypeError(f"Unsupported participant type: {type(p).__name__}")
# Terminate with the final conversation
builder.add_edge(prior, end)
if self._checkpoint_storage is not None:
builder = builder.with_checkpointing(self._checkpoint_storage)
return builder.build()
@@ -0,0 +1,69 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
class SharedState:
"""A class to manage shared state in a workflow."""
def __init__(self) -> None:
"""Initialize the shared state."""
self._state: dict[str, Any] = {}
self._shared_state_lock = asyncio.Lock()
async def set(self, key: str, value: Any) -> None:
"""Set a value in the shared state."""
async with self._shared_state_lock:
await self.set_within_hold(key, value)
async def get(self, key: str) -> Any:
"""Get a value from the shared state."""
async with self._shared_state_lock:
return await self.get_within_hold(key)
async def has(self, key: str) -> bool:
"""Check if a key exists in the shared state."""
async with self._shared_state_lock:
return await self.has_within_hold(key)
async def delete(self, key: str) -> None:
"""Delete a key from the shared state."""
async with self._shared_state_lock:
await self.delete_within_hold(key)
@asynccontextmanager
async def hold(self) -> AsyncIterator["SharedState"]:
"""Context manager to hold the shared state lock for multiple operations.
Usage:
async with shared_state.hold():
await shared_state.set_within_hold("key", value)
value = await shared_state.get_within_hold("key")
"""
async with self._shared_state_lock:
yield self
# Unsafe methods that don't acquire locks (for use within hold() context)
async def set_within_hold(self, key: str, value: Any) -> None:
"""Set a value without acquiring the lock (unsafe - use within hold() context)."""
self._state[key] = value
async def get_within_hold(self, key: str) -> Any:
"""Get a value without acquiring the lock (unsafe - use within hold() context)."""
if key not in self._state:
raise KeyError(f"Key '{key}' not found in shared state.")
return self._state[key]
async def has_within_hold(self, key: str) -> bool:
"""Check if a key exists without acquiring the lock (unsafe - use within hold() context)."""
return key in self._state
async def delete_within_hold(self, key: str) -> None:
"""Delete a key without acquiring the lock (unsafe - use within hold() context)."""
if key in self._state:
del self._state[key]
else:
raise KeyError(f"Key '{key}' not found in shared state.")
@@ -0,0 +1,155 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import Mapping
from dataclasses import fields, is_dataclass
from types import UnionType
from typing import Any, Union, get_args, get_origin
logger = logging.getLogger(__name__)
def _coerce_to_type(value: Any, target_type: type) -> Any | None:
"""Best-effort conversion of value into target_type."""
if isinstance(value, target_type):
return value
# Convert dataclass instances or objects with __dict__ into dict first
if not isinstance(value, dict):
if is_dataclass(value):
value = {f.name: getattr(value, f.name) for f in fields(value)}
else:
value_dict = getattr(value, "__dict__", None)
if isinstance(value_dict, dict):
value = dict(value_dict)
if isinstance(value, dict):
ctor_kwargs: dict[str, Any] = dict(value)
if is_dataclass(target_type):
field_names = {f.name for f in fields(target_type)}
ctor_kwargs = {k: v for k, v in value.items() if k in field_names}
try:
return target_type(**ctor_kwargs) # type: ignore[arg-type]
except TypeError as exc:
logger.debug(f"_coerce_to_type could not call {target_type.__name__}(**..): {exc}")
except Exception as exc: # pragma: no cover - unexpected constructor failure
logger.warning(
f"_coerce_to_type encountered unexpected error calling {target_type.__name__} constructor: {exc}"
)
try:
instance: Any = object.__new__(target_type)
except Exception as exc: # pragma: no cover - pathological type
logger.debug(f"_coerce_to_type could not allocate {target_type.__name__} without __init__: {exc}")
return None
for key, val in value.items():
try:
setattr(instance, key, val)
except Exception as exc:
logger.debug(
f"_coerce_to_type could not set {target_type.__name__}.{key} during fallback assignment: {exc}"
)
continue
return instance
return None
def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool:
"""Check if the data is an instance of the target type.
Args:
data (Any): The data to check.
target_type (type): The type to check against.
Returns:
bool: True if data is an instance of target_type, False otherwise.
"""
# Case 0: target_type is Any - always return True
if target_type is Any:
return True
origin = get_origin(target_type)
args = get_args(target_type)
# Case 1: origin is None, meaning target_type is not a generic type
if origin is None:
return isinstance(data, target_type)
# Case 2: target_type is Optional[T] or Union[T1, T2, ...]
# Optional[T] is really just as Union[T, None]
if origin is UnionType:
return any(is_instance_of(data, arg) for arg in args)
# Case 2b: Handle typing.Union (legacy Union syntax)
if origin is Union:
return any(is_instance_of(data, arg) for arg in args)
# Case 3: target_type is a generic type
if origin in [list, set]:
return isinstance(data, origin) and (
not args or all(any(is_instance_of(item, arg) for arg in args) for item in data)
) # type: ignore
# Case 4: target_type is a tuple
if origin is tuple:
if len(args) == 2 and args[1] is Ellipsis: # Tuple[T, ...] case
element_type = args[0]
return isinstance(data, tuple) and all(is_instance_of(item, element_type) for item in data)
if len(args) == 1 and args[0] is Ellipsis: # Tuple[...] case
return isinstance(data, tuple)
if len(args) == 0:
return isinstance(data, tuple)
return (
isinstance(data, tuple)
and len(data) == len(args) # type: ignore
and all(is_instance_of(item, arg) for item, arg in zip(data, args, strict=False)) # type: ignore
)
# Case 5: target_type is a dict
if origin is dict:
return isinstance(data, dict) and (
not args
or all(
is_instance_of(key, args[0]) and is_instance_of(value, args[1])
for key, value in data.items() # type: ignore
)
)
# Case 6: target_type is RequestResponse[T, U] - validate generic parameters
if origin and hasattr(origin, "__name__") and origin.__name__ == "RequestResponse":
if not isinstance(data, origin):
return False
# Validate generic parameters for RequestResponse[TRequest, TResponse]
if len(args) >= 2:
request_type, response_type = args[0], args[1]
# Check if the original_request matches TRequest and data matches TResponse
if (
hasattr(data, "original_request")
and data.original_request is not None
and not is_instance_of(data.original_request, request_type)
):
# Checkpoint decoding can leave original_request as a plain mapping. In that
# case we coerce it back into the expected request type so downstream handlers
# and validators still receive a fully typed RequestResponse instance.
original_request = data.original_request
if isinstance(original_request, Mapping):
coerced = _coerce_to_type(dict(original_request), request_type)
if coerced is None or not isinstance(coerced, request_type):
return False
data.original_request = coerced
else:
return False
if hasattr(data, "data") and data.data is not None and not is_instance_of(data.data, response_type):
return False
return True
# Case 7: Other custom generic classes - check origin type only
# For generic classes, we check if data is an instance of the origin type
# We don't validate the generic parameters at runtime since that's handled by type system
if origin and hasattr(origin, "__name__"):
return isinstance(data, origin)
# Fallback: if we reach here, we assume data is an instance of the target_type
return isinstance(data, target_type)
@@ -0,0 +1,602 @@
# Copyright (c) Microsoft. All rights reserved.
import inspect
import logging
from collections import defaultdict
from collections.abc import Sequence
from enum import Enum
from types import UnionType
from typing import Any, Union, get_args, get_origin
from ._edge import Edge, EdgeGroup, FanInEdgeGroup
from ._executor import Executor, RequestInfoExecutor
logger = logging.getLogger(__name__)
# Track cycle signatures we've already reported to avoid spamming logs when workflows
# with intentional feedback loops are constructed multiple times in the same process.
_LOGGED_CYCLE_SIGNATURES: set[tuple[str, ...]] = set()
# region Enums and Base Classes
class ValidationTypeEnum(Enum):
"""Enumeration of workflow validation types."""
EDGE_DUPLICATION = "EDGE_DUPLICATION"
EXECUTOR_DUPLICATION = "EXECUTOR_DUPLICATION"
TYPE_COMPATIBILITY = "TYPE_COMPATIBILITY"
GRAPH_CONNECTIVITY = "GRAPH_CONNECTIVITY"
HANDLER_OUTPUT_ANNOTATION = "HANDLER_OUTPUT_ANNOTATION"
INTERCEPTOR_CONFLICT = "INTERCEPTOR_CONFLICT"
class WorkflowValidationError(Exception):
"""Base exception for workflow validation errors."""
def __init__(self, message: str, validation_type: ValidationTypeEnum):
super().__init__(message)
self.message = message
self.validation_type = validation_type
def __str__(self) -> str:
return f"[{self.validation_type.value}] {self.message}"
class EdgeDuplicationError(WorkflowValidationError):
"""Exception raised when duplicate edges are detected in the workflow."""
def __init__(self, edge_id: str):
super().__init__(
message=f"Duplicate edge detected: {edge_id}. Each edge in the workflow must be unique.",
validation_type=ValidationTypeEnum.EDGE_DUPLICATION,
)
self.edge_id = edge_id
class ExecutorDuplicationError(WorkflowValidationError):
"""Exception raised when duplicate executor identifiers are detected."""
def __init__(self, executor_id: str):
super().__init__(
message=(
f"Duplicate executor id detected: '{executor_id}'. Executor ids must be globally unique within a "
"workflow."
),
validation_type=ValidationTypeEnum.EXECUTOR_DUPLICATION,
)
self.executor_id = executor_id
class TypeCompatibilityError(WorkflowValidationError):
"""Exception raised when type incompatibility is detected between connected executors."""
def __init__(
self,
source_executor_id: str,
target_executor_id: str,
source_types: list[type[Any]],
target_types: list[type[Any]],
):
# Use a placeholder for incompatible types - will be computed in WorkflowGraphValidator
super().__init__(
message=f"Type incompatibility between executors '{source_executor_id}' -> '{target_executor_id}'. "
f"Source executor outputs types {[str(t) for t in source_types]} but target executor "
f"can only handle types {[str(t) for t in target_types]}.",
validation_type=ValidationTypeEnum.TYPE_COMPATIBILITY,
)
self.source_executor_id = source_executor_id
self.target_executor_id = target_executor_id
self.source_types = source_types
self.target_types = target_types
class GraphConnectivityError(WorkflowValidationError):
"""Exception raised when graph connectivity issues are detected."""
def __init__(self, message: str):
super().__init__(message, validation_type=ValidationTypeEnum.GRAPH_CONNECTIVITY)
class InterceptorConflictError(WorkflowValidationError):
"""Exception raised when multiple executors intercept the same request type from the same sub-workflow."""
def __init__(self, message: str):
super().__init__(message, validation_type=ValidationTypeEnum.INTERCEPTOR_CONFLICT)
# endregion
# region Workflow Graph Validator
class WorkflowGraphValidator:
"""Validator for workflow graphs.
This validator performs multiple validation checks:
1. Edge duplication validation
2. Type compatibility validation between connected executors
3. Graph connectivity validation
"""
def __init__(self) -> None:
self._edges: list[Edge] = []
self._executors: dict[str, Executor] = {}
self._duplicate_executor_ids: set[str] = set()
self._start_executor_ref: Executor | str | None = None
# region Core Validation Methods
def validate_workflow(
self,
edge_groups: Sequence[EdgeGroup],
executors: dict[str, Executor],
start_executor: Executor | str,
*,
duplicate_executor_ids: Sequence[str] | None = None,
) -> None:
"""Validate the entire workflow graph.
Args:
edge_groups: list of edge groups in the workflow
executors: Map of executor IDs to executor instances
start_executor: The starting executor (can be instance or ID)
duplicate_executor_ids: Optional list of known duplicate executor IDs to pre-populate
Raises:
WorkflowValidationError: If any validation fails
"""
self._executors = executors
self._edges = [edge for group in edge_groups for edge in group.edges]
self._edge_groups = edge_groups
self._duplicate_executor_ids = set(duplicate_executor_ids or [])
self._start_executor_ref = start_executor
# If only the start executor exists, add it to the executor map
# Handle the special case where the workflow consists of only a single executor and no edges.
# In this scenario, the executor map will be empty because there are no edge groups to reference executors.
# Adding the start executor to the map ensures that single-executor workflows (without any edges) are supported,
# allowing validation and execution to proceed for workflows that do not require inter-executor communication.
if not self._executors and start_executor and isinstance(start_executor, Executor):
self._executors[start_executor.id] = start_executor
# Validate that start_executor exists in the graph
# It should because we check for it in the WorkflowBuilder
# but we do it here for completeness.
start_executor_id = start_executor.id if isinstance(start_executor, Executor) else start_executor
if start_executor_id not in self._executors:
raise GraphConnectivityError(f"Start executor '{start_executor_id}' is not present in the workflow graph")
# Additional presence verification:
# A start executor that is only injected via the builder (present in the executors map)
# but not referenced by any edge while other executors ARE referenced indicates a
# configuration error: the chosen start node is effectively disconnected / unknown to the
# defined graph topology. For single-node workflows (no edges) we allow the start executor
# to stand alone (handled above when we inject it into the map). We perform this refined
# check only when there is at least one edge group defined.
if self._edges: # Only evaluate when the workflow defines edges
edge_executor_ids: set[str] = set()
for _e in self._edges:
edge_executor_ids.add(_e.source_id)
edge_executor_ids.add(_e.target_id)
if start_executor_id not in edge_executor_ids:
raise GraphConnectivityError(
f"Start executor '{start_executor_id}' is not present in the workflow graph"
)
# Run all checks
self._validate_executor_id_uniqueness(start_executor_id)
self._validate_edge_duplication()
self._validate_handler_output_annotations()
self._validate_type_compatibility()
self._validate_graph_connectivity(start_executor_id)
self._validate_self_loops()
self._validate_dead_ends()
self._validate_cycles()
def _validate_handler_output_annotations(self) -> None:
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
Note: This validation is now primarily handled at handler registration time
via the unified validation functions in _workflow_context.py when the @handler
decorator is applied. This method is kept minimal for any edge cases.
"""
# The comprehensive validation is already done during handler registration:
# 1. @handler decorator calls validate_function_signature()
# 2. FunctionExecutor constructor calls validate_function_signature()
# 3. Both use validate_workflow_context_annotation() for WorkflowContext validation
#
# All executors in the workflow must have gone through one of these paths,
# so redundant validation here is unnecessary and has been removed.
pass
# endregion
def _validate_executor_id_uniqueness(self, start_executor_id: str) -> None:
"""Ensure executor identifiers are unique throughout the workflow graph."""
duplicates: set[str] = set(self._duplicate_executor_ids)
id_counts: defaultdict[str, int] = defaultdict(int)
for key, executor in self._executors.items():
id_counts[executor.id] += 1
if key != executor.id:
duplicates.add(executor.id)
duplicates.update({executor_id for executor_id, count in id_counts.items() if count > 1})
if isinstance(self._start_executor_ref, Executor):
mapped = self._executors.get(start_executor_id)
if mapped is not None and mapped is not self._start_executor_ref:
duplicates.add(start_executor_id)
if duplicates:
raise ExecutorDuplicationError(sorted(duplicates)[0])
# region Edge and Type Validation
def _validate_edge_duplication(self) -> None:
"""Validate that there are no duplicate edges in the workflow.
Raises:
EdgeDuplicationError: If duplicate edges are found
"""
seen_edge_ids: set[str] = set()
for edge in self._edges:
edge_id = edge.id
if edge_id in seen_edge_ids:
raise EdgeDuplicationError(edge_id)
seen_edge_ids.add(edge_id)
def _validate_type_compatibility(self) -> None:
"""Validate type compatibility between connected executors.
This checks that the output types of source executors are compatible
with the input types expected by target executors.
Raises:
TypeCompatibilityError: If type incompatibility is detected
"""
for edge_group in self._edge_groups:
for edge in edge_group.edges:
self._validate_edge_type_compatibility(edge, edge_group)
def _validate_edge_type_compatibility(self, edge: Edge, edge_group: EdgeGroup) -> None:
"""Validate type compatibility for a specific edge.
This checks that the output types of the source executor are compatible
with the input types expected by the target executor.
Args:
edge: The edge to validate
edge_group: The edge group containing this edge
Raises:
TypeCompatibilityError: If type incompatibility is detected
"""
source_executor = self._executors[edge.source_id]
target_executor = self._executors[edge.target_id]
# Get output types from source executor
source_output_types = list(source_executor.output_types)
# Get input types from target executor
target_input_types = target_executor.input_types
# If either executor has no type information, log warning and skip validation
# This allows for dynamic typing scenarios but warns about reduced validation coverage
if not source_output_types or not target_input_types:
# Suppress warnings for RequestInfoExecutor where dynamic typing is expected
if not source_output_types and not isinstance(source_executor, RequestInfoExecutor):
logger.warning(
f"Executor '{source_executor.id}' has no output type annotations. "
f"Type compatibility validation will be skipped for edges from this executor. "
f"Consider adding WorkflowContext[T] generics in handlers for better validation."
)
if not target_input_types and not isinstance(target_executor, RequestInfoExecutor):
logger.warning(
f"Executor '{target_executor.id}' has no input type annotations. "
f"Type compatibility validation will be skipped for edges to this executor. "
f"Consider adding type annotations to message handler parameters for better validation."
)
return
# Check if any source output type is compatible with any target input type
compatible = False
compatible_pairs: list[tuple[type[Any], type[Any]]] = []
for source_type in source_output_types:
for target_type in target_input_types:
if isinstance(edge_group, FanInEdgeGroup):
# If the edge is part of an edge group, the target expects a list of data types
if self._is_type_compatible(list[source_type], target_type): # type: ignore[valid-type]
compatible = True
compatible_pairs.append((list[source_type], target_type)) # type: ignore[valid-type]
else:
if self._is_type_compatible(source_type, target_type):
compatible = True
compatible_pairs.append((source_type, target_type))
# Log successful type compatibility for debugging
if compatible:
logger.debug(
f"Type compatibility validated for edge '{source_executor.id}' -> '{target_executor.id}'. "
f"Compatible type pairs: {[(str(s), str(t)) for s, t in compatible_pairs]}"
)
if not compatible:
# Enhanced error with more detailed information
raise TypeCompatibilityError(
source_executor.id,
target_executor.id,
source_output_types,
target_input_types,
)
# endregion
# region Graph Connectivity Validation
def _validate_graph_connectivity(self, start_executor_id: str) -> None:
"""Validate graph connectivity and detect potential issues.
This performs several checks:
- Detects unreachable executors from the start node
- Detects isolated executors (no incoming or outgoing edges)
- Warns about potential infinite loops
Args:
start_executor_id: The ID of the starting executor
Raises:
GraphConnectivityError: If connectivity issues are detected
"""
# Build adjacency list for the graph
graph: dict[str, list[str]] = defaultdict(list)
all_executors = set(self._executors.keys())
for edge in self._edges:
graph[edge.source_id].append(edge.target_id)
# Find reachable nodes from start
reachable = self._find_reachable_nodes(graph, start_executor_id)
# Check for unreachable executors
unreachable = all_executors - reachable
if unreachable:
raise GraphConnectivityError(
f"The following executors are unreachable from the start executor '{start_executor_id}': "
f"{sorted(unreachable)}. This may indicate a disconnected workflow graph."
)
# Check for isolated executors (no edges)
isolated_executors: list[str] = []
for executor_id in all_executors:
has_incoming = any(edge.target_id == executor_id for edge in self._edges)
has_outgoing = any(edge.source_id == executor_id for edge in self._edges)
if not has_incoming and not has_outgoing and executor_id != start_executor_id:
isolated_executors.append(executor_id)
if isolated_executors:
raise GraphConnectivityError(
f"The following executors are isolated (no incoming or outgoing edges): "
f"{sorted(isolated_executors)}. Isolated executors will never be executed."
)
def _find_reachable_nodes(self, graph: dict[str, list[str]], start: str) -> set[str]:
"""Find all nodes reachable from the start node using DFS.
Args:
graph: Adjacency list representation of the graph
start: Starting node ID
Returns:
Set of reachable node IDs
"""
visited: set[str] = set()
stack = [start]
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
stack.extend(graph[node])
return visited
# endregion
# region Additional Validation Scenarios
def _validate_self_loops(self) -> None:
"""Detect and log self-loops (edges from executor to itself).
Self-loops might indicate recursive processing which could be intentional
but should be highlighted for review.
"""
self_loops = [edge for edge in self._edges if edge.source_id == edge.target_id]
for edge in self_loops:
logger.warning(
f"Self-loop detected: Executor '{edge.source_id}' connects to itself. "
f"This may cause infinite recursion if not properly handled with conditions."
)
def _validate_dead_ends(self) -> None:
"""Identify executors that have no outgoing edges (potential dead ends).
These might be intentional final nodes or could indicate missing connections.
"""
executors_with_outgoing = {edge.source_id for edge in self._edges}
all_executor_ids = set(self._executors.keys())
dead_ends = all_executor_ids - executors_with_outgoing
if dead_ends:
logger.info(
f"Dead-end executors detected (no outgoing edges): {sorted(dead_ends)}. "
f"Verify these are intended as final nodes in the workflow."
)
def _validate_cycles(self) -> None:
"""Detect cycles in the workflow graph.
Cycles might be intentional for iterative processing but should be flagged
for review to ensure proper termination conditions exist. We surface each
distinct cycle group only once per process to avoid noisy, repeated warnings
when rebuilding the same workflow.
"""
# Build adjacency list (ensure every executor appears even if it has no outgoing edges)
graph: dict[str, list[str]] = defaultdict(list)
for edge in self._edges:
graph[edge.source_id].append(edge.target_id)
graph.setdefault(edge.target_id, [])
for executor_id in self._executors:
graph.setdefault(executor_id, [])
# Tarjan's algorithm to locate strongly-connected components that form cycles
index: dict[str, int] = {}
lowlink: dict[str, int] = {}
on_stack: set[str] = set()
stack: list[str] = []
current_index = 0
cycle_components: list[list[str]] = []
def strongconnect(node: str) -> None:
nonlocal current_index
index[node] = current_index
lowlink[node] = current_index
current_index += 1
stack.append(node)
on_stack.add(node)
for neighbor in graph[node]:
if neighbor not in index:
strongconnect(neighbor)
lowlink[node] = min(lowlink[node], lowlink[neighbor])
elif neighbor in on_stack:
lowlink[node] = min(lowlink[node], index[neighbor])
if lowlink[node] == index[node]:
component: list[str] = []
while True:
member = stack.pop()
on_stack.discard(member)
component.append(member)
if member == node:
break
# A strongly connected component represents a cycle if it has more than one
# node or if a single node references itself directly.
if len(component) > 1 or any(member in graph[member] for member in component):
cycle_components.append(component)
for executor_id in graph:
if executor_id not in index:
strongconnect(executor_id)
if not cycle_components:
return
unseen_components: list[list[str]] = []
for component in cycle_components:
signature = tuple(sorted(component))
if signature in _LOGGED_CYCLE_SIGNATURES:
continue
_LOGGED_CYCLE_SIGNATURES.add(signature)
unseen_components.append(component)
if not unseen_components:
# All cycles already reported in this process; keep noise low but retain traceability.
logger.debug(
"Cycle detected in workflow graph but previously reported. Components: %s",
[sorted(component) for component in cycle_components],
)
return
def _format_cycle(component: list[str]) -> str:
if not component:
return ""
ordered = list(component)
ordered.append(component[0])
return " -> ".join(ordered)
formatted_cycles = ", ".join(_format_cycle(component) for component in unseen_components)
logger.warning(
"Cycle detected in the workflow graph involving: %s. Ensure termination or iteration limits exist.",
formatted_cycles,
)
# endregion
# region Type Compatibility Utilities
@staticmethod
def _is_type_compatible(source_type: type[Any], target_type: type[Any]) -> bool:
"""Check if source_type is compatible with target_type."""
# Handle Any type
if source_type is Any or target_type is Any:
return True
# Handle exact match
if source_type == target_type:
return True
# Handle inheritance
try:
if inspect.isclass(source_type) and inspect.isclass(target_type):
return issubclass(source_type, target_type)
except TypeError:
# Handle generic types that can't be used with issubclass
pass
# Handle Union types
source_origin = get_origin(source_type)
target_origin = get_origin(target_type)
if target_origin in (Union, UnionType):
target_args = get_args(target_type)
return any(WorkflowGraphValidator._is_type_compatible(source_type, arg) for arg in target_args)
if source_origin in (Union, UnionType):
source_args = get_args(source_type)
return all(WorkflowGraphValidator._is_type_compatible(arg, target_type) for arg in source_args)
# Handle generic types
if source_origin is not None and target_origin is not None and source_origin == target_origin:
source_args = get_args(source_type)
target_args = get_args(target_type)
if len(source_args) == len(target_args):
return all(
WorkflowGraphValidator._is_type_compatible(s_arg, t_arg)
for s_arg, t_arg in zip(source_args, target_args, strict=True)
)
# No other special compatibility cases
return False
# endregion
# endregion
def validate_workflow_graph(
edge_groups: Sequence[EdgeGroup],
executors: dict[str, Executor],
start_executor: Executor | str,
*,
duplicate_executor_ids: Sequence[str] | None = None,
) -> None:
"""Convenience function to validate a workflow graph.
Args:
edge_groups: list of edge groups in the workflow
executors: Map of executor IDs to executor instances
start_executor: The starting executor (can be instance or ID)
duplicate_executor_ids: Optional list of known duplicate executor IDs to pre-populate
Raises:
WorkflowValidationError: If any validation fails
"""
validator = WorkflowGraphValidator()
validator.validate_workflow(
edge_groups,
executors,
start_executor,
duplicate_executor_ids=duplicate_executor_ids,
)
@@ -0,0 +1,350 @@
# Copyright (c) Microsoft. All rights reserved.
import hashlib
import re
import tempfile
import uuid
from pathlib import Path
from typing import Literal
from ._edge import FanInEdgeGroup
from ._workflow import Workflow
# Import of WorkflowExecutor is performed lazily inside methods to avoid cycles
"""Workflow visualization module using graphviz."""
class WorkflowViz:
"""A class for visualizing workflows using graphviz."""
def __init__(self, workflow: Workflow):
"""Initialize the WorkflowViz with a workflow.
Args:
workflow: The workflow to visualize.
"""
self._workflow = workflow
def to_digraph(self) -> str:
"""Export the workflow as a DOT format digraph string.
Returns:
A string representation of the workflow in DOT format.
"""
lines = ["digraph Workflow {"]
lines.append(" rankdir=TD;") # Top to bottom layout
lines.append(" node [shape=box, style=filled, fillcolor=lightblue];")
lines.append(" edge [color=black, arrowhead=vee];")
lines.append("")
# Emit the top-level workflow nodes/edges
self._emit_workflow_digraph(self._workflow, lines, indent=" ")
# Emit sub-workflows hosted by WorkflowExecutor as nested clusters
self._emit_sub_workflows_digraph(self._workflow, lines, indent=" ")
lines.append("}")
return "\n".join(lines)
def export(self, format: Literal["svg", "png", "pdf", "dot"] = "svg", filename: str | None = None) -> str:
"""Export the workflow visualization to a file or return the file path.
Args:
format: The output format. Supported formats: 'svg', 'png', 'pdf', 'dot'.
filename: Optional filename to save the output. If None, creates a temporary file.
Returns:
The path to the saved file.
Raises:
ImportError: If graphviz is not installed.
ValueError: If an unsupported format is specified.
"""
# Validate format first
if format not in ["svg", "png", "pdf", "dot"]:
raise ValueError(f"Unsupported format: {format}. Supported formats: svg, png, pdf, dot")
if format == "dot":
content = self.to_digraph()
if filename:
with open(filename, "w", encoding="utf-8") as f:
f.write(content)
return filename
# Create temporary file for dot format
with tempfile.NamedTemporaryFile(mode="w", suffix=".dot", delete=False, encoding="utf-8") as temp_file:
temp_file.write(content)
return temp_file.name
try:
import graphviz # type: ignore
except ImportError as e:
raise ImportError(
"viz extra is required for export. Install it with: pip install agent-framework[viz]. "
"You also need to install graphviz separately. E.g., sudo apt-get install graphviz on Debian/Ubuntu "
"or brew install graphviz on macOS. See https://graphviz.org/download/ for details."
) from e
# Create a temporary graphviz Source object
dot_content = self.to_digraph()
source = graphviz.Source(dot_content)
try:
if filename:
# Save to specified file
output_path = Path(filename)
if output_path.suffix and output_path.suffix[1:] != format:
raise ValueError(f"File extension {output_path.suffix} doesn't match format {format}")
# Remove extension if present since graphviz.render() adds it
base_name = str(output_path.with_suffix(""))
source.render(base_name, format=format, cleanup=True)
# Return the actual filename with extension
return f"{base_name}.{format}"
# Create temporary file
with tempfile.NamedTemporaryFile(suffix=f".{format}", delete=False) as temp_file:
temp_path = Path(temp_file.name)
base_name = str(temp_path.with_suffix(""))
source.render(base_name, format=format, cleanup=True)
return f"{base_name}.{format}"
except graphviz.backend.execute.ExecutableNotFound as e:
raise ImportError(
"The graphviz executables are not found. The graphviz Python package is installed, but the "
"graphviz executables (dot, neato, etc.) are not available on your system's PATH. "
"Install graphviz executables: sudo apt-get install graphviz on Debian/Ubuntu, "
"brew install graphviz on macOS, or download from https://graphviz.org/download/ for other platforms."
) from e
def save_svg(self, filename: str) -> str:
"""Convenience method to save as SVG.
Args:
filename: The filename to save the SVG file.
Returns:
The path to the saved SVG file.
"""
return self.export(format="svg", filename=filename)
def save_png(self, filename: str) -> str:
"""Convenience method to save as PNG.
Args:
filename: The filename to save the PNG file.
Returns:
The path to the saved PNG file.
"""
return self.export(format="png", filename=filename)
def save_pdf(self, filename: str) -> str:
"""Convenience method to save as PDF.
Args:
filename: The filename to save the PDF file.
Returns:
The path to the saved PDF file.
"""
return self.export(format="pdf", filename=filename)
def to_mermaid(self) -> str:
"""Export the workflow as a Mermaid flowchart string.
Returns:
A string representation of the workflow in Mermaid flowchart syntax.
"""
def _san(s: str) -> str:
"""Sanitize an ID for Mermaid (alphanumeric and underscore, start with letter)."""
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
if not s2 or not s2[0].isalpha():
s2 = f"n_{s2}"
return s2
lines: list[str] = ["flowchart TD"]
# Emit top-level workflow
self._emit_workflow_mermaid(self._workflow, lines, indent=" ")
# Emit sub-workflows as Mermaid subgraphs
self._emit_sub_workflows_mermaid(self._workflow, lines, indent=" ")
return "\n".join(lines)
# region Private helpers
def _fan_in_digest(self, target: str, sources: list[str]) -> str:
sources_sorted = sorted(sources)
return hashlib.sha256((target + "|" + "|".join(sources_sorted)).encode("utf-8")).hexdigest()[:8]
def _compute_fan_in_descriptors(self, wf: Workflow | None = None) -> list[tuple[str, list[str], str]]:
"""Return list of (node_id, sources, target) for fan-in groups.
node_id is DOT-oriented: fan_in::target::digest
"""
result: list[tuple[str, list[str], str]] = []
workflow = wf or self._workflow
for group in workflow.edge_groups:
if isinstance(group, FanInEdgeGroup):
target = group.target_executor_ids[0]
sources = list(group.source_executor_ids)
digest = self._fan_in_digest(target, sources)
node_id = f"fan_in::{target}::{digest}"
result.append((node_id, sorted(sources), target))
return result
def _compute_normal_edges(self, wf: Workflow | None = None) -> list[tuple[str, str, bool]]:
"""Return list of (source_id, target_id, is_conditional) for non-fan-in groups."""
edges: list[tuple[str, str, bool]] = []
workflow = wf or self._workflow
for group in workflow.edge_groups:
if isinstance(group, FanInEdgeGroup):
continue
for edge in group.edges:
is_cond = getattr(edge, "_condition", None) is not None
edges.append((edge.source_id, edge.target_id, is_cond))
return edges
# endregion
# region Internal emitters (DOT)
def _emit_workflow_digraph(self, wf: Workflow, lines: list[str], indent: str, ns: str | None = None) -> None:
"""Emit DOT nodes/edges for the given workflow.
If ns (namespace) is provided, node ids are prefixed with f"{ns}/" for uniqueness,
but labels remain the original executor ids.
"""
def map_id(x: str) -> str:
return f"{ns}/{x}" if ns else x
# Nodes
start_executor_id = wf.start_executor_id
lines.append(
f'{indent}"{map_id(start_executor_id)}" [fillcolor=lightgreen, label="{start_executor_id}\\n(Start)"];'
)
for executor_id in wf.executors:
if executor_id != start_executor_id:
lines.append(f'{indent}"{map_id(executor_id)}" [label="{executor_id}"];')
# Fan-in nodes
fan_in_nodes = self._compute_fan_in_descriptors(wf)
if fan_in_nodes:
lines.append("")
for node_id, _, _ in fan_in_nodes:
lines.append(f'{indent}"{map_id(node_id)}" [shape=ellipse, fillcolor=lightgoldenrod, label="fan-in"];')
# Fan-in edges
for node_id, sources, target in fan_in_nodes:
for src in sources:
lines.append(f'{indent}"{map_id(src)}" -> "{map_id(node_id)}";')
lines.append(f'{indent}"{map_id(node_id)}" -> "{map_id(target)}";')
# Normal edges
for src, tgt, is_cond in self._compute_normal_edges(wf):
edge_attr = ' [style=dashed, label="conditional"]' if is_cond else ""
lines.append(f'{indent}"{map_id(src)}" -> "{map_id(tgt)}"{edge_attr};')
def _emit_sub_workflows_digraph(self, wf: Workflow, lines: list[str], indent: str) -> None:
"""Emit DOT subgraphs for any WorkflowExecutor instances found in the workflow."""
# Lazy import to avoid any potential import cycles
try:
from ._workflow_executor import WorkflowExecutor # type: ignore
except ImportError: # pragma: no cover - best-effort; if unavailable, skip subgraphs
return
for exec_id, exec_obj in wf.executors.items():
if isinstance(exec_obj, WorkflowExecutor) and hasattr(exec_obj, "workflow") and exec_obj.workflow:
subgraph_id = f"cluster_{uuid.uuid5(uuid.NAMESPACE_OID, exec_id).hex[:8]}"
lines.append(f"{indent}subgraph {subgraph_id} {{")
lines.append(f'{indent} label="sub-workflow: {exec_id}";')
lines.append(f"{indent} style=dashed;")
# Emit the nested workflow inside this cluster using a namespace
ns = exec_id
self._emit_workflow_digraph(exec_obj.workflow, lines, indent=f"{indent} ", ns=ns)
# Recurse into deeper nested sub-workflows
self._emit_sub_workflows_digraph(exec_obj.workflow, lines, indent=f"{indent} ")
lines.append(f"{indent}}}")
# endregion
# region Internal emitters (Mermaid)
def _emit_workflow_mermaid(self, wf: Workflow, lines: list[str], indent: str, ns: str | None = None) -> None:
def _san(s: str) -> str:
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
if not s2 or not s2[0].isalpha():
s2 = f"n_{s2}"
return s2
def map_id(x: str) -> str:
if ns:
return f"{_san(ns)}__{_san(x)}"
return _san(x)
# Nodes
start_executor_id = wf.start_executor_id
lines.append(f'{indent}{map_id(start_executor_id)}["{start_executor_id} (Start)"];')
for executor_id in wf.executors:
if executor_id == start_executor_id:
continue
lines.append(f'{indent}{map_id(executor_id)}["{executor_id}"];')
# Fan-in nodes
fan_in_nodes_dot = self._compute_fan_in_descriptors(wf)
fan_in_nodes: list[tuple[str, list[str], str]] = []
for dot_node_id, sources, target in fan_in_nodes_dot:
digest = dot_node_id.split("::")[-1]
base = f"{target}__{digest}"
fan_node_id = f"fan_in__{_san(ns) + '__' if ns else ''}{_san(base)}"
fan_in_nodes.append((fan_node_id, sources, target))
for fan_node_id, _, _ in fan_in_nodes:
# Keep this line without trailing semicolon to match existing tests
lines.append(f"{indent}{fan_node_id}((fan-in))")
# Fan-in edges
for fan_node_id, sources, target in fan_in_nodes:
for s in sources:
lines.append(f"{indent}{map_id(s)} --> {fan_node_id};")
lines.append(f"{indent}{fan_node_id} --> {map_id(target)};")
# Normal edges
for src, tgt, is_cond in self._compute_normal_edges(wf):
s = map_id(src)
t = map_id(tgt)
if is_cond:
lines.append(f"{indent}{s} -. conditional .-> {t};")
else:
lines.append(f"{indent}{s} --> {t};")
def _emit_sub_workflows_mermaid(self, wf: Workflow, lines: list[str], indent: str) -> None:
try:
from ._workflow_executor import WorkflowExecutor # type: ignore
except ImportError: # pragma: no cover
return
def _san(s: str) -> str:
s2 = re.sub(r"[^0-9A-Za-z_]", "_", s)
if not s2 or not s2[0].isalpha():
s2 = f"n_{s2}"
return s2
for exec_id, exec_obj in wf.executors.items():
if isinstance(exec_obj, WorkflowExecutor) and hasattr(exec_obj, "workflow") and exec_obj.workflow:
sg_id = _san(exec_id)
lines.append(f"{indent}subgraph {sg_id}")
# Render nested workflow within this subgraph using namespacing
self._emit_workflow_mermaid(exec_obj.workflow, lines, indent=f"{indent} ", ns=exec_id)
# Recurse into deeper sub-workflows
self._emit_sub_workflows_mermaid(exec_obj.workflow, lines, indent=f"{indent} ")
lines.append(f"{indent}end")
# endregion
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,447 @@
# Copyright (c) Microsoft. All rights reserved.
import inspect
import logging
from collections.abc import Callable
from types import UnionType
from typing import Any, Generic, Union, cast, get_args, get_origin
from opentelemetry.propagate import inject
from opentelemetry.trace import SpanKind
from typing_extensions import Never, TypeVar
from ..observability import OtelAttr, create_workflow_span
from ._events import (
WorkflowEvent,
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowLifecycleEvent,
WorkflowOutputEvent,
WorkflowStartedEvent,
WorkflowStatusEvent,
WorkflowWarningEvent,
_framework_event_origin,
)
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
T_Out = TypeVar("T_Out", default=Never)
T_W_Out = TypeVar("T_W_Out", default=Never)
logger = logging.getLogger(__name__)
def infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> tuple[list[type[Any]], list[type[Any]]]:
"""Infer message types and workflow output types from the WorkflowContext generic parameters.
Examples:
- WorkflowContext -> ([], [])
- WorkflowContext[str] -> ([str], [])
- WorkflowContext[str, int] -> ([str], [int])
- WorkflowContext[str | int, bool | int] -> ([str, int], [bool, int])
- WorkflowContext[Union[str, int], Union[bool, int]] -> ([str, int], [bool, int])
- WorkflowContext[Any] -> ([Any], [])
- WorkflowContext[Any, Any] -> ([Any], [Any])
- WorkflowContext[Never, Never] -> ([], [])
- WorkflowContext[Never, int] -> ([], [int])
Returns:
Tuple of (message_types, workflow_output_types)
"""
# If no annotation or not parameterized, return empty lists
try:
origin = get_origin(ctx_annotation)
except Exception:
origin = None
# If annotation is unsubscripted WorkflowContext, nothing to infer
if origin is None:
return [], []
# Expecting WorkflowContext[T_Out, T_W_Out]
if origin is not WorkflowContext:
return [], []
args = list(get_args(ctx_annotation))
if not args:
return [], []
# WorkflowContext[T_Out] -> message_types from T_Out, no workflow output types
if len(args) == 1:
t = args[0]
t_origin = get_origin(t)
if t is Any:
return [cast(type[Any], Any)], []
if t_origin in (Union, UnionType):
message_types = [arg for arg in get_args(t) if arg is not Any and arg is not Never]
return message_types, []
if t is Never:
return [], []
return [t], []
# WorkflowContext[T_Out, T_W_Out] -> message_types from T_Out, workflow_output_types from T_W_Out
t_out, t_w_out = args[:2] # Take first two args in case there are more
# Process T_Out for message_types
message_types = []
t_out_origin = get_origin(t_out)
if t_out is Any:
message_types = [cast(type[Any], Any)]
elif t_out is not Never:
if t_out_origin in (Union, UnionType):
message_types = [arg for arg in get_args(t_out) if arg is not Any and arg is not Never]
else:
message_types = [t_out]
# Process T_W_Out for workflow_output_types
workflow_output_types = []
t_w_out_origin = get_origin(t_w_out)
if t_w_out is Any:
workflow_output_types = [cast(type[Any], Any)]
elif t_w_out is not Never:
if t_w_out_origin in (Union, UnionType):
workflow_output_types = [arg for arg in get_args(t_w_out) if arg is not Any and arg is not Never]
else:
workflow_output_types = [t_w_out]
return message_types, workflow_output_types
def _is_workflow_context_type(annotation: Any) -> bool:
"""Check if an annotation represents WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U]."""
origin = get_origin(annotation)
if origin is WorkflowContext:
return True
# Also handle the case where the raw class is used
return annotation is WorkflowContext
def validate_workflow_context_annotation(
annotation: Any,
parameter_name: str,
context_description: str,
) -> tuple[list[type[Any]], list[type[Any]]]:
"""Validate a WorkflowContext annotation and return inferred types.
Args:
annotation: The type annotation to validate
parameter_name: Name of the parameter (for error messages)
context_description: Description of the context (e.g., "Function func1", "Handler method")
Returns:
Tuple of (output_types, workflow_output_types)
Raises:
ValueError: If the annotation is invalid
"""
if annotation == inspect.Parameter.empty:
raise ValueError(
f"{context_description} {parameter_name} must have a WorkflowContext, "
f"WorkflowContext[T] or WorkflowContext[T, U] type annotation, "
f"where T is output message type and U is workflow output type"
)
if not _is_workflow_context_type(annotation):
raise ValueError(
f"{context_description} {parameter_name} must be annotated as "
f"WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U], "
f"got {annotation}"
)
# Validate type arguments for WorkflowContext[T] or WorkflowContext[T, U]
type_args = get_args(annotation)
if len(type_args) > 2:
raise ValueError(
f"{context_description} {parameter_name} must have at most 2 type arguments, "
"WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U], "
f"got {len(type_args)} arguments"
)
if type_args:
# Helper function to check if a value is a valid type annotation
def _is_type_like(x: Any) -> bool:
"""Check if a value is a type-like entity (class, type, or typing construct)."""
return isinstance(x, type) or get_origin(x) is not None or x is Never
for i, type_arg in enumerate(type_args):
param_description = "T_Out" if i == 0 else "T_W_Out"
# Allow Any explicitly
if type_arg is Any:
continue
# Check if it's a union type and validate each member
union_origin = get_origin(type_arg)
if union_origin in (Union, UnionType):
union_members = get_args(type_arg)
invalid_members = [m for m in union_members if not _is_type_like(m) and m is not Any]
if invalid_members:
raise ValueError(
f"{context_description} {parameter_name} {param_description} "
f"contains invalid type entries: {invalid_members}. "
f"Use proper types or typing generics"
)
else:
# Check if it's a valid type
if not _is_type_like(type_arg):
raise ValueError(
f"{context_description} {parameter_name} {param_description} "
f"contains invalid type entry: {type_arg}. "
f"Use proper types or typing generics"
)
return infer_output_types_from_ctx_annotation(annotation)
def validate_function_signature(
func: Callable[..., Any], context_description: str
) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
"""Validate function signature for executor functions.
Args:
func: The function to validate
context_description: Description for error messages (e.g., "Function", "Handler method")
Returns:
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
Raises:
ValueError: If the function signature is invalid
"""
signature = inspect.signature(func)
params = list(signature.parameters.values())
# Determine expected parameter count based on context
expected_counts: tuple[int, ...]
if context_description.startswith("Function"):
# Function executor: (message) or (message, ctx)
expected_counts = (1, 2)
param_description = "(message: T) or (message: T, ctx: WorkflowContext[U])"
else:
# Handler method: (self, message, ctx)
expected_counts = (3,)
param_description = "(self, message: T, ctx: WorkflowContext[U])"
if len(params) not in expected_counts:
raise ValueError(
f"{context_description} {func.__name__} must have {param_description}. Got {len(params)} parameters."
)
# Extract message parameter (index 0 for functions, index 1 for methods)
message_param_idx = 0 if context_description.startswith("Function") else 1
message_param = params[message_param_idx]
# Check message parameter has type annotation
if message_param.annotation == inspect.Parameter.empty:
raise ValueError(f"{context_description} {func.__name__} must have a type annotation for the message parameter")
message_type = message_param.annotation
# Check if there's a context parameter
ctx_param_idx = message_param_idx + 1
if len(params) > ctx_param_idx:
ctx_param = params[ctx_param_idx]
output_types, workflow_output_types = validate_workflow_context_annotation(
ctx_param.annotation, f"parameter '{ctx_param.name}'", context_description
)
ctx_annotation = ctx_param.annotation
else:
# No context parameter (only valid for function executors)
if not context_description.startswith("Function"):
raise ValueError(f"{context_description} {func.__name__} must have a WorkflowContext parameter")
output_types, workflow_output_types = [], []
ctx_annotation = None
return message_type, ctx_annotation, output_types, workflow_output_types
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: tuple[type[WorkflowEvent], ...] = cast(
tuple[type[WorkflowEvent], ...],
tuple(get_args(WorkflowLifecycleEvent))
or (
WorkflowStartedEvent,
WorkflowStatusEvent,
WorkflowFailedEvent,
),
)
class WorkflowContext(Generic[T_Out, T_W_Out]):
"""Execution context that enables executors to interact with workflows and other executors.
## Overview
WorkflowContext provides a controlled interface for executors to send messages, yield outputs,
manage state, and interact with the broader workflow ecosystem. It enforces type safety through
generic parameters while preventing direct access to internal runtime components.
## Type Parameters
The context is parameterized to enforce type safety for different operations:
### WorkflowContext (no parameters)
For executors that only perform side effects without sending messages or yielding outputs:
```python
async def log_handler(message: str, ctx: WorkflowContext) -> None:
print(f"Received: {message}") # Only side effects
```
### WorkflowContext[T_Out]
Enables sending messages of type T_Out to other executors:
```python
async def processor(message: str, ctx: WorkflowContext[int]) -> None:
result = len(message)
await ctx.send_message(result) # Send int to downstream executors
```
### WorkflowContext[T_Out, T_W_Out]
Enables both sending messages (T_Out) and yielding workflow outputs (T_W_Out):
```python
async def dual_output(message: str, ctx: WorkflowContext[int, str]) -> None:
await ctx.send_message(42) # Send int message
await ctx.yield_output("complete") # Yield str workflow output
```
### Union Types
Multiple types can be specified using union notation:
```python
async def flexible(message: str, ctx: WorkflowContext[int | str, bool | dict]) -> None:
await ctx.send_message("text") # or send 42
await ctx.yield_output(True) # or yield {"status": "done"}
```
"""
def __init__(
self,
executor_id: str,
source_executor_ids: list[str],
shared_state: SharedState,
runner_context: RunnerContext,
trace_contexts: list[dict[str, str]] | None = None,
source_span_ids: list[str] | None = None,
):
"""Initialize the executor context with the given workflow context.
Args:
executor_id: The unique identifier of the executor that this context belongs to.
source_executor_ids: The IDs of the source executors that sent messages to this executor.
This is a list to support fan_in scenarios where multiple sources send aggregated
messages to the same executor.
shared_state: The shared state for the workflow.
runner_context: The runner context that provides methods to send messages and events.
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
source_span_ids: Optional source span IDs from multiple sources for linking (not for nesting).
"""
self._executor_id = executor_id
self._source_executor_ids = source_executor_ids
self._runner_context = runner_context
self._shared_state = shared_state
# Store trace contexts and source span IDs for linking (supporting multiple sources)
self._trace_contexts = trace_contexts or []
self._source_span_ids = source_span_ids or []
if not self._source_executor_ids:
raise ValueError("source_executor_ids cannot be empty. At least one source executor ID is required.")
async def send_message(self, message: T_Out, target_id: str | None = None) -> None:
"""Send a message to the workflow context.
Args:
message: The message to send. This must conform to the output type(s) declared on this context.
target_id: The ID of the target executor to send the message to.
If None, the message will be sent to all target executors.
"""
global OBSERVABILITY_SETTINGS
from ..observability import OBSERVABILITY_SETTINGS
# Create publishing span (inherits current trace context automatically)
attributes: dict[str, str] = {OtelAttr.MESSAGE_TYPE: type(message).__name__}
if target_id:
attributes[OtelAttr.MESSAGE_DESTINATION_EXECUTOR_ID] = target_id
with create_workflow_span(OtelAttr.MESSAGE_SEND_SPAN, attributes, kind=SpanKind.PRODUCER) as span:
# Create Message wrapper
msg = Message(data=message, source_id=self._executor_id, target_id=target_id)
# Inject current trace context if tracing enabled
if OBSERVABILITY_SETTINGS.ENABLED and span and span.is_recording(): # type: ignore[name-defined]
trace_context: dict[str, str] = {}
inject(trace_context) # Inject current trace context for message propagation
msg.trace_contexts = [trace_context]
msg.source_span_ids = [format(span.get_span_context().span_id, "016x")]
await self._runner_context.send_message(msg)
async def yield_output(self, output: T_W_Out) -> None:
"""Set the output of the workflow.
Args:
output: The output to yield. This must conform to the workflow output type(s)
declared on this context.
"""
with _framework_event_origin():
event = WorkflowOutputEvent(data=output, source_executor_id=self._executor_id)
await self._runner_context.add_event(event)
async def add_event(self, event: WorkflowEvent) -> None:
"""Add an event to the workflow context."""
if event.origin == WorkflowEventSource.EXECUTOR and isinstance(event, _FRAMEWORK_LIFECYCLE_EVENT_TYPES):
event_name = event.__class__.__name__
warning_msg = (
f"Executor '{self._executor_id}' attempted to emit {event_name}, "
"which is reserved for framework lifecycle notifications. The "
"event was ignored."
)
logger.warning(warning_msg)
await self._runner_context.add_event(WorkflowWarningEvent(warning_msg))
return
await self._runner_context.add_event(event)
async def get_shared_state(self, key: str) -> Any:
"""Get a value from the shared state."""
return await self._shared_state.get(key)
async def set_shared_state(self, key: str, value: Any) -> None:
"""Set a value in the shared state."""
await self._shared_state.set(key, value)
def get_source_executor_id(self) -> str:
"""Get the ID of the source executor that sent the message to this executor.
Raises:
RuntimeError: If there are multiple source executors, this method raises an error.
"""
if len(self._source_executor_ids) > 1:
raise RuntimeError(
"Cannot get source executor ID when there are multiple source executors. "
"Access the full list via the source_executor_ids property instead."
)
return self._source_executor_ids[0]
@property
def source_executor_ids(self) -> list[str]:
"""Get the IDs of the source executors that sent messages to this executor."""
return self._source_executor_ids
@property
def shared_state(self) -> SharedState:
"""Get the shared state."""
return self._shared_state
async def set_state(self, state: dict[str, Any]) -> None:
"""Persist this executor's state into the checkpointable context.
Executors call this with a JSON-serializable dict capturing the minimal
state needed to resume. It replaces any previously stored state.
"""
if hasattr(self._runner_context, "set_state"):
await self._runner_context.set_state(self._executor_id, state) # type: ignore[arg-type]
async def get_state(self) -> dict[str, Any] | None:
"""Retrieve previously persisted state for this executor, if any."""
if hasattr(self._runner_context, "get_state"):
return await self._runner_context.get_state(self._executor_id) # type: ignore[return-value]
return None
@@ -0,0 +1,659 @@
# Copyright (c) Microsoft. All rights reserved.
import contextlib
import inspect
import logging
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ._workflow import Workflow
from ._events import (
RequestInfoEvent,
WorkflowErrorEvent,
WorkflowFailedEvent,
WorkflowRunState,
)
from ._executor import (
Executor,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
handler,
)
from ._typing_utils import is_instance_of
from ._workflow_context import WorkflowContext
logger = logging.getLogger(__name__)
@dataclass
class ExecutionContext:
"""Context for tracking a single sub-workflow execution."""
execution_id: str
collected_responses: dict[str, Any] # request_id -> response_data
expected_response_count: int
pending_requests: dict[str, Any] # request_id -> original request data
class WorkflowExecutor(Executor):
"""An executor that wraps a workflow to enable hierarchical workflow composition.
## Overview
WorkflowExecutor makes a workflow behave as a single executor within a parent workflow,
enabling nested workflow architectures. It handles the complete lifecycle of sub-workflow
execution including event processing, output forwarding, and request/response coordination
between parent and child workflows.
## Execution Model
When invoked, WorkflowExecutor:
1. Starts the wrapped workflow with the input message
2. Runs the sub-workflow to completion or until it needs external input
3. Processes the sub-workflow's complete event stream after execution
4. Forwards outputs to the parent workflow's event stream
5. Handles external requests by routing them to the parent workflow
6. Accumulates responses and resumes sub-workflow execution
## Event Stream Processing
WorkflowExecutor processes events after sub-workflow completion:
### Output Forwarding
All outputs from the sub-workflow are automatically forwarded to the parent:
```python
# Sub-workflow yields outputs
await ctx.yield_output("sub-workflow result")
# WorkflowExecutor forwards to parent via ctx.send_message()
# Parent receives the output as a regular message
```
### Request/Response Coordination
When sub-workflows need external information:
```python
# Sub-workflow makes request
request = MyDataRequest(query="user info")
# RequestInfoExecutor emits RequestInfoEvent
# WorkflowExecutor sets source_executor_id and forwards to parent
request.source_executor_id = "child_workflow_executor_id"
# Parent workflow can handle via @handler for RequestInfoMessage subclasses,
# or directly forward to external source via a RequestInfoExecutor in the parent
# workflow.
```
### State Management
WorkflowExecutor maintains execution state across request/response cycles:
- Tracks pending requests by request_id
- Accumulates responses until all expected responses are received
- Resumes sub-workflow execution with complete response batch
- Handles concurrent executions and multiple pending requests
## Type System Integration
WorkflowExecutor inherits its type signature from the wrapped workflow:
### Input Types
Matches the wrapped workflow's start executor input types:
```python
# If sub-workflow accepts str, WorkflowExecutor accepts str
workflow_executor = WorkflowExecutor(my_workflow, id="wrapper")
assert workflow_executor.input_types == my_workflow.input_types
```
### Output Types
Combines sub-workflow outputs with request coordination types:
```python
# Includes all sub-workflow output types
# Plus RequestInfoMessage if sub-workflow can make requests
output_types = workflow.output_types + [RequestInfoMessage] # if applicable
```
## Error Handling
WorkflowExecutor propagates sub-workflow failures:
- Captures WorkflowFailedEvent from sub-workflow
- Converts to WorkflowErrorEvent in parent context
- Provides detailed error information including sub-workflow ID
## Concurrent Execution Support
WorkflowExecutor fully supports multiple concurrent sub-workflow executions:
### Per-Execution State Isolation
Each sub-workflow invocation creates an isolated ExecutionContext:
```python
# Multiple concurrent invocations are supported
workflow_executor = WorkflowExecutor(my_workflow, id="concurrent_executor")
# Each invocation gets its own execution context
# Execution 1: processes input_1 independently
# Execution 2: processes input_2 independently
# No state interference between executions
```
### Request/Response Coordination
Responses are correctly routed to the originating execution:
- Each execution tracks its own pending requests and expected responses
- Request-to-execution mapping ensures responses reach the correct sub-workflow
- Response accumulation is isolated per execution
- Automatic cleanup when execution completes
### Memory Management
- Unlimited concurrent executions supported
- Each execution has unique UUID-based identification
- Cleanup of completed execution contexts
- Thread-safe state management for concurrent access
### Important Considerations
**Shared Workflow Instance**: All concurrent executions use the same underlying workflow instance.
For proper isolation, ensure that:
- The wrapped workflow and its executors are stateless
- Executors use WorkflowContext state management instead of instance variables
- Any shared state is managed through WorkflowContext.get_shared_state/set_shared_state
```python
# Good: Stateless executor using context state
class StatelessExecutor(Executor):
@handler
async def process(self, data: str, ctx: WorkflowContext[str]) -> None:
# Use context state instead of instance variables
state = await ctx.get_state() or {}
state["processed"] = data
await ctx.set_state(state)
# Avoid: Stateful executor with instance variables
class StatefulExecutor(Executor):
def __init__(self):
super().__init__(id="stateful")
self.data = [] # This will be shared across concurrent executions!
```
## Integration with Parent Workflows
Parent workflows can intercept sub-workflow requests:
```python
class ParentExecutor(Executor):
@handler
async def handle_request(
self,
request: MyRequestType, # Subclass of RequestInfoMessage
ctx: WorkflowContext[RequestResponse[RequestInfoMessage, Any] | RequestInfoMessage],
) -> None:
# Handle request locally or forward to external source
if self.can_handle_locally(request):
# Send response back to sub-workflow
response = RequestResponse(data="local result", original_request=request, request_id=request.request_id)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
# Forward to external handler
await ctx.send_message(request)
```
## Implementation Notes
- Sub-workflows run to completion before processing their results
- Event processing is atomic - all outputs are forwarded before requests
- Response accumulation ensures sub-workflows receive complete response batches
- Execution state is maintained for proper resumption after external requests
- Concurrent executions are fully isolated and do not interfere with each other
"""
def __init__(self, workflow: "Workflow", id: str, **kwargs: Any):
"""Initialize the WorkflowExecutor.
Args:
workflow: The workflow to execute as a sub-workflow.
id: Unique identifier for this executor.
**kwargs: Additional keyword arguments passed to the parent constructor.
"""
super().__init__(id, **kwargs)
self.workflow = workflow
# Track execution contexts for concurrent sub-workflow executions
self._execution_contexts: dict[str, ExecutionContext] = {} # execution_id -> ExecutionContext
# Map request_id to execution_id for response routing
self._request_to_execution: dict[str, str] = {} # request_id -> execution_id
self._active_executions: int = 0 # Count of active sub-workflow executions
self._state_loaded: bool = False
@property
def input_types(self) -> list[type[Any]]:
"""Get the input types based on the underlying workflow's input types plus WorkflowExecutor-specific types.
Returns:
A list of input types that the WorkflowExecutor can accept.
"""
input_types = list(self.workflow.input_types)
# WorkflowExecutor can also handle RequestResponse for sub-workflow responses
if RequestResponse not in input_types:
input_types.append(RequestResponse)
return input_types
@property
def output_types(self) -> list[type[Any]]:
"""Get the output types based on the underlying workflow's output types.
Returns:
A list of output types that the underlying workflow can produce.
Includes specific RequestInfoMessage subtypes if the sub-workflow contains RequestInfoExecutor.
"""
output_types = list(self.workflow.output_types)
# Check if the sub-workflow contains a RequestInfoExecutor
# If so, collect the specific RequestInfoMessage subtypes from all executors
has_request_info_executor = any(
isinstance(executor, RequestInfoExecutor) for executor in self.workflow.executors.values()
)
if has_request_info_executor:
# Collect all RequestInfoMessage subtypes from executor output types
for executor in self.workflow.executors.values():
for output_type in executor.output_types:
# Check if this is a RequestInfoMessage subclass
if (
inspect.isclass(output_type)
and issubclass(output_type, RequestInfoMessage)
and output_type not in output_types
):
output_types.append(output_type)
return output_types
def to_dict(self) -> dict[str, Any]:
data = super().to_dict()
data["workflow"] = self.workflow.to_dict()
return data
def can_handle(self, message: Any) -> bool:
"""Override can_handle to only accept messages that the wrapped workflow can handle.
This prevents the WorkflowExecutor from accepting messages that should go to other
executors (like RequestInfoExecutor).
"""
# Always handle RequestResponse (for the handle_response handler)
if isinstance(message, RequestResponse):
return True
# For other messages, only handle if the wrapped workflow can accept them as input
return any(is_instance_of(message, input_type) for input_type in self.workflow.input_types)
@handler # No output_types - can send any completion data type
async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any]) -> None:
"""Execute the sub-workflow with raw input data.
This handler starts a new sub-workflow execution. When the sub-workflow
needs external information, it pauses and sends a request to the parent.
Args:
input_data: The input data to send to the sub-workflow.
ctx: The workflow context from the parent.
"""
# Skip RequestResponse - it has a specific handler
if isinstance(input_data, RequestResponse):
logger.debug(f"WorkflowExecutor {self.id} ignoring input of type {type(input_data)}")
return
await self._ensure_state_loaded(ctx)
# Create execution context for this sub-workflow run
execution_id = str(uuid.uuid4())
execution_context = ExecutionContext(
execution_id=execution_id,
collected_responses={},
expected_response_count=0,
pending_requests={},
)
self._execution_contexts[execution_id] = execution_context
# Track this execution
self._active_executions += 1
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id} execution {execution_id}")
try:
# Run the sub-workflow and collect all events
result = await self.workflow.run(input_data)
logger.debug(
f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} "
f"execution {execution_id} completed with {len(result)} events"
)
# Process the workflow result using shared logic
await self._process_workflow_result(result, execution_context, ctx)
finally:
# Clean up execution context if it's completed (no pending requests)
if execution_id in self._execution_contexts:
exec_ctx = self._execution_contexts[execution_id]
if not exec_ctx.pending_requests:
del self._execution_contexts[execution_id]
self._active_executions -= 1
async def _process_workflow_result(
self, result: Any, execution_context: ExecutionContext, ctx: WorkflowContext[Any]
) -> None:
"""Process the result from a workflow execution.
This method handles the common logic for processing outputs, request info events,
and final states that is shared between process_workflow and handle_response.
Args:
result: The workflow execution result.
execution_context: The execution context for this sub-workflow run.
ctx: The workflow context.
"""
# Collect all events from the workflow
request_info_events = result.get_request_info_events()
outputs = result.get_outputs()
final_state = result.get_final_state()
logger.debug(
f"WorkflowExecutor {self.id} processing workflow result with "
f"{len(outputs)} outputs and {len(request_info_events)} request info events, "
f"final state: {final_state}"
)
# Process outputs
for output in outputs:
await ctx.send_message(output)
# Process request info events
for event in request_info_events:
# Track the pending request in execution context
execution_context.pending_requests[event.request_id] = event.data
# Map request to execution for response routing
self._request_to_execution[event.request_id] = execution_context.execution_id
# Set source_executor_id for response routing and send to parent
if not isinstance(event.data, RequestInfoMessage):
raise TypeError(f"Expected RequestInfoMessage, got {type(event.data)}")
# Set the source_executor_id to this WorkflowExecutor's ID for response routing
event.data.source_executor_id = self.id
await ctx.send_message(event.data)
# Update expected response count for this execution
execution_context.expected_response_count = len(request_info_events)
# Handle final state
if final_state == WorkflowRunState.FAILED:
# Find the WorkflowFailedEvent.
failed_events = [e for e in result if isinstance(e, WorkflowFailedEvent)]
if failed_events:
failed_event = failed_events[0]
error_type = failed_event.details.error_type
error_message = failed_event.details.message
exception = Exception(
f"Sub-workflow {self.workflow.id} failed with error: {error_type} - {error_message}"
)
error_event = WorkflowErrorEvent(
data=exception,
)
await ctx.add_event(error_event)
self._active_executions -= 1
elif final_state == WorkflowRunState.IDLE:
# Sub-workflow is idle - nothing more to do now
logger.debug(f"Sub-workflow {self.workflow.id} is idle with {self._active_executions} active executions")
self._active_executions -= 1 # Treat idle as completion for now
elif final_state == WorkflowRunState.CANCELLED:
# Sub-workflow was cancelled - treat as completion
logger.debug(
f"Sub-workflow {self.workflow.id} was cancelled with {self._active_executions} active executions"
)
self._active_executions -= 1
elif final_state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
# Sub-workflow is still running with pending requests
logger.debug(
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} "
f"pending requests with {self._active_executions} active executions"
)
elif final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
# Sub-workflow is idle but has pending requests
logger.debug(
f"Sub-workflow {self.workflow.id} is idle with pending requests: "
f"{len(request_info_events)} with {self._active_executions} active executions"
)
else:
raise RuntimeError(f"Unexpected final state: {final_state}")
await self._persist_execution_state(ctx)
@handler
async def handle_response(
self,
response: RequestResponse[RequestInfoMessage, Any],
ctx: WorkflowContext[Any],
) -> None:
"""Handle response from parent for a forwarded request.
This handler accumulates responses and only resumes the sub-workflow
when all expected responses have been received for that execution.
Args:
response: The response to a previous request.
ctx: The workflow context.
"""
await self._ensure_state_loaded(ctx)
# Find the execution context for this request
execution_id = self._request_to_execution.get(response.request_id)
if not execution_id or execution_id not in self._execution_contexts:
logger.warning(
f"WorkflowExecutor {self.id} received response for unknown request_id: {response.request_id}, ignoring"
)
return
execution_context = self._execution_contexts[execution_id]
# Check if we have this pending request in the execution context
if response.request_id not in execution_context.pending_requests:
logger.warning(
f"WorkflowExecutor {self.id} received response for unknown request_id: "
f"{response.request_id} in execution {execution_id}, ignoring"
)
return
# Remove the request from pending list and request mapping
execution_context.pending_requests.pop(response.request_id, None)
self._request_to_execution.pop(response.request_id, None)
# Accumulate the response in this execution's context
execution_context.collected_responses[response.request_id] = response.data
await self._persist_execution_state(ctx)
# Check if we have all expected responses for this execution
if len(execution_context.collected_responses) < execution_context.expected_response_count:
logger.debug(
f"WorkflowExecutor {self.id} execution {execution_id} waiting for more responses: "
f"{len(execution_context.collected_responses)}/{execution_context.expected_response_count} received"
)
return # Wait for more responses
# Send all collected responses to the sub-workflow
responses_to_send = dict(execution_context.collected_responses)
execution_context.collected_responses.clear() # Clear for next batch
try:
# Resume the sub-workflow with all collected responses
result = await self.workflow.send_responses(responses_to_send)
# Process the workflow result using shared logic
await self._process_workflow_result(result, execution_context, ctx)
finally:
# Clean up execution context if it's completed (no pending requests)
if not execution_context.pending_requests:
del self._execution_contexts[execution_id]
self._active_executions -= 1
async def _ensure_state_loaded(self, ctx: WorkflowContext[Any]) -> None:
if self._state_loaded:
return
state: dict[str, Any] | None = None
try:
state = await ctx.get_state()
except Exception:
state = None
if isinstance(state, dict) and state:
with contextlib.suppress(Exception):
self.restore_state(state)
self._state_loaded = True
else:
self._state_loaded = True
def restore_state(self, state: dict[str, Any]) -> None:
"""Restore pending request bookkeeping from a checkpoint snapshot."""
self._execution_contexts = {}
self._request_to_execution = {}
executions_payload = state.get("executions")
if isinstance(executions_payload, Mapping) and executions_payload:
for execution_id, payload in executions_payload.items():
if not isinstance(execution_id, str) or not isinstance(payload, Mapping):
continue
pending_ids_raw = payload.get("pending_request_ids", [])
if not isinstance(pending_ids_raw, list):
continue
pending_ids = [rid for rid in pending_ids_raw if isinstance(rid, str)]
expected = payload.get("expected_response_count", len(pending_ids))
try:
expected_count = int(expected)
except (TypeError, ValueError):
expected_count = len(pending_ids)
collected_ids_raw = payload.get("collected_response_ids", [])
collected: dict[str, Any] = {}
if isinstance(collected_ids_raw, list):
for rid in collected_ids_raw:
if isinstance(rid, str):
collected[rid] = None
exec_ctx = ExecutionContext(
execution_id=execution_id,
collected_responses=collected,
expected_response_count=expected_count,
pending_requests={rid: None for rid in pending_ids},
)
if exec_ctx.pending_requests or exec_ctx.collected_responses:
self._execution_contexts[execution_id] = exec_ctx
for rid in exec_ctx.pending_requests:
self._request_to_execution[rid] = execution_id
else:
pending_ids = state.get("pending_request_ids", [])
if isinstance(pending_ids, list):
pending = [rid for rid in pending_ids if isinstance(rid, str)]
if pending:
try:
expected = int(state.get("expected_response_count", len(pending)))
except (TypeError, ValueError):
expected = len(pending)
execution_id = str(uuid.uuid4())
exec_ctx = ExecutionContext(
execution_id=execution_id,
collected_responses={},
expected_response_count=expected,
pending_requests={rid: None for rid in pending},
)
self._execution_contexts[execution_id] = exec_ctx
for rid in pending:
self._request_to_execution[rid] = execution_id
try:
self._active_executions = int(state.get("active_executions", len(self._execution_contexts)))
except (TypeError, ValueError):
self._active_executions = len(self._execution_contexts)
helper_states = state.get("request_info_executor_states", {})
restored_request_data: dict[str, RequestInfoMessage] = {}
if isinstance(helper_states, Mapping):
for exec_id, helper_state in helper_states.items():
helper_executor = self.workflow.executors.get(exec_id)
if not isinstance(helper_executor, RequestInfoExecutor) or not isinstance(helper_state, Mapping):
continue
with contextlib.suppress(Exception):
helper_executor.restore_state(dict(helper_state))
for req_id, event in getattr(helper_executor, "_request_events", {}).items(): # type: ignore[attr-defined]
if (
isinstance(req_id, str)
and isinstance(event, RequestInfoEvent)
and isinstance(event.data, RequestInfoMessage)
):
restored_request_data[req_id] = event.data
if restored_request_data:
for req_id, data in restored_request_data.items():
execution_id = self._request_to_execution.get(req_id)
if execution_id and execution_id in self._execution_contexts:
self._execution_contexts[execution_id].pending_requests[req_id] = data
for execution_id, exec_ctx in self._execution_contexts.items():
for req_id in exec_ctx.pending_requests:
self._request_to_execution.setdefault(req_id, execution_id)
request_map = state.get("request_to_execution")
if isinstance(request_map, Mapping):
for req_id, execution_id in request_map.items():
if (
isinstance(req_id, str)
and isinstance(execution_id, str)
and execution_id in self._execution_contexts
):
self._request_to_execution.setdefault(req_id, execution_id)
self._state_loaded = True
def _build_state_snapshot(self) -> dict[str, Any]:
executions: dict[str, Any] = {}
pending_request_ids: list[str] = []
for execution_id, exec_ctx in self._execution_contexts.items():
if not exec_ctx.pending_requests and not exec_ctx.collected_responses:
continue
request_ids = list(exec_ctx.pending_requests.keys())
pending_request_ids.extend(request_ids)
summary: dict[str, Any] = {
"pending_request_ids": request_ids,
"expected_response_count": exec_ctx.expected_response_count,
}
if exec_ctx.collected_responses:
summary["collected_response_ids"] = list(exec_ctx.collected_responses.keys())
executions[execution_id] = summary
helper_states: dict[str, Any] = {}
for exec_id, executor in self.workflow.executors.items():
if isinstance(executor, RequestInfoExecutor):
with contextlib.suppress(Exception):
snapshot = executor.snapshot_state()
if snapshot:
helper_states[exec_id] = snapshot
has_state = bool(executions or helper_states or self._request_to_execution)
if not has_state:
return {}
state: dict[str, Any] = {
"executions": executions,
"request_to_execution": dict(self._request_to_execution),
"pending_request_ids": pending_request_ids,
"active_executions": self._active_executions,
}
if helper_states:
state["request_info_executor_states"] = helper_states
return state
async def _persist_execution_state(self, ctx: WorkflowContext[Any]) -> None:
snapshot = self._build_state_snapshot()
try:
await ctx.set_state(snapshot)
except Exception as exc: # pragma: no cover - transport specific
logger.warning(f"WorkflowExecutor {self.id} failed to persist state: {exc}")
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_a2a"
PACKAGE_EXTRA = "a2a"
_IMPORTS = ["__version__", "A2AAgent"]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(PACKAGE_NAME), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_EXTRA}' extra is not installed, "
f"please do `pip install agent-framework[{PACKAGE_EXTRA}]`"
) from exc
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_a2a import A2AAgent, __version__
__all__ = ["A2AAgent", "__version__"]
@@ -0,0 +1,33 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
_IMPORTS: dict[str, tuple[str, list[str]]] = {
"AzureAIAgentClient": ("agent_framework_azure_ai", ["azure_ai", "azure"]),
"AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", []),
"AzureOpenAIChatClient": ("agent_framework.azure._chat_client", []),
"AzureAISettings": ("agent_framework_azure_ai", ["azure_ai", "azure"]),
"AzureOpenAISettings": ("agent_framework.azure._shared", []),
"AzureOpenAIResponsesClient": ("agent_framework.azure._responses_client", []),
"get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", []),
}
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
package_name, package_extra = _IMPORTS[name]
try:
return getattr(importlib.import_module(package_name), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The {' or '.join(package_extra)} extra is not installed, "
f"please use `pip install agent-framework[{package_extra[0]}]`, "
"or update your requirements.txt or pyproject.toml file."
) from exc
raise AttributeError(f"Module `azure` has no attribute {name}.")
def __dir__() -> list[str]:
return list(_IMPORTS.keys())
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings
from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient
from agent_framework.azure._chat_client import AzureOpenAIChatClient
from agent_framework.azure._entra_id_authentication import get_entra_auth_token
from agent_framework.azure._responses_client import AzureOpenAIResponsesClient
from agent_framework.azure._shared import AzureOpenAISettings
__all__ = [
"AzureAIAgentClient",
"AzureAISettings",
"AzureOpenAIAssistantsClient",
"AzureOpenAIChatClient",
"AzureOpenAIResponsesClient",
"AzureOpenAISettings",
"get_entra_auth_token",
]
@@ -0,0 +1,135 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
from pydantic import SecretStr, ValidationError
from pydantic.networks import AnyUrl
from ..exceptions import ServiceInitializationError
from ..openai import OpenAIAssistantsClient
from ._shared import AzureOpenAISettings
if TYPE_CHECKING:
from azure.core.credentials import TokenCredential
__all__ = ["AzureOpenAIAssistantsClient"]
class AzureOpenAIAssistantsClient(OpenAIAssistantsClient):
"""Azure OpenAI Assistants client."""
DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview"
def __init__(
self,
deployment_name: str | None = None,
assistant_id: str | None = None,
assistant_name: str | None = None,
thread_id: str | None = None,
api_key: str | None = None,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
ad_token: str | None = None,
ad_token_provider: AsyncAzureADTokenProvider | None = None,
token_endpoint: str | None = None,
credential: "TokenCredential | None" = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncAzureOpenAI | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize an Azure OpenAI Assistants client.
Args:
deployment_name: The Azure OpenAI deployment name for the model to use.
assistant_id: The ID of an Azure OpenAI assistant to use.
If not provided, a new assistant will be created (and deleted after the request).
assistant_name: The name to use when creating new assistants.
thread_id: Default thread ID to use for conversations. Can be overridden by
conversation_id property, when making a request.
If not provided, a new thread will be created (and deleted after the request).
api_key: The optional API key to use. If provided will override,
the env vars or .env file value.
endpoint: The optional deployment endpoint. If provided will override the value
in the env vars or .env file.
base_url: The optional deployment base_url. If provided will override the value
in the env vars or .env file.
api_version: The optional deployment api version. If provided will override the value
in the env vars or .env file.
ad_token: The Azure Active Directory token. (Optional)
ad_token_provider: The Azure Active Directory token provider. (Optional)
token_endpoint: The token endpoint to request an Azure token. (Optional)
credential: The Azure credential to use for authentication. (Optional)
default_headers: The default headers mapping of string keys to
string values for HTTP requests. (Optional)
async_client: An existing client to use. (Optional)
env_file_path: Use the environment settings file as a fallback
to environment variables. (Optional)
env_file_encoding: The encoding of the environment settings file. (Optional)
"""
try:
azure_openai_settings = AzureOpenAISettings(
api_key=SecretStr(api_key) if api_key else None,
base_url=AnyUrl(base_url) if base_url else None,
endpoint=AnyUrl(endpoint) if endpoint else None,
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
default_api_version=self.DEFAULT_AZURE_API_VERSION,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create Azure OpenAI settings.", ex) from ex
if not azure_openai_settings.chat_deployment_name:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
# Handle authentication: try API key first, then AD token, then Entra ID
if (
not async_client
and not azure_openai_settings.api_key
and not ad_token
and not ad_token_provider
and azure_openai_settings.token_endpoint
and credential
):
ad_token = azure_openai_settings.get_azure_auth_token(credential)
if not async_client and not azure_openai_settings.api_key and not ad_token and not ad_token_provider:
raise ServiceInitializationError("The Azure OpenAI API key, ad_token, or ad_token_provider is required.")
# Create Azure client if not provided
if not async_client:
client_params: dict[str, Any] = {
"api_version": azure_openai_settings.api_version,
"default_headers": default_headers,
}
if azure_openai_settings.api_key:
client_params["api_key"] = azure_openai_settings.api_key.get_secret_value()
elif ad_token:
client_params["azure_ad_token"] = ad_token
elif ad_token_provider:
client_params["azure_ad_token_provider"] = ad_token_provider
if azure_openai_settings.base_url:
client_params["base_url"] = str(azure_openai_settings.base_url)
elif azure_openai_settings.endpoint:
client_params["azure_endpoint"] = str(azure_openai_settings.endpoint)
async_client = AsyncAzureOpenAI(**client_params)
super().__init__(
ai_model_id=azure_openai_settings.chat_deployment_name,
assistant_id=assistant_id,
assistant_name=assistant_name,
thread_id=thread_id,
async_client=async_client, # type: ignore[reportArgumentType]
)
@@ -0,0 +1,194 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
import sys
from collections.abc import Mapping
from typing import Any, TypeVar
from azure.core.credentials import TokenCredential
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from pydantic import SecretStr, ValidationError
from pydantic.networks import AnyUrl
from agent_framework import (
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
TextContent,
use_chat_middleware,
use_function_invocation,
)
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import use_observability
from agent_framework.openai._chat_client import OpenAIBaseChatClient
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
TChatResponse = TypeVar("TChatResponse", ChatResponse, ChatResponseUpdate)
TAzureOpenAIChatClient = TypeVar("TAzureOpenAIChatClient", bound="AzureOpenAIChatClient")
@use_function_invocation
@use_observability
@use_chat_middleware
class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
"""Azure OpenAI Chat completion class."""
def __init__(
self,
api_key: str | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
ad_token: str | None = None,
ad_token_provider: AsyncAzureADTokenProvider | None = None,
token_endpoint: str | None = None,
credential: TokenCredential | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncAzureOpenAI | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
instruction_role: str | None = None,
) -> None:
"""Initialize an AzureChatCompletion service.
Args:
api_key: The optional api key. If provided, will override the value in the
env vars or .env file.
deployment_name: The optional deployment. If provided, will override the value
(chat_deployment_name) in the env vars or .env file.
endpoint: The optional deployment endpoint. If provided will override the value
in the env vars or .env file.
base_url: The optional deployment base_url. If provided will override the value
in the env vars or .env file.
api_version: The optional deployment api version. If provided will override the value
in the env vars or .env file.
ad_token: The Azure Active Directory token. (Optional)
ad_token_provider: The Azure Active Directory token provider. (Optional)
token_endpoint: The token endpoint to request an Azure token. (Optional)
credential: The Azure credential for authentication. (Optional)
default_headers: The default headers mapping of string keys to
string values for HTTP requests. (Optional)
async_client: An existing client to use. (Optional)
env_file_path: Use the environment settings file as a fallback to using env vars.
env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'.
instruction_role: The role to use for 'instruction' messages, for example, summarization
prompts could use `developer` or `system`. (Optional)
"""
try:
# Filter out any None values from the arguments
azure_openai_settings = AzureOpenAISettings(
api_key=SecretStr(api_key) if api_key else None,
base_url=AnyUrl(base_url) if base_url else None,
endpoint=AnyUrl(endpoint) if endpoint else None,
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
)
except ValidationError as exc:
raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc
if not azure_openai_settings.chat_deployment_name:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
super().__init__(
deployment_name=azure_openai_settings.chat_deployment_name,
endpoint=azure_openai_settings.endpoint,
base_url=azure_openai_settings.base_url,
api_version=azure_openai_settings.api_version, # type: ignore
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
ad_token=ad_token,
ad_token_provider=ad_token_provider,
token_endpoint=azure_openai_settings.token_endpoint,
credential=credential,
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
)
@classmethod
def from_dict(cls: type[TAzureOpenAIChatClient], settings: dict[str, Any]) -> TAzureOpenAIChatClient:
"""Initialize an Azure OpenAI service from a dictionary of settings.
Args:
settings: A dictionary of settings for the service.
should contain keys: service_id, and optionally:
ad_auth, ad_token_provider, default_headers
"""
return cls(
api_key=settings.get("api_key"),
deployment_name=settings.get("deployment_name"),
endpoint=settings.get("endpoint"),
base_url=settings.get("base_url"),
api_version=settings.get("api_version"),
ad_token=settings.get("ad_token"),
ad_token_provider=settings.get("ad_token_provider"),
default_headers=settings.get("default_headers"),
env_file_path=settings.get("env_file_path"),
)
@override
def _parse_text_from_choice(self, choice: Choice | ChunkChoice) -> TextContent | None:
"""Parse the choice into a TextContent object.
Overwritten from OpenAIBaseChatClient to deal with Azure On Your Data function.
For docs see:
https://learn.microsoft.com/en-us/azure/ai-foundry/openai/references/on-your-data?tabs=python#context
"""
message = choice.message if isinstance(choice, Choice) else choice.delta
if hasattr(message, "refusal") and message.refusal:
return TextContent(text=message.refusal, raw_representation=choice)
if not message.content:
return None
text_content = TextContent(text=message.content, raw_representation=choice)
if not message.model_extra or "context" not in message.model_extra:
return text_content
context: dict[str, Any] | str = message.context # type: ignore[assignment, union-attr]
if isinstance(context, str):
try:
context = json.loads(context)
except json.JSONDecodeError:
logger.warning("Context is not a valid JSON string, ignoring context.")
return text_content
if not isinstance(context, dict):
logger.warning("Context is not a valid dictionary, ignoring context.")
return text_content
# `all_retrieved_documents` is currently not used, but can be retrieved
# through the raw_representation in the text content.
if intent := context.get("intent"):
text_content.additional_properties = {"intent": intent}
if citations := context.get("citations"):
text_content.annotations = []
for citation in citations:
text_content.annotations.append(
CitationAnnotation(
title=citation.get("title", ""),
url=citation.get("url", ""),
snippet=citation.get("content", ""),
file_id=citation.get("filepath", ""),
tool_name="Azure-on-your-Data",
additional_properties={"chunk_id": citation.get("chunk_id", "")},
raw_representation=citation,
)
)
return text_content
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import TYPE_CHECKING, Any
from azure.core.exceptions import ClientAuthenticationError
from ..exceptions import ServiceInvalidAuthError
if TYPE_CHECKING:
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
logger: logging.Logger = logging.getLogger(__name__)
def get_entra_auth_token(
credential: "TokenCredential",
token_endpoint: str,
**kwargs: Any,
) -> str | None:
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint.
The token endpoint may be specified as an environment variable, via the .env
file or as an argument. If the token endpoint is not provided, the default is None.
Args:
credential: The Azure credential to use for authentication.
token_endpoint: The token endpoint to use to retrieve the authentication token.
**kwargs: Additional keyword arguments to pass to the token retrieval method.
Returns:
The Azure token or None if the token could not be retrieved.
"""
if not token_endpoint:
raise ServiceInvalidAuthError(
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
)
try:
auth_token = credential.get_token(token_endpoint, **kwargs)
except ClientAuthenticationError as ex:
logger.error(f"Failed to retrieve Azure token for the specified endpoint: `{token_endpoint}`, with error: {ex}")
return None
return auth_token.token if auth_token else None
async def get_entra_auth_token_async(
credential: "AsyncTokenCredential", token_endpoint: str, **kwargs: Any
) -> str | None:
"""Retrieve a async Microsoft Entra Auth Token for a given token endpoint.
The token endpoint may be specified as an environment variable, via the .env
file or as an argument. If the token endpoint is not provided, the default is None.
Args:
credential: The async Azure credential to use for authentication.
token_endpoint: The token endpoint to use to retrieve the authentication token.
**kwargs: Additional keyword arguments to pass to the token retrieval method.
Returns:
The Azure token or None if the token could not be retrieved.
"""
if not token_endpoint:
raise ServiceInvalidAuthError(
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
)
try:
auth_token = await credential.get_token(token_endpoint, **kwargs)
except ClientAuthenticationError as ex:
logger.error(f"Failed to retrieve Azure token for the specified endpoint: `{token_endpoint}`, with error: {ex}")
return None
return auth_token.token if auth_token else None
@@ -0,0 +1,136 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Mapping
from typing import Any, TypeVar
from urllib.parse import urljoin
from azure.core.credentials import TokenCredential
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
from pydantic import SecretStr, ValidationError
from pydantic.networks import AnyUrl
from agent_framework import use_chat_middleware, use_function_invocation
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import use_observability
from agent_framework.openai._responses_client import OpenAIBaseResponsesClient
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
)
TAzureOpenAIResponsesClient = TypeVar("TAzureOpenAIResponsesClient", bound="AzureOpenAIResponsesClient")
@use_observability
@use_function_invocation
@use_chat_middleware
class AzureOpenAIResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClient):
"""Azure Responses completion class."""
def __init__(
self,
api_key: str | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
ad_token: str | None = None,
ad_token_provider: AsyncAzureADTokenProvider | None = None,
token_endpoint: str | None = None,
credential: TokenCredential | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncAzureOpenAI | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
instruction_role: str | None = None,
) -> None:
"""Initialize an AzureResponses service.
Args:
api_key: The optional api key. If provided, will override the value in the
env vars or .env file.
deployment_name: The optional deployment. If provided, will override the value
(responses_deployment_name) in the env vars or .env file.
endpoint: The optional deployment endpoint. If provided will override the value
in the env vars or .env file.
base_url: The optional deployment base_url. If provided will override the value
in the env vars or .env file. Currently, the base_url must end with "/openai/v1/"
api_version: The optional deployment api version. If provided will override the value
in the env vars or .env file. Currently, the api_version must be "preview".
ad_token: The Azure Active Directory token. (Optional)
ad_token_provider: The Azure Active Directory token provider. (Optional)
token_endpoint: The token endpoint to request an Azure token. (Optional)
credential: The Azure credential for authentication. (Optional)
default_headers: The default headers mapping of string keys to
string values for HTTP requests. (Optional)
async_client: An existing client to use. (Optional)
env_file_path: Use the environment settings file as a fallback to using env vars.
env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'.
instruction_role: The role to use for 'instruction' messages, for example, summarization
prompts could use `developer` or `system`. (Optional)
"""
try:
# Filter out any None values from the arguments
azure_openai_settings = AzureOpenAISettings(
api_key=SecretStr(api_key) if api_key else None,
base_url=AnyUrl(base_url) if base_url else None,
endpoint=AnyUrl(endpoint) if endpoint else None,
responses_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
default_api_version="preview",
)
# TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly
# while this feature is in preview.
# But we should only do this if we're on azure. Private deployments may not need this.
if (
not azure_openai_settings.base_url
and azure_openai_settings.endpoint
and str(azure_openai_settings.endpoint).rstrip("/").endswith("openai.azure.com")
):
azure_openai_settings.base_url = AnyUrl(urljoin(str(azure_openai_settings.endpoint), "/openai/v1/"))
except ValidationError as exc:
raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc
if not azure_openai_settings.responses_deployment_name:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable."
)
super().__init__(
deployment_name=azure_openai_settings.responses_deployment_name,
endpoint=azure_openai_settings.endpoint,
base_url=azure_openai_settings.base_url,
api_version=azure_openai_settings.api_version, # type: ignore
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
ad_token=ad_token,
ad_token_provider=ad_token_provider,
token_endpoint=azure_openai_settings.token_endpoint,
credential=credential,
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
)
@classmethod
def from_dict(cls: type[TAzureOpenAIResponsesClient], settings: dict[str, Any]) -> TAzureOpenAIResponsesClient:
"""Initialize an Open AI service from a dictionary of settings.
Args:
settings: A dictionary of settings for the service.
"""
return cls(
api_key=settings.get("api_key"),
deployment_name=settings.get("deployment_name"),
endpoint=settings.get("endpoint"),
base_url=settings.get("base_url"),
api_version=settings.get("api_version"),
ad_token=settings.get("ad_token"),
ad_token_provider=settings.get("ad_token_provider"),
default_headers=settings.get("default_headers"),
env_file_path=settings.get("env_file_path"),
)
@@ -0,0 +1,242 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from collections.abc import Awaitable, Callable, Mapping
from copy import copy
from typing import Any, ClassVar, Final
from azure.core.credentials import TokenCredential
from openai.lib.azure import AsyncAzureOpenAI
from pydantic import ConfigDict, SecretStr, model_validator, validate_call
from .._pydantic import AFBaseSettings, HTTPsUrl
from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
from ..exceptions import ServiceInitializationError
from ..openai._shared import OpenAIBase
from ._entra_id_authentication import get_entra_auth_token
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21"
DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105
class AzureOpenAISettings(AFBaseSettings):
"""AzureOpenAI model settings.
The settings are first loaded from environment variables with the prefix 'AZURE_OPENAI_'.
If the environment variables are not found, the settings can be loaded from a .env file
with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
are ignored; however, validation will fail alerting that the settings are missing.
Args:
endpoint: The endpoint of the Azure deployment. This value
can be found in the Keys & Endpoint section when examining
your resource from the Azure portal, the endpoint should end in openai.azure.com.
If both base_url and endpoint are supplied, base_url will be used.
(Env var AZURE_OPENAI_ENDPOINT)
chat_deployment_name: The name of the Azure Chat deployment. This value
will correspond to the custom name you chose for your deployment
when you deployed a model. This value can be found under
Resource Management > Deployments in the Azure portal or, alternatively,
under Management > Deployments in Azure AI Foundry.
(Env var AZURE_OPENAI_CHAT_DEPLOYMENT_NAME)
responses_deployment_name: The name of the Azure Responses deployment. This value
will correspond to the custom name you chose for your deployment
when you deployed a model. This value can be found under
Resource Management > Deployments in the Azure portal or, alternatively,
under Management > Deployments in Azure AI Foundry.
(Env var AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME)
api_key: The API key for the Azure deployment. This value can be
found in the Keys & Endpoint section when examining your resource in
the Azure portal. You can use either KEY1 or KEY2.
(Env var AZURE_OPENAI_API_KEY)
api_version: The API version to use. The default value is `default_api_version`.
(Env var AZURE_OPENAI_API_VERSION)
base_url: The url of the Azure deployment. This value
can be found in the Keys & Endpoint section when examining
your resource from the Azure portal, the base_url consists of the endpoint,
followed by /openai/deployments/{deployment_name}/,
use endpoint if you only want to supply the endpoint.
(Env var AZURE_OPENAI_BASE_URL)
token_endpoint: The token endpoint to use to retrieve the authentication token.
The default value is `default_token_endpoint`.
(Env var AZURE_OPENAI_TOKEN_ENDPOINT)
default_api_version: The default API version to use if not specified.
The default value is "2024-10-21".
default_token_endpoint: The default token endpoint to use if not specified.
The default value is "https://cognitiveservices.azure.com/.default".
env_file_path: The path to the .env file to load settings from.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
"""
env_prefix: ClassVar[str] = "AZURE_OPENAI_"
chat_deployment_name: str | None = None
responses_deployment_name: str | None = None
endpoint: HTTPsUrl | None = None
base_url: HTTPsUrl | None = None
api_key: SecretStr | None = None
api_version: str | None = None
token_endpoint: str | None = None
default_api_version: str = DEFAULT_AZURE_API_VERSION
default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT
def get_azure_auth_token(
self, credential: "TokenCredential", token_endpoint: str | None = None, **kwargs: Any
) -> str | None:
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint for the use with Azure OpenAI.
The required role for the token is `Cognitive Services OpenAI Contributor`.
The token endpoint may be specified as an environment variable, via the .env
file or as an argument. If the token endpoint is not provided, the default is None.
The `token_endpoint` argument takes precedence over the `token_endpoint` attribute.
Args:
credential: The Azure AD credential to use.
token_endpoint: The token endpoint to use. Defaults to `https://cognitiveservices.azure.com/.default`.
**kwargs: Additional keyword arguments to pass to the token retrieval method.
Returns:
The Azure token or None if the token could not be retrieved.
Raises:
ServiceInitializationError: If the token endpoint is not provided.
"""
endpoint_to_use = token_endpoint or self.token_endpoint or self.default_token_endpoint
return get_entra_auth_token(credential, endpoint_to_use, **kwargs)
@model_validator(mode="after")
def _validate_fields(self) -> Self:
self.api_version = self.api_version or self.default_api_version
self.token_endpoint = self.token_endpoint or self.default_token_endpoint
return self
class AzureOpenAIConfigMixin(OpenAIBase):
"""Internal class for configuring a connection to an Azure OpenAI service."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure_openai" # type: ignore[reportIncompatibleVariableOverride, misc]
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def __init__(
self,
deployment_name: str,
endpoint: HTTPsUrl | None = None,
base_url: HTTPsUrl | None = None,
api_version: str = DEFAULT_AZURE_API_VERSION,
api_key: str | None = None,
ad_token: str | None = None,
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
token_endpoint: str | None = None,
credential: TokenCredential | None = None,
default_headers: Mapping[str, str] | None = None,
client: AsyncAzureOpenAI | None = None,
instruction_role: str | None = None,
**kwargs: Any,
) -> None:
"""Internal class for configuring a connection to an Azure OpenAI service.
The `validate_call` decorator is used with a configuration that allows arbitrary types.
This is necessary for types like `HTTPsUrl` and `OpenAIModelTypes`.
Args:
deployment_name: Name of the deployment.
ai_model_type: The type of OpenAI model to deploy.
endpoint: The specific endpoint URL for the deployment.
base_url: The base URL for Azure services.
api_version: Azure API version. Defaults to the defined DEFAULT_AZURE_API_VERSION.
api_key: API key for Azure services.
ad_token: Azure AD token for authentication.
ad_token_provider: A callable or coroutine function providing Azure AD tokens.
token_endpoint: Azure AD token endpoint use to get the token.
credential: Azure credential for authentication.
default_headers: Default headers for HTTP requests.
client: An existing client to use.
instruction_role: The role to use for 'instruction' messages, for example, summarization
prompts could use `developer` or `system`.
kwargs: Additional keyword arguments.
"""
# Merge APP_INFO into the headers if it exists
merged_headers = dict(copy(default_headers)) if default_headers else {}
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
if not client:
# If the client is None, the api_key is none, the ad_token is none, and the ad_token_provider is none,
# then we will attempt to get the ad_token using the default endpoint specified in the Azure OpenAI
# settings.
if not api_key and not ad_token_provider and not ad_token and token_endpoint and credential:
ad_token = get_entra_auth_token(credential, token_endpoint)
if not api_key and not ad_token and not ad_token_provider:
raise ServiceInitializationError(
"Please provide either api_key, ad_token or ad_token_provider or a client."
)
if not endpoint and not base_url:
raise ServiceInitializationError("Please provide an endpoint or a base_url")
args: dict[str, Any] = {
"default_headers": merged_headers,
}
if api_version:
args["api_version"] = api_version
if ad_token:
args["azure_ad_token"] = ad_token
if ad_token_provider:
args["azure_ad_token_provider"] = ad_token_provider
if api_key:
args["api_key"] = api_key
if base_url:
args["base_url"] = str(base_url)
if endpoint and not base_url:
args["azure_endpoint"] = str(endpoint)
if deployment_name:
args["azure_deployment"] = deployment_name
if "websocket_base_url" in kwargs:
args["websocket_base_url"] = kwargs.pop("websocket_base_url")
client = AsyncAzureOpenAI(**args)
args = {
"ai_model_id": deployment_name,
"client": client,
}
if instruction_role:
args["instruction_role"] = instruction_role
super().__init__(**args, **kwargs)
def to_dict(self) -> dict[str, Any]:
"""Convert the configuration to a dictionary."""
client_settings = {
"base_url": str(self.client.base_url),
"api_version": self.client._custom_query["api-version"], # type: ignore
"api_key": self.client.api_key,
"ad_token": getattr(self.client, "_azure_ad_token", None),
"ad_token_provider": getattr(self.client, "_azure_ad_token_provider", None),
"default_headers": {k: v for k, v in self.client.default_headers.items() if k != USER_AGENT_KEY},
}
base = self.model_dump(
exclude={
"prompt_tokens",
"completion_tokens",
"total_tokens",
"api_type",
"org_id",
"service_id",
"client",
},
by_alias=True,
exclude_none=True,
)
base.update(client_settings)
return base
@@ -0,0 +1,35 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_devui"
PACKAGE_EXTRA = "devui"
_IMPORTS = [
"AgentFrameworkRequest",
"DevServer",
"DiscoveryResponse",
"EntityInfo",
"OpenAIError",
"OpenAIResponse",
"ResponseStreamEvent",
"main",
"serve",
"__version__",
]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(PACKAGE_NAME), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_EXTRA}' extra is not installed, "
f"please do `pip install agent-framework[{PACKAGE_EXTRA}]`"
) from exc
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -0,0 +1,142 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Any, Literal
logger = logging.getLogger("agent_framework")
class AgentFrameworkException(Exception):
"""Base exceptions for the Agent Framework.
Automatically logs the message as debug.
"""
def __init__(
self,
message: str,
inner_exception: Exception | None = None,
log_level: Literal[0] | Literal[10] | Literal[20] | Literal[30] | Literal[40] | Literal[50] | None = 10,
*args: Any,
**kwargs: Any,
):
"""Create an AgentFrameworkException.
This emits a debug log (by default), with the inner_exception if provided.
"""
if log_level is not None:
logger.log(log_level, message, exc_info=inner_exception)
if inner_exception:
super().__init__(message, inner_exception, *args) # type: ignore
super().__init__(message, *args) # type: ignore
class AgentException(AgentFrameworkException):
"""Base class for all agent exceptions."""
pass
class AgentExecutionException(AgentException):
"""An error occurred while executing the agent."""
pass
class AgentInitializationError(AgentException):
"""An error occurred while initializing the agent."""
pass
class AgentThreadException(AgentException):
"""An error occurred while managing the agent thread."""
pass
class ChatClientException(AgentFrameworkException):
"""An error occurred while dealing with a chat client."""
pass
class ChatClientInitializationError(ChatClientException):
"""An error occurred while initializing the chat client."""
pass
# region Service Exceptions
class ServiceException(AgentFrameworkException):
"""Base class for all service exceptions."""
pass
class ServiceInitializationError(ServiceException):
"""An error occurred while initializing the service."""
pass
class ServiceResponseException(ServiceException):
"""Base class for all service response exceptions."""
pass
class ServiceContentFilterException(ServiceResponseException):
"""An error was raised by the content filter of the service."""
pass
class ServiceInvalidAuthError(ServiceException):
"""An error occurred while authenticating the service."""
pass
class ServiceInvalidExecutionSettingsError(ServiceResponseException):
"""An error occurred while validating the execution settings of the service."""
pass
class ServiceInvalidRequestError(ServiceResponseException):
"""An error occurred while validating the request to the service."""
pass
class ServiceInvalidResponseError(ServiceResponseException):
"""An error occurred while validating the response from the service."""
pass
class ToolException(AgentFrameworkException):
"""An error occurred while executing a tool."""
pass
class ToolExecutionException(ToolException):
"""An error occurred while executing a tool."""
pass
class AdditionItemMismatch(AgentFrameworkException):
"""An error occurred while adding two types."""
pass
class MiddlewareException(AgentFrameworkException):
"""An error occurred during middleware execution."""
pass
@@ -0,0 +1,4 @@
# Copyright (c) Microsoft. All rights reserved.
# This makes agent_framework.lab a namespace package
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_mem0"
PACKAGE_EXTRA = "mem0"
_IMPORTS = ["__version__", "Mem0Provider"]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(PACKAGE_NAME), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_EXTRA}' extra is not installed, "
f"please do `pip install agent-framework[{PACKAGE_EXTRA}]`"
) from exc
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_mem0 import Mem0Provider, __version__
__all__ = ["Mem0Provider", "__version__"]
@@ -0,0 +1,30 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_copilotstudio"
PACKAGE_EXTRA = ["microsoft-copilotstudio", "copilotstudio"]
_IMPORTS: dict[str, tuple[str, list[str]]] = {
"CopilotStudioAgent": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]),
"__version__": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]),
"acquire_token": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]),
}
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
package_name, package_extra = _IMPORTS[name]
try:
return getattr(importlib.import_module(package_name), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The {' or '.join(package_extra)} extra is not installed, "
f"please use `pip install agent-framework[{package_extra[0]}]`, "
"or update your requirements.txt or pyproject.toml file."
) from exc
raise AttributeError(f"Module `azure` has no attribute {name}.")
def __dir__() -> list[str]:
return list(_IMPORTS.keys())
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_copilotstudio import CopilotStudioAgent, __version__, acquire_token
__all__ = ["CopilotStudioAgent", "__version__", "acquire_token"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
from ._assistants_client import * # noqa: F403
from ._chat_client import * # noqa: F403
from ._exceptions import * # noqa: F403
from ._responses_client import * # noqa: F403
from ._shared import * # noqa: F403
@@ -0,0 +1,498 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import sys
from collections.abc import AsyncIterable, Mapping, MutableMapping, MutableSequence
from typing import Any
from openai import AsyncOpenAI
from openai.types.beta.threads import (
ImageURLContentBlockParam,
ImageURLParam,
MessageContentPartParam,
MessageDeltaEvent,
Run,
TextContentBlockParam,
TextDeltaBlock,
)
from openai.types.beta.threads.run_create_params import AdditionalMessage
from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput
from openai.types.beta.threads.runs import RunStep
from pydantic import Field, PrivateAttr, SecretStr, ValidationError
from .._clients import BaseChatClient
from .._middleware import use_chat_middleware
from .._tools import AIFunction, HostedCodeInterpreterTool, HostedFileSearchTool, use_function_invocation
from .._types import (
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Contents,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
ToolMode,
UriContent,
UsageContent,
UsageDetails,
)
from ..exceptions import ServiceInitializationError
from ..observability import use_observability
from ._shared import OpenAIConfigMixin, OpenAISettings
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
__all__ = ["OpenAIAssistantsClient"]
@use_function_invocation
@use_observability
@use_chat_middleware
class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
"""OpenAI Assistants client."""
assistant_id: str | None = Field(default=None)
assistant_name: str | None = Field(default=None)
thread_id: str | None = Field(default=None)
_should_delete_assistant: bool = PrivateAttr(default=False) # Track whether we should delete the assistant
def __init__(
self,
ai_model_id: str | None = None,
assistant_id: str | None = None,
assistant_name: str | None = None,
thread_id: str | None = None,
api_key: str | None = None,
org_id: str | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncOpenAI | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize an OpenAI Assistants client.
Args:
ai_model_id: OpenAI model name, see
https://platform.openai.com/docs/models
assistant_id: The ID of an OpenAI assistant to use.
If not provided, a new assistant will be created (and deleted after the request).
assistant_name: The name to use when creating new assistants.
thread_id: Default thread ID to use for conversations. Can be overridden by
conversation_id property, when making a request.
If not provided, a new thread will be created (and deleted after the request).
api_key: The optional API key to use. If provided will override,
the env vars or .env file value.
org_id: The optional org ID to use. If provided will override,
the env vars or .env file value.
default_headers: The default headers mapping of string keys to
string values for HTTP requests. (Optional)
async_client: An existing client to use. (Optional)
env_file_path: Use the environment settings file as a fallback
to environment variables. (Optional)
env_file_encoding: The encoding of the environment settings file. (Optional)
"""
try:
openai_settings = OpenAISettings(
api_key=SecretStr(api_key) if api_key else None,
org_id=org_id,
chat_model_id=ai_model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
if not async_client and not openai_settings.api_key:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings.chat_model_id:
raise ServiceInitializationError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
super().__init__(
ai_model_id=openai_settings.chat_model_id,
assistant_id=assistant_id, # type: ignore[reportCallIssue]
assistant_name=assistant_name, # type: ignore[reportCallIssue]
thread_id=thread_id, # type: ignore[reportCallIssue]
api_key=openai_settings.api_key.get_secret_value() if openai_settings.api_key else None,
org_id=openai_settings.org_id,
default_headers=default_headers,
client=async_client,
)
async def __aenter__(self) -> "Self":
"""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 - clean up any assistants we created."""
await self.close()
async def close(self) -> None:
"""Clean up any assistants we created."""
if self._should_delete_assistant and self.assistant_id is not None:
await self.client.beta.assistants.delete(self.assistant_id)
self.assistant_id = None
self._should_delete_assistant = False
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
return await ChatResponse.from_chat_response_generator(
updates=self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs),
output_format_type=chat_options.response_format,
)
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# Extract necessary state from messages and options
run_options, tool_results = self._prepare_options(messages, chat_options, **kwargs)
# Get the thread ID
thread_id: str | None = (
chat_options.conversation_id
if chat_options.conversation_id is not None
else run_options.get("conversation_id", self.thread_id)
)
if thread_id is None and tool_results is not None:
raise ValueError("No thread ID was provided, but chat messages includes tool results.")
# Determine which assistant to use and create if needed
assistant_id = await self._get_assistant_id_or_create()
# Create the streaming response
stream, thread_id = await self._create_assistant_stream(thread_id, assistant_id, run_options, tool_results)
# Process and yield each update from the stream
async for update in self._process_stream_events(stream, thread_id):
yield update
async def _get_assistant_id_or_create(self) -> str:
"""Determine which assistant to use and create if needed.
Returns:
str: The assistant_id to use.
"""
# If no assistant is provided, create a temporary assistant
if self.assistant_id is None:
created_assistant = await self.client.beta.assistants.create(
name=self.assistant_name, model=self.ai_model_id
)
self.assistant_id = created_assistant.id
self._should_delete_assistant = True
return self.assistant_id
async def _create_assistant_stream(
self,
thread_id: str | None,
assistant_id: str,
run_options: dict[str, Any],
tool_results: list[FunctionResultContent] | None,
) -> tuple[Any, str]:
"""Create the assistant stream for processing.
Returns:
tuple: (stream, final_thread_id)
"""
# Get any active run for this thread
thread_run = await self._get_active_thread_run(thread_id)
tool_run_id, tool_outputs = self._convert_function_results_to_tool_output(tool_results)
if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs:
# There's an active run and we have tool results to submit, so submit the results.
stream = self.client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated]
run_id=tool_run_id, thread_id=thread_run.thread_id, tool_outputs=tool_outputs
)
final_thread_id = thread_run.thread_id
else:
# Handle thread creation or cancellation
final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options)
# Now create a new run and stream the results.
stream = self.client.beta.threads.runs.stream( # type: ignore[reportDeprecated]
assistant_id=assistant_id, thread_id=final_thread_id, **run_options
)
return stream, final_thread_id
async def _get_active_thread_run(self, thread_id: str | None) -> Run | None:
"""Get any active run for the given thread."""
if thread_id is None:
return None
async for run in self.client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated]
if run.status not in ["completed", "cancelled", "failed", "expired"]:
return run
return None
async def _prepare_thread(self, thread_id: str | None, thread_run: Run | None, run_options: dict[str, Any]) -> str:
"""Prepare the thread for a new run, creating or cleaning up as needed."""
if thread_id is None:
# No thread ID was provided, so create a new thread.
thread = await self.client.beta.threads.create( # type: ignore[reportDeprecated]
messages=run_options["additional_messages"],
tool_resources=run_options.get("tool_resources"),
metadata=run_options.get("metadata"),
)
run_options["additional_messages"] = []
run_options.pop("tool_resources", None)
return thread.id
if thread_run is not None:
# There was an active run; we need to cancel it before starting a new run.
await self.client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated]
return thread_id
async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIterable[ChatResponseUpdate]:
response_id: str | None = None
async with stream as response_stream:
async for response in response_stream:
if response.event == "thread.run.created":
yield ChatResponseUpdate(
contents=[],
conversation_id=thread_id,
message_id=response_id,
raw_representation=response.data,
response_id=response_id,
role=Role.ASSISTANT,
)
elif response.event == "thread.run.step.created" and isinstance(response.data, RunStep):
response_id = response.data.run_id
elif response.event == "thread.message.delta" and isinstance(response.data, MessageDeltaEvent):
delta = response.data.delta
role = Role.USER if delta.role == "user" else Role.ASSISTANT
for delta_block in delta.content or []:
if isinstance(delta_block, TextDeltaBlock) and delta_block.text and delta_block.text.value:
yield ChatResponseUpdate(
role=role,
text=delta_block.text.value,
conversation_id=thread_id,
message_id=response_id,
raw_representation=response.data,
response_id=response_id,
)
elif response.event == "thread.run.requires_action" and isinstance(response.data, Run):
contents = self._create_function_call_contents(response.data, response_id)
if contents:
yield ChatResponseUpdate(
role=Role.ASSISTANT,
contents=contents,
conversation_id=thread_id,
message_id=response_id,
raw_representation=response.data,
response_id=response_id,
)
elif (
response.event == "thread.run.completed"
and isinstance(response.data, Run)
and response.data.usage is not None
):
usage = response.data.usage
usage_content = UsageContent(
UsageDetails(
input_token_count=usage.prompt_tokens,
output_token_count=usage.completion_tokens,
total_token_count=usage.total_tokens,
)
)
yield ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[usage_content],
conversation_id=thread_id,
message_id=response_id,
raw_representation=response.data,
response_id=response_id,
)
else:
yield ChatResponseUpdate(
contents=[],
conversation_id=thread_id,
message_id=response_id,
raw_representation=response.data,
response_id=response_id,
role=Role.ASSISTANT,
)
def _create_function_call_contents(self, event_data: Run, response_id: str | None) -> list[Contents]:
"""Create function call contents from a tool action event."""
contents: list[Contents] = []
if event_data.required_action is not None:
for tool_call in event_data.required_action.submit_tool_outputs.tool_calls:
call_id = json.dumps([response_id, tool_call.id])
function_name = tool_call.function.name
function_arguments = json.loads(tool_call.function.arguments)
contents.append(FunctionCallContent(call_id=call_id, name=function_name, arguments=function_arguments))
return contents
def _prepare_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions | None,
**kwargs: Any,
) -> tuple[dict[str, Any], list[FunctionResultContent] | None]:
run_options: dict[str, Any] = {**kwargs}
if chat_options is not None:
run_options["max_completion_tokens"] = chat_options.max_tokens
run_options["model"] = chat_options.model_id
run_options["top_p"] = chat_options.top_p
run_options["temperature"] = chat_options.temperature
if chat_options.allow_multiple_tool_calls is not None:
run_options["parallel_tool_calls"] = chat_options.allow_multiple_tool_calls
if chat_options.tool_choice is not None:
tool_definitions: list[MutableMapping[str, Any]] = []
if chat_options.tool_choice != "none" and chat_options.tools is not None:
for tool in chat_options.tools:
if isinstance(tool, AIFunction):
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
elif isinstance(tool, HostedCodeInterpreterTool):
tool_definitions.append({"type": "code_interpreter"})
elif isinstance(tool, HostedFileSearchTool):
params: dict[str, Any] = {
"type": "file_search",
}
if tool.max_results is not None:
params["max_num_results"] = tool.max_results
tool_definitions.append(params)
elif isinstance(tool, MutableMapping):
tool_definitions.append(tool)
if len(tool_definitions) > 0:
run_options["tools"] = tool_definitions
if chat_options.tool_choice == "none" or chat_options.tool_choice == "auto":
run_options["tool_choice"] = chat_options.tool_choice
elif (
isinstance(chat_options.tool_choice, ToolMode)
and chat_options.tool_choice == "required"
and chat_options.tool_choice.required_function_name is not None
):
run_options["tool_choice"] = {
"type": "function",
"function": {"name": chat_options.tool_choice.required_function_name},
}
if chat_options.response_format is not None:
run_options["response_format"] = {
"type": "json_schema",
"json_schema": chat_options.response_format.model_json_schema(),
}
instructions: list[str] = [chat_options.instructions] if chat_options and chat_options.instructions else []
tool_results: list[FunctionResultContent] | None = None
additional_messages: list[AdditionalMessage] | None = None
# System/developer messages are turned into instructions,
# since there is no such message roles in OpenAI Assistants.
# All other messages are added 1:1.
for chat_message in messages:
if chat_message.role.value in ["system", "developer"]:
for text_content in [content for content in chat_message.contents if isinstance(content, TextContent)]:
instructions.append(text_content.text)
continue
message_contents: list[MessageContentPartParam] = []
for content in chat_message.contents:
if isinstance(content, TextContent):
message_contents.append(TextContentBlockParam(type="text", text=content.text))
elif isinstance(content, UriContent) and content.has_top_level_media_type("image"):
message_contents.append(
ImageURLContentBlockParam(type="image_url", image_url=ImageURLParam(url=content.uri))
)
elif isinstance(content, FunctionResultContent):
if tool_results is None:
tool_results = []
tool_results.append(content)
if len(message_contents) > 0:
if additional_messages is None:
additional_messages = []
additional_messages.append(
AdditionalMessage(
role="assistant" if chat_message.role == Role.ASSISTANT else "user",
content=message_contents,
)
)
if additional_messages is not None:
run_options["additional_messages"] = additional_messages
if len(instructions) > 0:
run_options["instructions"] = "".join(instructions)
return run_options, tool_results
def _convert_function_results_to_tool_output(
self,
tool_results: list[FunctionResultContent] | None,
) -> tuple[str | None, list[ToolOutput] | None]:
run_id: str | None = None
tool_outputs: list[ToolOutput] | None = None
if tool_results:
for function_result_content in tool_results:
# When creating the FunctionCallContent, we created it with a CallId == [runId, callId].
# We need to extract the run ID and ensure that the ToolOutput we send back to Azure
# is only the call ID.
run_and_call_ids: list[str] = json.loads(function_result_content.call_id)
if (
not run_and_call_ids
or len(run_and_call_ids) != 2
or not run_and_call_ids[0]
or not run_and_call_ids[1]
or (run_id is not None and run_id != run_and_call_ids[0])
):
continue
run_id = run_and_call_ids[0]
call_id = run_and_call_ids[1]
if tool_outputs is None:
tool_outputs = []
tool_outputs.append(ToolOutput(tool_call_id=call_id, output=str(function_result_content.result)))
return run_id, tool_outputs
def _update_agent_name(self, agent_name: str | None) -> None:
"""Update the agent name in the chat client.
Args:
agent_name: The new name for the agent.
"""
# This is a no-op in the base class, but can be overridden by subclasses
# to update the agent name in the client.
if agent_name and not self.assistant_name:
self.assistant_name = agent_name
@@ -0,0 +1,540 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import sys
from collections.abc import AsyncIterable, Mapping, MutableMapping, MutableSequence, Sequence
from datetime import datetime
from itertools import chain
from typing import Any, TypeVar
from openai import AsyncOpenAI, BadRequestError
from openai.lib._parsing._completions import type_to_response_format_param
from openai.types import CompletionUsage
from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall
from pydantic import BaseModel, SecretStr, ValidationError
from .._clients import BaseChatClient
from .._logging import get_logger
from .._middleware import use_chat_middleware
from .._tools import AIFunction, HostedWebSearchTool, ToolProtocol, use_function_invocation
from .._types import (
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Contents,
DataContent,
FinishReason,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
UriContent,
UsageContent,
UsageDetails,
)
from ..exceptions import (
ServiceInitializationError,
ServiceInvalidRequestError,
ServiceResponseException,
)
from ..observability import use_observability
from ._exceptions import OpenAIContentFilterException
from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings, prepare_function_call_results
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
__all__ = ["OpenAIChatClient"]
logger = get_logger("agent_framework.openai")
# region Base Client
class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
"""OpenAI Chat completion class."""
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
options_dict = self._prepare_options(messages, chat_options)
try:
return self._create_chat_response(
await self.client.chat.completions.create(stream=False, **options_dict), chat_options
)
except BadRequestError as ex:
if ex.code == "content_filter":
raise OpenAIContentFilterException(
f"{type(self)} service encountered a content error: {ex}",
inner_exception=ex,
) from ex
raise ServiceResponseException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
except Exception as ex:
raise ServiceResponseException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
options_dict = self._prepare_options(messages, chat_options)
options_dict["stream_options"] = {"include_usage": True}
try:
async for chunk in await self.client.chat.completions.create(stream=True, **options_dict):
if len(chunk.choices) == 0 and chunk.usage is None:
continue
yield self._create_chat_response_update(chunk)
except BadRequestError as ex:
if ex.code == "content_filter":
raise OpenAIContentFilterException(
f"{type(self)} service encountered a content error: {ex}",
inner_exception=ex,
) from ex
raise ServiceResponseException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
except Exception as ex:
raise ServiceResponseException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
# region content creation
def _chat_to_tool_spec(self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]) -> list[dict[str, Any]]:
chat_tools: list[dict[str, Any]] = []
for tool in tools:
if isinstance(tool, ToolProtocol):
match tool:
case AIFunction():
chat_tools.append(tool.to_json_schema_spec())
case _:
logger.debug("Unsupported tool passed (type: %s), ignoring", type(tool))
else:
chat_tools.append(tool if isinstance(tool, dict) else dict(tool))
return chat_tools
def _process_web_search_tool(
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]
) -> dict[str, Any] | None:
for tool in tools:
if isinstance(tool, HostedWebSearchTool):
# Web search tool requires special handling
return (
{
"user_location": {
"approximate": tool.additional_properties.get("user_location", None),
"type": "approximate",
}
}
if tool.additional_properties and "user_location" in tool.additional_properties
else {}
)
return None
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
# Preprocess web search tool if it exists
options_dict = chat_options.to_provider_settings()
instructions = options_dict.pop("instructions", None)
if instructions:
messages = [ChatMessage(role="system", text=instructions), *messages]
if messages and "messages" not in options_dict:
options_dict["messages"] = self._prepare_chat_history_for_request(messages)
if "messages" not in options_dict:
raise ServiceInvalidRequestError("Messages are required for chat completions")
if chat_options.tools is not None:
web_search_options = self._process_web_search_tool(chat_options.tools)
if web_search_options:
options_dict["web_search_options"] = web_search_options
options_dict["tools"] = self._chat_to_tool_spec(chat_options.tools)
if not options_dict.get("tools", None):
options_dict.pop("tools", None)
options_dict.pop("parallel_tool_calls", None)
options_dict.pop("tool_choice", None)
if "model" not in options_dict:
options_dict["model"] = self.ai_model_id
if (
chat_options.response_format
and isinstance(chat_options.response_format, type)
and issubclass(chat_options.response_format, BaseModel)
):
options_dict["response_format"] = type_to_response_format_param(chat_options.response_format)
return options_dict
def _create_chat_response(self, response: ChatCompletion, chat_options: ChatOptions) -> "ChatResponse":
"""Create a chat message content object from a choice."""
response_metadata = self._get_metadata_from_chat_response(response)
messages: list[ChatMessage] = []
finish_reason: FinishReason | None = None
for choice in response.choices:
response_metadata.update(self._get_metadata_from_chat_choice(choice))
if choice.finish_reason:
finish_reason = FinishReason(value=choice.finish_reason)
contents: list[Contents] = []
if text_content := self._parse_text_from_choice(choice):
contents.append(text_content)
if parsed_tool_calls := [tool for tool in self._get_tool_calls_from_chat_choice(choice)]:
contents.extend(parsed_tool_calls)
messages.append(ChatMessage(role="assistant", contents=contents))
return ChatResponse(
response_id=response.id,
created_at=datetime.fromtimestamp(response.created).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
usage_details=self._usage_details_from_openai(response.usage) if response.usage else None,
messages=messages,
model_id=response.model,
additional_properties=response_metadata,
finish_reason=finish_reason,
response_format=chat_options.response_format,
)
def _create_chat_response_update(
self,
chunk: ChatCompletionChunk,
) -> ChatResponseUpdate:
"""Create a streaming chat message content object from a choice."""
chunk_metadata = self._get_metadata_from_streaming_chat_response(chunk)
if chunk.usage:
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[UsageContent(details=self._usage_details_from_openai(chunk.usage), raw_representation=chunk)],
model_id=chunk.model,
additional_properties=chunk_metadata,
response_id=chunk.id,
message_id=chunk.id,
)
contents: list[Contents] = []
finish_reason: FinishReason | None = None
for choice in chunk.choices:
chunk_metadata.update(self._get_metadata_from_chat_choice(choice))
contents.extend(self._get_tool_calls_from_chat_choice(choice))
if choice.finish_reason:
finish_reason = FinishReason(value=choice.finish_reason)
if text_content := self._parse_text_from_choice(choice):
contents.append(text_content)
return ChatResponseUpdate(
created_at=datetime.fromtimestamp(chunk.created).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
contents=contents,
role=Role.ASSISTANT,
model_id=chunk.model,
additional_properties=chunk_metadata,
finish_reason=finish_reason,
raw_representation=chunk,
response_id=chunk.id,
message_id=chunk.id,
)
def _usage_details_from_openai(self, usage: CompletionUsage) -> UsageDetails:
details = UsageDetails(
input_token_count=usage.prompt_tokens,
output_token_count=usage.completion_tokens,
total_token_count=usage.total_tokens,
)
if usage.completion_tokens_details:
if tokens := usage.completion_tokens_details.accepted_prediction_tokens:
details["completion/accepted_prediction_tokens"] = tokens
if tokens := usage.completion_tokens_details.audio_tokens:
details["completion/audio_tokens"] = tokens
if tokens := usage.completion_tokens_details.reasoning_tokens:
details["completion/reasoning_tokens"] = tokens
if tokens := usage.completion_tokens_details.rejected_prediction_tokens:
details["completion/rejected_prediction_tokens"] = tokens
if usage.prompt_tokens_details:
if tokens := usage.prompt_tokens_details.audio_tokens:
details["prompt/audio_tokens"] = tokens
if tokens := usage.prompt_tokens_details.cached_tokens:
details["prompt/cached_tokens"] = tokens
return details
def _parse_text_from_choice(self, choice: Choice | ChunkChoice) -> TextContent | None:
"""Parse the choice into a TextContent object."""
message = choice.message if isinstance(choice, Choice) else choice.delta
if message.content:
return TextContent(text=message.content, raw_representation=choice)
if hasattr(message, "refusal") and message.refusal:
return TextContent(text=message.refusal, raw_representation=choice)
return None
def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]:
"""Get metadata from a chat response."""
return {
"system_fingerprint": response.system_fingerprint,
}
def _get_metadata_from_streaming_chat_response(self, response: ChatCompletionChunk) -> dict[str, Any]:
"""Get metadata from a streaming chat response."""
return {
"system_fingerprint": response.system_fingerprint,
}
def _get_metadata_from_chat_choice(self, choice: Choice | ChunkChoice) -> dict[str, Any]:
"""Get metadata from a chat choice."""
return {
"logprobs": getattr(choice, "logprobs", None),
}
def _get_tool_calls_from_chat_choice(self, choice: Choice | ChunkChoice) -> list[Contents]:
"""Get tool calls from a chat choice."""
resp: list[Contents] = []
content = choice.message if isinstance(choice, Choice) else choice.delta
if content and content.tool_calls:
for tool in content.tool_calls:
if not isinstance(tool, ChatCompletionMessageCustomToolCall) and tool.function:
# ignoring tool.custom
fcc = FunctionCallContent(
call_id=tool.id if tool.id else "",
name=tool.function.name if tool.function.name else "",
arguments=tool.function.arguments if tool.function.arguments else "",
raw_representation=tool.function,
)
resp.append(fcc)
# When you enable asynchronous content filtering in Azure OpenAI, you may receive empty deltas
return resp
def _prepare_chat_history_for_request(
self,
chat_messages: Sequence[ChatMessage],
role_key: str = "role",
content_key: str = "content",
) -> list[dict[str, Any]]:
"""Prepare the chat history for a request.
Allowing customization of the key names for role/author, and optionally overriding the role.
Role.TOOL messages need to be formatted different than system/user/assistant messages:
They require a "tool_call_id" and (function) "name" key, and the "metadata" key should
be removed. The "encoding" key should also be removed.
Override this method to customize the formatting of the chat history for a request.
Args:
chat_messages: The chat history to prepare.
role_key: The key name for the role/author.
content_key: The key name for the content/message.
Returns:
prepared_chat_history (Any): The prepared chat history for a request.
"""
list_of_list = [self._openai_chat_message_parser(message) for message in chat_messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(list_of_list))
# region Parsers
def _openai_chat_message_parser(self, message: ChatMessage) -> list[dict[str, Any]]:
"""Parse a chat message into the openai format."""
all_messages: list[dict[str, Any]] = []
for content in message.contents:
args: dict[str, Any] = {
"role": message.role.value if isinstance(message.role, Role) else message.role,
}
if message.additional_properties:
args["metadata"] = message.additional_properties
match content:
case FunctionCallContent():
if all_messages and "tool_calls" in all_messages[-1]:
# If the last message already has tool calls, append to it
all_messages[-1]["tool_calls"].append(self._openai_content_parser(content))
else:
args["tool_calls"] = [self._openai_content_parser(content)] # type: ignore
case FunctionResultContent():
args["tool_call_id"] = content.call_id
if content.result is not None:
args["content"] = prepare_function_call_results(content.result)
elif content.exception is not None:
# Send the exception message to the model
# Otherwise we won't have any channels to talk to OpenAI
# TODO(yuge): This should ideally be customizable
args["content"] = "Error: " + str(content.exception)
case _:
if "content" not in args:
args["content"] = []
# this is a list to allow multi-modal content
args["content"].append(self._openai_content_parser(content)) # type: ignore
if "content" in args or "tool_calls" in args:
all_messages.append(args)
return all_messages
def _openai_content_parser(self, content: Contents) -> dict[str, Any]:
"""Parse contents into the openai format."""
match content:
case FunctionCallContent():
args = json.dumps(content.arguments) if isinstance(content.arguments, Mapping) else content.arguments
return {
"id": content.call_id,
"type": "function",
"function": {"name": content.name, "arguments": args},
}
case FunctionResultContent():
return {
"tool_call_id": content.call_id,
"content": content.result,
}
case DataContent() | UriContent() if content.has_top_level_media_type("image"):
return {
"type": "image_url",
"image_url": {"url": content.uri},
}
case DataContent() | UriContent() if content.has_top_level_media_type("audio"):
if content.media_type and "wav" in content.media_type:
audio_format = "wav"
elif content.media_type and "mp3" in content.media_type:
audio_format = "mp3"
else:
# Fallback to default to_dict for unsupported audio formats
return content.to_dict(exclude_none=True)
# Extract base64 data from data URI
audio_data = content.uri
if audio_data.startswith("data:"):
# Extract just the base64 part after "data:audio/format;base64,"
audio_data = audio_data.split(",", 1)[-1]
return {
"type": "input_audio",
"input_audio": {
"data": audio_data,
"format": audio_format,
},
}
case DataContent() | UriContent() if content.media_type and content.media_type.startswith("application/"):
if content.media_type == "application/pdf":
if content.uri.startswith("data:"):
filename = (
getattr(content, "filename", None)
or content.additional_properties.get("filename", "document.pdf")
if hasattr(content, "additional_properties") and content.additional_properties
else "document.pdf"
)
return {
"type": "file",
"file": {
"file_data": content.uri, # Send full data URI
"filename": filename,
},
}
return content.to_dict(exclude_none=True)
return content.to_dict(exclude_none=True)
case _:
return content.to_dict(exclude_none=True)
@override
def service_url(self) -> str:
"""Get the URL of the service.
Override this in the subclass to return the proper URL.
If the service does not have a URL, return None.
"""
return str(self.client.base_url) if self.client else "Unknown"
# region Public client
TOpenAIChatClient = TypeVar("TOpenAIChatClient", bound="OpenAIChatClient")
@use_function_invocation
@use_observability
@use_chat_middleware
class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
"""OpenAI Chat completion class."""
def __init__(
self,
ai_model_id: str | None = None,
api_key: str | None = None,
org_id: str | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncOpenAI | None = None,
instruction_role: str | None = None,
base_url: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize an OpenAIChatCompletion service.
Args:
ai_model_id: OpenAI model name, see
https://platform.openai.com/docs/models
api_key: The optional API key to use. If provided will override,
the env vars or .env file value.
org_id: The optional org ID to use. If provided will override,
the env vars or .env file value.
default_headers: The default headers mapping of string keys to
string values for HTTP requests. (Optional)
async_client: An existing client to use. (Optional)
instruction_role: The role to use for 'instruction' messages, for example,
"system" or "developer". If not provided, the default is "system".
base_url: The optional base URL to use. If provided will override
the standard value for a OpenAI connector,
the env vars or .env file value.
env_file_path: Use the environment settings file as a fallback
to environment variables. (Optional)
env_file_encoding: The encoding of the environment settings file. (Optional)
"""
try:
openai_settings = OpenAISettings(
api_key=SecretStr(api_key) if api_key else None,
base_url=base_url,
org_id=org_id,
chat_model_id=ai_model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
if not async_client and not openai_settings.api_key:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings.chat_model_id:
raise ServiceInitializationError(
"OpenAI model ID is required. "
"Set via 'ai_model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
super().__init__(
ai_model_id=openai_settings.chat_model_id,
api_key=openai_settings.api_key.get_secret_value() if openai_settings.api_key else None,
base_url=openai_settings.base_url if openai_settings.base_url else None,
org_id=openai_settings.org_id,
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
)
@classmethod
def from_dict(cls: type[TOpenAIChatClient], settings: dict[str, Any]) -> TOpenAIChatClient:
"""Initialize an Open AI Chat Client from a dictionary of settings.
Args:
settings: A dictionary of settings for the service.
"""
return cls(**settings)
# endregion
@@ -0,0 +1,92 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from enum import Enum
from typing import Any
from openai import BadRequestError
from ..exceptions import ServiceContentFilterException
__all__ = ["ContentFilterResultSeverity", "OpenAIContentFilterException"]
class ContentFilterResultSeverity(Enum):
"""The severity of the content filter result."""
HIGH = "high"
MEDIUM = "medium"
SAFE = "safe"
LOW = "low"
@dataclass
class ContentFilterResult:
"""The result of a content filter check."""
filtered: bool = False
detected: bool = False
severity: ContentFilterResultSeverity = ContentFilterResultSeverity.SAFE
@classmethod
def from_inner_error_result(cls, inner_error_results: dict[str, Any]) -> "ContentFilterResult":
"""Creates a ContentFilterResult from the inner error results.
Args:
key (str): The key to get the inner error result from.
inner_error_results (Dict[str, Any]): The inner error results.
Returns:
ContentFilterResult: The ContentFilterResult.
"""
return cls(
filtered=inner_error_results.get("filtered", False),
detected=inner_error_results.get("detected", False),
severity=ContentFilterResultSeverity(
inner_error_results.get("severity", ContentFilterResultSeverity.SAFE.value)
),
)
class ContentFilterCodes(Enum):
"""Content filter codes."""
RESPONSIBLE_AI_POLICY_VIOLATION = "ResponsibleAIPolicyViolation"
@dataclass
class OpenAIContentFilterException(ServiceContentFilterException):
"""AI exception for an error from Azure OpenAI's content filter."""
# The parameter that caused the error.
param: str | None
# The error code specific to the content filter.
content_filter_code: ContentFilterCodes
# The results of the different content filter checks.
content_filter_result: dict[str, ContentFilterResult]
def __init__(
self,
message: str,
inner_exception: BadRequestError,
) -> None:
"""Initializes a new instance of the ContentFilterAIException class.
Args:
message (str): The error message.
inner_exception (Exception): The inner exception.
"""
super().__init__(message)
self.param = inner_exception.param
if inner_exception.body is not None and isinstance(inner_exception.body, dict):
inner_error = inner_exception.body.get("innererror", {}) # type: ignore
self.content_filter_code = ContentFilterCodes(
inner_error.get("code", ContentFilterCodes.RESPONSIBLE_AI_POLICY_VIOLATION.value) # type: ignore
)
self.content_filter_result = {
key: ContentFilterResult.from_inner_error_result(values) # type: ignore
for key, values in inner_error.get("content_filter_result", {}).items() # type: ignore
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,198 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
from collections.abc import Mapping
from copy import copy
from typing import Annotated, Any, ClassVar, Union
from openai import (
AsyncOpenAI,
AsyncStream,
_legacy_response, # type: ignore
)
from openai.types import Completion
from openai.types.audio import Transcription
from openai.types.chat import ChatCompletion, ChatCompletionChunk
from openai.types.images_response import ImagesResponse
from openai.types.responses.response import Response
from openai.types.responses.response_stream_event import ResponseStreamEvent
from pydantic import ConfigDict, Field, SecretStr, validate_call
from pydantic.types import StringConstraints
from .._logging import get_logger
from .._pydantic import AFBaseModel, AFBaseSettings
from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
from .._types import ChatOptions, Contents
from ..exceptions import ServiceInitializationError
logger: logging.Logger = get_logger("agent_framework.openai")
RESPONSE_TYPE = Union[
ChatCompletion,
Completion,
AsyncStream[ChatCompletionChunk],
AsyncStream[Completion],
list[Any],
ImagesResponse,
Response,
AsyncStream[ResponseStreamEvent],
Transcription,
_legacy_response.HttpxBinaryResponseContent,
]
OPTION_TYPE = Union[ChatOptions, dict[str, Any]]
__all__ = [
"OpenAISettings",
]
def _prepare_function_call_results_as_dumpable(content: Contents | Any | list[Contents | Any]) -> Any:
if isinstance(content, list):
# Particularly deal with lists of Content
return [_prepare_function_call_results_as_dumpable(item) for item in content]
if isinstance(content, dict):
return {k: _prepare_function_call_results_as_dumpable(v) for k, v in content.items()}
if hasattr(content, "to_dict"):
return content.to_dict(exclude={"raw_representation", "additional_properties"})
return content
def prepare_function_call_results(content: Contents | Any | list[Contents | Any]) -> str | list[str]:
"""Prepare the values of the function call results."""
if isinstance(content, Contents):
# For BaseContent objects, use to_dict and serialize to JSON
return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}))
dumpable = _prepare_function_call_results_as_dumpable(content)
if isinstance(dumpable, str):
return dumpable
# fallback
return json.dumps(dumpable)
class OpenAISettings(AFBaseSettings):
"""OpenAI environment settings.
The settings are first loaded from environment variables with the prefix 'OPENAI_'.
If the environment variables are not found, the settings can be loaded from a .env file with the
encoding 'utf-8'. If the settings are not found in the .env file, the settings are ignored;
however, validation will fail alerting that the settings are missing.
Args:
api_key: OpenAI API key, see https://platform.openai.com/account/api-keys
(Env var OPENAI_API_KEY)
base_url: The base URL for the OpenAI API.
(Env var OPENAI_BASE_URL)
org_id: This is usually optional unless your account belongs to multiple organizations.
(Env var OPENAI_ORG_ID)
chat_model_id: The OpenAI chat model ID to use, for example, gpt-3.5-turbo or gpt-4.
(Env var OPENAI_CHAT_MODEL_ID)
responses_model_id: The OpenAI responses model ID to use, for example, gpt-4o or o1.
(Env var OPENAI_RESPONSES_MODEL_ID)
env_file_path: The path to the .env file to load settings from.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
"""
env_prefix: ClassVar[str] = "OPENAI_"
api_key: SecretStr | None = None
base_url: str | None = None
org_id: str | None = None
chat_model_id: str | None = None
responses_model_id: str | None = None
class OpenAIBase(AFBaseModel):
"""Base class for OpenAI Clients."""
client: AsyncOpenAI
ai_model_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
class OpenAIConfigMixin(OpenAIBase):
"""Internal class for configuring a connection to an OpenAI service."""
OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc]
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def __init__(
self,
ai_model_id: str = Field(min_length=1),
api_key: str | None = Field(min_length=1),
org_id: str | None = None,
default_headers: Mapping[str, str] | None = None,
client: AsyncOpenAI | None = None,
instruction_role: str | None = None,
base_url: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a client for OpenAI services.
This constructor sets up a client to interact with OpenAI's API, allowing for
different types of AI model interactions, like chat or text completion.
Args:
ai_model_id: OpenAI model identifier. Must be non-empty.
Default to a preset value.
api_key: OpenAI API key for authentication.
Must be non-empty. (Optional)
org_id: OpenAI organization ID. This is optional
unless the account belongs to multiple organizations.
default_headers: Default headers
for HTTP requests. (Optional)
client: An existing OpenAI client, optional.
instruction_role: The role to use for 'instruction'
messages, for example, summarization prompts could use `developer` or `system`. (Optional)
base_url: The optional base URL to use. If provided will override the standard value for a OpenAI connector.
Will not be used when supplying a custom client.
kwargs: Additional keyword arguments.
"""
# Merge APP_INFO into the headers if it exists
merged_headers = dict(copy(default_headers)) if default_headers else {}
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
if not client:
if not api_key:
raise ServiceInitializationError("Please provide an api_key")
args: dict[str, Any] = {"api_key": api_key, "default_headers": merged_headers}
if org_id:
args["organization"] = org_id
if base_url:
args["base_url"] = base_url
client = AsyncOpenAI(**args)
args = {
"ai_model_id": ai_model_id,
"client": client,
}
if instruction_role:
args["instruction_role"] = instruction_role
super().__init__(**args, **kwargs)
def to_dict(self) -> dict[str, Any]:
"""Create a dict of the service settings."""
client_settings = {
"api_key": self.client.api_key,
"default_headers": {k: v for k, v in self.client.default_headers.items() if k != USER_AGENT_KEY},
}
if self.client.organization:
client_settings["org_id"] = self.client.organization
base = self.model_dump(
exclude={
"prompt_tokens",
"completion_tokens",
"total_tokens",
"api_type",
"client",
},
by_alias=True,
exclude_none=True,
)
base.update(client_settings)
return base
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_redis"
PACKAGE_EXTRA = "redis"
_IMPORTS = ["__version__", "RedisProvider", "RedisChatMessageStore"]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(PACKAGE_NAME), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_EXTRA}' extra is not installed, "
f"please do `pip install agent-framework[{PACKAGE_EXTRA}]`"
) from exc
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_redis import RedisChatMessageStore, RedisProvider, __version__
__all__ = ["RedisChatMessageStore", "RedisProvider", "__version__"]
+115
View File
@@ -0,0 +1,115 @@
[project]
name = "agent-framework-core"
description = "Microsoft Agent Framework for building AI Agents with Python. This is the core package that has all the core abstractions and implementations."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0-b251001"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/"
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 :: 5 - Production/Stable",
"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",
"Framework :: Pydantic :: 2",
"Typing :: Typed",
]
dependencies = [
"openai>=1.99.0",
"pydantic>=2,<3",
"pydantic-settings>=2,<3",
"typing-extensions",
"opentelemetry-api>=1.24",
"opentelemetry-sdk>=1.24",
"mcp[ws]>=1.13",
"azure-monitor-opentelemetry>=1.7.0",
"azure-monitor-opentelemetry-exporter>=1.0.0b41",
"opentelemetry-exporter-otlp-proto-grpc>=1.36.0",
"opentelemetry-semantic-conventions-ai>=0.4.13",
"aiofiles>=24.1.0",
"azure-identity>=1,<2",
]
[project.optional-dependencies]
viz = [
"graphviz>=0.20.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',
'packages/core/tests',
'packages/a2a/tests',
'packages/azure-ai/tests',
'packages/copilotstudio/tests',
'packages/mem0/tests',
'packages/runtime/tests'
]
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.pyright]
extend = "../../pyproject.toml"
exclude = ['tests']
[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"]
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"
test = "pytest --cov=agent_framework --cov-report=term-missing:skip-covered tests"
[tool.flit.module]
name = "agent_framework"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,62 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from pytest import fixture
from agent_framework import ChatMessage
# region: Connector Settings fixtures
@fixture
def exclude_list(request: Any) -> list[str]:
"""Fixture that returns a list of environment variables to exclude."""
return request.param if hasattr(request, "param") else []
@fixture
def override_env_param_dict(request: Any) -> dict[str, str]:
"""Fixture that returns a dict of environment variables to override."""
return request.param if hasattr(request, "param") else {}
# These two fixtures are used for multiple things, also non-connector tests
@fixture()
def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
"""Fixture to set environment variables for AzureOpenAISettings."""
if exclude_list is None:
exclude_list = []
if override_env_param_dict is None:
override_env_param_dict = {}
env_vars = {
"AZURE_OPENAI_ENDPOINT": "https://test-endpoint.com",
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment",
"AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_chat_deployment",
"AZURE_OPENAI_TEXT_DEPLOYMENT_NAME": "test_text_deployment",
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment",
"AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME": "test_text_to_image_deployment",
"AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME": "test_audio_to_text_deployment",
"AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME": "test_text_to_audio_deployment",
"AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME": "test_realtime_deployment",
"AZURE_OPENAI_API_KEY": "test_api_key",
"AZURE_OPENAI_API_VERSION": "2023-03-15-preview",
"AZURE_OPENAI_BASE_URL": "https://test_text_deployment.test-base-url.com",
"AZURE_OPENAI_TOKEN_ENDPOINT": "https://test-token-endpoint.com",
}
env_vars.update(override_env_param_dict) # type: ignore
for key, value in env_vars.items():
if key in exclude_list:
monkeypatch.delenv(key, raising=False) # type: ignore
continue
monkeypatch.setenv(key, value) # type: ignore
return env_vars
@fixture(scope="function")
def chat_history() -> list[ChatMessage]:
return []
@@ -0,0 +1,723 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Annotated
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from azure.identity import AzureCliCredential
from pydantic import Field
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatResponse,
ChatResponseUpdate,
HostedCodeInterpreterTool,
TextContent,
)
from agent_framework.azure import AzureOpenAIAssistantsClient
from agent_framework.exceptions import ServiceInitializationError
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"),
reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
def create_test_azure_assistants_client(
mock_async_azure_openai: MagicMock,
deployment_name: str | None = None,
assistant_id: str | None = None,
assistant_name: str | None = None,
thread_id: str | None = None,
should_delete_assistant: bool = False,
) -> AzureOpenAIAssistantsClient:
"""Helper function to create AzureOpenAIAssistantsClient instances for testing, bypassing Pydantic validation."""
return AzureOpenAIAssistantsClient.model_construct(
ai_model_id=deployment_name or "test_chat_deployment",
assistant_id=assistant_id,
assistant_name=assistant_name,
thread_id=thread_id,
api_key="test-api-key",
endpoint="https://test-endpoint.com",
client=mock_async_azure_openai,
_should_delete_assistant=should_delete_assistant,
)
@pytest.fixture
def mock_async_azure_openai() -> MagicMock:
"""Mock AsyncAzureOpenAI client."""
mock_client = MagicMock()
# Mock beta.assistants
mock_client.beta.assistants.create = AsyncMock(return_value=MagicMock(id="test-assistant-id"))
mock_client.beta.assistants.delete = AsyncMock()
# Mock beta.threads
mock_client.beta.threads.create = AsyncMock(return_value=MagicMock(id="test-thread-id"))
mock_client.beta.threads.delete = AsyncMock()
# Mock beta.threads.runs
mock_client.beta.threads.runs.create = AsyncMock(return_value=MagicMock(id="test-run-id"))
mock_client.beta.threads.runs.retrieve = AsyncMock()
mock_client.beta.threads.runs.submit_tool_outputs = AsyncMock()
# Mock beta.threads.messages
mock_client.beta.threads.messages.create = AsyncMock()
mock_client.beta.threads.messages.list = AsyncMock(return_value=MagicMock(data=[]))
return mock_client
def test_azure_assistants_client_init_with_client(mock_async_azure_openai: MagicMock) -> None:
"""Test AzureOpenAIAssistantsClient initialization with existing client."""
chat_client = create_test_azure_assistants_client(
mock_async_azure_openai,
deployment_name="test_chat_deployment",
assistant_id="existing-assistant-id",
thread_id="test-thread-id",
)
assert chat_client.client is mock_async_azure_openai
assert chat_client.ai_model_id == "test_chat_deployment"
assert chat_client.assistant_id == "existing-assistant-id"
assert chat_client.thread_id == "test-thread-id"
assert not chat_client._should_delete_assistant # type: ignore
assert isinstance(chat_client, ChatClientProtocol)
def test_azure_assistants_client_init_auto_create_client(
azure_openai_unit_test_env: dict[str, str],
mock_async_azure_openai: MagicMock,
) -> None:
"""Test AzureOpenAIAssistantsClient initialization with auto-created client."""
chat_client = AzureOpenAIAssistantsClient.model_construct(
ai_model_id=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
assistant_id=None,
assistant_name="TestAssistant",
thread_id=None,
api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
client=mock_async_azure_openai,
_should_delete_assistant=False,
)
assert chat_client.client is mock_async_azure_openai
assert chat_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
assert chat_client.assistant_id is None
assert chat_client.assistant_name == "TestAssistant"
assert not chat_client._should_delete_assistant # type: ignore
def test_azure_assistants_client_init_validation_fail() -> None:
"""Test AzureOpenAIAssistantsClient initialization with validation failure."""
with pytest.raises(ServiceInitializationError):
# Force failure by providing invalid deployment name type - this should cause validation to fail
AzureOpenAIAssistantsClient(deployment_name=123, api_key="valid-key") # type: ignore
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True)
def test_azure_assistants_client_init_missing_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test AzureOpenAIAssistantsClient initialization with missing deployment name."""
with pytest.raises(ServiceInitializationError):
AzureOpenAIAssistantsClient(
api_key=azure_openai_unit_test_env.get("AZURE_OPENAI_API_KEY", "test-key"), env_file_path="nonexistent.env"
)
def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test AzureOpenAIAssistantsClient initialization with default headers."""
default_headers = {"X-Unit-Test": "test-guid"}
chat_client = AzureOpenAIAssistantsClient(
deployment_name="test_chat_deployment",
api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
default_headers=default_headers,
)
assert chat_client.ai_model_id == "test_chat_deployment"
assert isinstance(chat_client, ChatClientProtocol)
# Assert that the default header we added is present in the client's default headers
for key, value in default_headers.items():
assert key in chat_client.client.default_headers
assert chat_client.client.default_headers[key] == value
async def test_azure_assistants_client_get_assistant_id_or_create_existing_assistant(
mock_async_azure_openai: MagicMock,
) -> None:
"""Test _get_assistant_id_or_create when assistant_id is already provided."""
chat_client = create_test_azure_assistants_client(mock_async_azure_openai, assistant_id="existing-assistant-id")
assistant_id = await chat_client._get_assistant_id_or_create() # type: ignore
assert assistant_id == "existing-assistant-id"
assert not chat_client._should_delete_assistant # type: ignore
mock_async_azure_openai.beta.assistants.create.assert_not_called()
async def test_azure_assistants_client_get_assistant_id_or_create_create_new(
mock_async_azure_openai: MagicMock,
) -> None:
"""Test _get_assistant_id_or_create when creating a new assistant."""
chat_client = create_test_azure_assistants_client(
mock_async_azure_openai, deployment_name="test_chat_deployment", assistant_name="TestAssistant"
)
assistant_id = await chat_client._get_assistant_id_or_create() # type: ignore
assert assistant_id == "test-assistant-id"
assert chat_client._should_delete_assistant # type: ignore
mock_async_azure_openai.beta.assistants.create.assert_called_once()
async def test_azure_assistants_client_aclose_should_not_delete(
mock_async_azure_openai: MagicMock,
) -> None:
"""Test close when assistant should not be deleted."""
chat_client = create_test_azure_assistants_client(
mock_async_azure_openai, assistant_id="assistant-to-keep", should_delete_assistant=False
)
await chat_client.close() # type: ignore
# Verify assistant deletion was not called
mock_async_azure_openai.beta.assistants.delete.assert_not_called()
assert not chat_client._should_delete_assistant # type: ignore
async def test_azure_assistants_client_aclose_should_delete(mock_async_azure_openai: MagicMock) -> None:
"""Test close method calls cleanup."""
chat_client = create_test_azure_assistants_client(
mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True
)
await chat_client.close()
# Verify assistant deletion was called
mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete")
assert not chat_client._should_delete_assistant # type: ignore
async def test_azure_assistants_client_async_context_manager(mock_async_azure_openai: MagicMock) -> None:
"""Test async context manager functionality."""
chat_client = create_test_azure_assistants_client(
mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True
)
# Test context manager
async with chat_client:
pass # Just test that we can enter and exit
# Verify cleanup was called on exit
mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete")
def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test serialization of AzureOpenAIAssistantsClient."""
default_headers = {"X-Unit-Test": "test-guid"}
# Test basic initialization and to_dict
chat_client = AzureOpenAIAssistantsClient(
deployment_name="test_chat_deployment",
assistant_id="test-assistant-id",
assistant_name="TestAssistant",
thread_id="test-thread-id",
api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
default_headers=default_headers,
)
dumped_settings = chat_client.to_dict()
assert dumped_settings["ai_model_id"] == "test_chat_deployment"
assert dumped_settings["assistant_id"] == "test-assistant-id"
assert dumped_settings["assistant_name"] == "TestAssistant"
assert dumped_settings["thread_id"] == "test-thread-id"
assert dumped_settings["api_key"] == azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"]
# Assert that the default header we added is present in the dumped_settings default headers
for key, value in default_headers.items():
assert key in dumped_settings["default_headers"]
assert dumped_settings["default_headers"][key] == value
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
assert "User-Agent" not in dumped_settings["default_headers"]
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
return f"The weather in {location} is sunny with a high of 25°C."
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_client_get_response() -> None:
"""Test Azure Assistants Client response."""
async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client:
assert isinstance(azure_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="The weather in Seattle is currently sunny with a high of 25°C. "
"It's a beautiful day for outdoor activities.",
)
)
messages.append(ChatMessage(role="user", text="What's the weather like today?"))
# Test that the client can be used to get a response
response = await azure_assistants_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
assert any(word in response.text.lower() for word in ["sunny", "25", "weather", "seattle"])
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_client_get_response_tools() -> None:
"""Test Azure Assistants Client response with tools."""
async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client:
assert isinstance(azure_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?"))
# Test that the client can be used to get a response
response = await azure_assistants_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert any(word in response.text.lower() for word in ["sunny", "25", "weather"])
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_client_streaming() -> None:
"""Test Azure Assistants Client streaming response."""
async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client:
assert isinstance(azure_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="The weather in Seattle is currently sunny with a high of 25°C. "
"It's a beautiful day for outdoor activities.",
)
)
messages.append(ChatMessage(role="user", text="What's the weather like today?"))
# Test that the client can be used to get a response
response = azure_assistants_client.get_streaming_response(messages=messages)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"])
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_client_streaming_tools() -> None:
"""Test Azure Assistants Client streaming response with tools."""
async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as azure_assistants_client:
assert isinstance(azure_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="What's the weather like in Seattle?"))
# Test that the client can be used to get a response
response = azure_assistants_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather"])
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_client_with_existing_assistant() -> None:
"""Test Azure Assistants Client with existing assistant ID."""
# First create an assistant to use in the test
async with AzureOpenAIAssistantsClient(credential=AzureCliCredential()) as temp_client:
# Get the assistant ID by triggering assistant creation
messages = [ChatMessage(role="user", text="Hello")]
await temp_client.get_response(messages=messages)
assistant_id = temp_client.assistant_id
# Now test using the existing assistant
async with AzureOpenAIAssistantsClient(
assistant_id=assistant_id, credential=AzureCliCredential()
) as azure_assistants_client:
assert isinstance(azure_assistants_client, ChatClientProtocol)
assert azure_assistants_client.assistant_id == assistant_id
messages = [ChatMessage(role="user", text="What can you do?")]
# Test that the client can be used to get a response
response = await azure_assistants_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
assert len(response.text) > 0
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_basic_run():
"""Test ChatAgent basic run functionality with AzureOpenAIAssistantsClient."""
async with ChatAgent(
chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
) as agent:
# Run a simple query
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
assert "Hello World" in response.text
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_basic_run_streaming():
"""Test ChatAgent basic streaming functionality with AzureOpenAIAssistantsClient."""
async with ChatAgent(
chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
) as agent:
# Run streaming query
full_message: str = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert chunk is not None
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_message += chunk.text
# Validate streaming response
assert len(full_message) > 0
assert "streaming response test" in full_message.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_thread_persistence():
"""Test ChatAgent thread persistence across runs with AzureOpenAIAssistantsClient."""
async with ChatAgent(
chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# First message - establish context
first_response = await agent.run(
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
)
assert isinstance(first_response, AgentRunResponse)
assert "42" in first_response.text
# Second message - test conversation memory
second_response = await agent.run(
"What number did I tell you to remember in my previous message?", thread=thread
)
assert isinstance(second_response, AgentRunResponse)
assert "42" in second_response.text
# Verify thread has been populated with conversation ID
assert thread.service_thread_id is not None
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_existing_thread_id():
"""Test ChatAgent with existing thread ID to continue conversations across agent instances."""
# First, create a conversation and capture the thread ID
existing_thread_id = None
async with ChatAgent(
chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Start a conversation and get the thread ID
thread = agent.get_new_thread()
response1 = await agent.run("What's the weather in Paris?", thread=thread)
# Validate first response
assert isinstance(response1, AgentRunResponse)
assert response1.text is not None
assert any(word in response1.text.lower() for word in ["weather", "paris"])
# The thread ID is set after the first response
existing_thread_id = thread.service_thread_id
assert existing_thread_id is not None
# Now continue with the same thread ID in a new agent instance
async with ChatAgent(
chat_client=AzureOpenAIAssistantsClient(thread_id=existing_thread_id, credential=AzureCliCredential()),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Create a thread with the existing ID
thread = AgentThread(service_thread_id=existing_thread_id)
# Ask about the previous conversation
response2 = await agent.run("What was the last city I asked about?", thread=thread)
# Validate that the agent remembers the previous conversation
assert isinstance(response2, AgentRunResponse)
assert response2.text is not None
# Should reference Paris from the previous conversation
assert "paris" in response2.text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_code_interpreter():
"""Test ChatAgent with code interpreter through AzureOpenAIAssistantsClient."""
async with ChatAgent(
chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that can write and execute Python code.",
tools=[HostedCodeInterpreterTool()],
) as agent:
# Request code execution
response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.")
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
# Factorial of 5 is 120
assert "120" in response.text or "factorial" in response.text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_client_agent_level_tool_persistence():
"""Test that agent-level tools persist across multiple runs with Azure Assistants Client."""
async with ChatAgent(
chat_client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather], # Agent-level tool
) as agent:
# First run - agent-level tool should be available
first_response = await agent.run("What's the weather like in Chicago?")
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the agent-level weather tool
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - agent-level tool should still be available (persistence test)
second_response = await agent.run("What's the weather in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should use the agent-level weather tool again
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
def test_azure_assistants_client_entra_id_authentication() -> None:
"""Test Entra ID authentication path with credential."""
mock_credential = MagicMock()
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key = None # No API key to trigger Entra ID path
mock_settings.token_endpoint = "https://login.microsoftonline.com/test"
mock_settings.get_azure_auth_token.return_value = "entra-token-12345"
mock_settings.api_version = "2024-05-01-preview"
mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
mock_settings.base_url = None
mock_settings_class.return_value = mock_settings
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
api_key="placeholder-key",
endpoint="https://test-endpoint.openai.azure.com",
credential=mock_credential,
token_endpoint="https://login.microsoftonline.com/test",
)
# Verify Entra ID token was requested
mock_settings.get_azure_auth_token.assert_called_once_with(mock_credential)
# Verify client was created with the token
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["azure_ad_token"] == "entra-token-12345"
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
def test_azure_assistants_client_no_authentication_error() -> None:
"""Test authentication validation error when no auth provided."""
with patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class:
mock_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key = None # No API key
mock_settings.token_endpoint = None # No token endpoint
mock_settings_class.return_value = mock_settings
# Test missing authentication raises error
with pytest.raises(ServiceInitializationError, match="API key, ad_token, or ad_token_provider is required"):
AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
endpoint="https://test-endpoint.openai.azure.com",
# No authentication provided at all
)
def test_azure_assistants_client_ad_token_authentication() -> None:
"""Test ad_token authentication client parameter path."""
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key = None # No API key
mock_settings.api_version = "2024-05-01-preview"
mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
mock_settings.base_url = None
mock_settings_class.return_value = mock_settings
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
endpoint="https://test-endpoint.openai.azure.com",
ad_token="test-ad-token-12345",
)
# ad_token path
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["azure_ad_token"] == "test-ad-token-12345"
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
def test_azure_assistants_client_ad_token_provider_authentication() -> None:
"""Test ad_token_provider authentication client parameter path."""
from openai.lib.azure import AsyncAzureADTokenProvider
mock_token_provider = MagicMock(spec=AsyncAzureADTokenProvider)
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key = None # No API key
mock_settings.api_version = "2024-05-01-preview"
mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
mock_settings.base_url = None
mock_settings_class.return_value = mock_settings
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
endpoint="https://test-endpoint.openai.azure.com",
ad_token_provider=mock_token_provider,
)
# ad_token_provider path
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["azure_ad_token_provider"] is mock_token_provider
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
def test_azure_assistants_client_base_url_configuration() -> None:
"""Test base_url client parameter path."""
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key.get_secret_value.return_value = "test-api-key"
mock_settings.base_url = "https://custom-base-url.com"
mock_settings.endpoint = None # No endpoint, should use base_url
mock_settings.api_version = "2024-05-01-preview"
mock_settings_class.return_value = mock_settings
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment", api_key="test-api-key", base_url="https://custom-base-url.com"
)
# base_url path
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["base_url"] == "https://custom-base-url.com"
assert "azure_endpoint" not in call_args
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
def test_azure_assistants_client_azure_endpoint_configuration() -> None:
"""Test azure_endpoint client parameter path."""
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key.get_secret_value.return_value = "test-api-key"
mock_settings.base_url = None # No base_url
mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
mock_settings.api_version = "2024-05-01-preview"
mock_settings_class.return_value = mock_settings
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
api_key="test-api-key",
endpoint="https://test-endpoint.openai.azure.com",
)
# azure_endpoint path
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["azure_endpoint"] == "https://test-endpoint.openai.azure.com"
assert "base_url" not in call_args
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
@@ -0,0 +1,835 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import os
from unittest.mock import AsyncMock, MagicMock, patch
import openai
import pytest
from azure.identity import AzureCliCredential
from httpx import Request, Response
from openai import AsyncAzureOpenAI, AsyncStream
from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions
from openai.types.chat import ChatCompletion, ChatCompletionChunk
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
BaseChatClient,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatResponse,
ChatResponseUpdate,
TextContent,
ai_function,
)
from agent_framework._telemetry import USER_AGENT_KEY
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.openai import (
ContentFilterResultSeverity,
OpenAIContentFilterException,
)
# region Service Setup
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"),
reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
def test_init(azure_openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
azure_chat_client = AzureOpenAIChatClient()
assert azure_chat_client.client is not None
assert isinstance(azure_chat_client.client, AsyncAzureOpenAI)
assert azure_chat_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
assert isinstance(azure_chat_client, BaseChatClient)
def test_init_client(azure_openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization with client
client = MagicMock(spec=AsyncAzureOpenAI)
azure_chat_client = AzureOpenAIChatClient(async_client=client)
assert azure_chat_client.client is not None
assert isinstance(azure_chat_client.client, AsyncAzureOpenAI)
def test_init_base_url(azure_openai_unit_test_env: dict[str, str]) -> None:
# Custom header for testing
default_headers = {"X-Unit-Test": "test-guid"}
azure_chat_client = AzureOpenAIChatClient(
default_headers=default_headers,
)
assert azure_chat_client.client is not None
assert isinstance(azure_chat_client.client, AsyncAzureOpenAI)
assert azure_chat_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
assert isinstance(azure_chat_client, BaseChatClient)
for key, value in default_headers.items():
assert key in azure_chat_client.client.default_headers
assert azure_chat_client.client.default_headers[key] == value
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True)
def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
azure_chat_client = AzureOpenAIChatClient()
assert azure_chat_client.client is not None
assert isinstance(azure_chat_client.client, AsyncAzureOpenAI)
assert azure_chat_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
assert isinstance(azure_chat_client, BaseChatClient)
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True)
def test_init_with_empty_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
AzureOpenAIChatClient(
env_file_path="test.env",
)
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True)
def test_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
AzureOpenAIChatClient(
env_file_path="test.env",
)
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
def test_init_with_invalid_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
AzureOpenAIChatClient()
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True)
def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Test": "test"}
settings = {
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
"endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
"api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"],
"default_headers": default_headers,
"env_file_path": "test.env",
}
azure_chat_client = AzureOpenAIChatClient.from_dict(settings)
dumped_settings = azure_chat_client.to_dict()
assert dumped_settings["ai_model_id"] == settings["deployment_name"]
assert str(settings["endpoint"]) in str(dumped_settings["base_url"])
assert str(settings["deployment_name"]) in str(dumped_settings["base_url"])
assert settings["api_key"] == dumped_settings["api_key"]
assert settings["api_version"] == dumped_settings["api_version"]
# Assert that the default header we added is present in the dumped_settings default headers
for key, value in default_headers.items():
assert key in dumped_settings["default_headers"]
assert dumped_settings["default_headers"][key] == value
# Assert that the 'User-agent' header is not present in the dumped_settings default headers
assert USER_AGENT_KEY not in dumped_settings["default_headers"]
# endregion
# region CMC
@pytest.fixture
def mock_chat_completion_response() -> ChatCompletion:
return ChatCompletion(
id="test_id",
choices=[
Choice(index=0, message=ChatCompletionMessage(content="test", role="assistant"), finish_reason="stop")
],
created=0,
model="test",
object="chat.completion",
)
@pytest.fixture
def mock_streaming_chat_completion_response() -> AsyncStream[ChatCompletionChunk]:
content = ChatCompletionChunk(
id="test_id",
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
created=0,
model="test",
object="chat.completion.chunk",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [content]
return stream
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
) -> None:
mock_create.return_value = mock_chat_completion_response
chat_history.append(ChatMessage(text="hello world", role="user"))
azure_chat_client = AzureOpenAIChatClient()
await azure_chat_client.get_response(
messages=chat_history,
)
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
stream=False,
messages=azure_chat_client._prepare_chat_history_for_request(chat_history), # type: ignore
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc_with_logit_bias(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
) -> None:
mock_create.return_value = mock_chat_completion_response
prompt = "hello world"
chat_history.append(ChatMessage(text=prompt, role="user"))
token_bias: dict[str | int, float] = {"1": -100}
azure_chat_client = AzureOpenAIChatClient()
await azure_chat_client.get_response(messages=chat_history, logit_bias=token_bias)
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
messages=azure_chat_client._prepare_chat_history_for_request(chat_history), # type: ignore
stream=False,
logit_bias=token_bias,
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc_with_stop(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
) -> None:
mock_create.return_value = mock_chat_completion_response
prompt = "hello world"
chat_history.append(ChatMessage(text=prompt, role="user"))
stop = ["!"]
azure_chat_client = AzureOpenAIChatClient()
await azure_chat_client.get_response(messages=chat_history, stop=stop)
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
messages=azure_chat_client._prepare_chat_history_for_request(chat_history), # type: ignore
stream=False,
stop=stop,
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_azure_on_your_data(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
) -> None:
mock_chat_completion_response.choices = [
Choice(
index=0,
message=ChatCompletionMessage(
content="test",
role="assistant",
context={ # type: ignore
"citations": [
{
"content": "test content",
"title": "test title",
"url": "test url",
"filepath": "test filepath",
"chunk_id": "test chunk_id",
}
],
"intent": "query used",
},
),
finish_reason="stop",
)
]
mock_create.return_value = mock_chat_completion_response
prompt = "hello world"
messages_in = chat_history
chat_history.append(ChatMessage(text=prompt, role="user"))
messages_out: list[ChatMessage] = []
messages_out.append(ChatMessage(text=prompt, role="user"))
expected_data_settings = {
"data_sources": [
{
"type": "AzureCognitiveSearch",
"parameters": {
"indexName": "test_index",
"endpoint": "https://test-endpoint-search.com",
"key": "test_key",
},
}
]
}
azure_chat_client = AzureOpenAIChatClient()
content = await azure_chat_client.get_response(
messages=messages_in,
additional_properties={"extra_body": expected_data_settings},
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert len(content.messages[0].contents[0].annotations) == 1
assert content.messages[0].contents[0].annotations[0].title == "test title"
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
messages=azure_chat_client._prepare_chat_history_for_request(messages_out), # type: ignore
stream=False,
extra_body=expected_data_settings,
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_azure_on_your_data_string(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
) -> None:
mock_chat_completion_response.choices = [
Choice(
index=0,
message=ChatCompletionMessage(
content="test",
role="assistant",
context=json.dumps({ # type: ignore
"citations": [
{
"content": "test content",
"title": "test title",
"url": "test url",
"filepath": "test filepath",
"chunk_id": "test chunk_id",
}
],
"intent": "query used",
}),
),
finish_reason="stop",
)
]
mock_create.return_value = mock_chat_completion_response
prompt = "hello world"
messages_in = chat_history
messages_in.append(ChatMessage(text=prompt, role="user"))
messages_out: list[ChatMessage] = []
messages_out.append(ChatMessage(text=prompt, role="user"))
expected_data_settings = {
"data_sources": [
{
"type": "AzureCognitiveSearch",
"parameters": {
"indexName": "test_index",
"endpoint": "https://test-endpoint-search.com",
"key": "test_key",
},
}
]
}
azure_chat_client = AzureOpenAIChatClient()
content = await azure_chat_client.get_response(
messages=messages_in,
additional_properties={"extra_body": expected_data_settings},
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert len(content.messages[0].contents[0].annotations) == 1
assert content.messages[0].contents[0].annotations[0].title == "test title"
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
messages=azure_chat_client._prepare_chat_history_for_request(messages_out), # type: ignore
stream=False,
extra_body=expected_data_settings,
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_azure_on_your_data_fail(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
) -> None:
mock_chat_completion_response.choices = [
Choice(
index=0,
message=ChatCompletionMessage(
content="test",
role="assistant",
context="not a dictionary", # type: ignore
),
finish_reason="stop",
)
]
mock_create.return_value = mock_chat_completion_response
prompt = "hello world"
messages_in = chat_history
messages_in.append(ChatMessage(text=prompt, role="user"))
messages_out: list[ChatMessage] = []
messages_out.append(ChatMessage(text=prompt, role="user"))
expected_data_settings = {
"data_sources": [
{
"type": "AzureCognitiveSearch",
"parameters": {
"indexName": "test_index",
"endpoint": "https://test-endpoint-search.com",
"key": "test_key",
},
}
]
}
azure_chat_client = AzureOpenAIChatClient()
content = await azure_chat_client.get_response(
messages=messages_in,
additional_properties={"extra_body": expected_data_settings},
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
messages=azure_chat_client._prepare_chat_history_for_request(messages_out), # type: ignore
stream=False,
extra_body=expected_data_settings,
)
CONTENT_FILTERED_ERROR_MESSAGE = (
"The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please "
"modify your prompt and retry. To learn more about our content filtering policies please read our "
"documentation: https://go.microsoft.com/fwlink/?linkid=2198766"
)
CONTENT_FILTERED_ERROR_FULL_MESSAGE = (
"Error code: 400 - {'error': {'message': \"%s\", 'type': null, 'param': 'prompt', 'code': 'content_filter', "
"'status': 400, 'innererror': {'code': 'ResponsibleAIPolicyViolation', 'content_filter_result': {'hate': "
"{'filtered': True, 'severity': 'high'}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': "
"{'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}}}"
) % CONTENT_FILTERED_ERROR_MESSAGE
@patch.object(AsyncChatCompletions, "create")
async def test_content_filtering_raises_correct_exception(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
) -> None:
prompt = "some prompt that would trigger the content filtering"
chat_history.append(ChatMessage(text=prompt, role="user"))
test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
assert test_endpoint is not None
mock_create.side_effect = openai.BadRequestError(
CONTENT_FILTERED_ERROR_FULL_MESSAGE,
response=Response(400, request=Request("POST", test_endpoint)),
body={
"message": CONTENT_FILTERED_ERROR_MESSAGE,
"type": None,
"param": "prompt",
"code": "content_filter",
"status": 400,
"innererror": {
"code": "ResponsibleAIPolicyViolation",
"content_filter_result": {
"hate": {"filtered": True, "severity": "high"},
"self_harm": {"filtered": False, "severity": "safe"},
"sexual": {"filtered": False, "severity": "safe"},
"violence": {"filtered": False, "severity": "safe"},
},
},
},
)
azure_chat_client = AzureOpenAIChatClient()
with pytest.raises(OpenAIContentFilterException, match="service encountered a content error") as exc_info:
await azure_chat_client.get_response(
messages=chat_history,
)
content_filter_exc = exc_info.value
assert content_filter_exc.param == "prompt"
assert content_filter_exc.content_filter_result["hate"].filtered
assert content_filter_exc.content_filter_result["hate"].severity == ContentFilterResultSeverity.HIGH
@patch.object(AsyncChatCompletions, "create")
async def test_content_filtering_without_response_code_raises_with_default_code(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
) -> None:
prompt = "some prompt that would trigger the content filtering"
chat_history.append(ChatMessage(text=prompt, role="user"))
test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
assert test_endpoint is not None
mock_create.side_effect = openai.BadRequestError(
CONTENT_FILTERED_ERROR_FULL_MESSAGE,
response=Response(400, request=Request("POST", test_endpoint)),
body={
"message": CONTENT_FILTERED_ERROR_MESSAGE,
"type": None,
"param": "prompt",
"code": "content_filter",
"status": 400,
"innererror": {
"content_filter_result": {
"hate": {"filtered": True, "severity": "high"},
"self_harm": {"filtered": False, "severity": "safe"},
"sexual": {"filtered": False, "severity": "safe"},
"violence": {"filtered": False, "severity": "safe"},
},
},
},
)
azure_chat_client = AzureOpenAIChatClient()
with pytest.raises(OpenAIContentFilterException, match="service encountered a content error"):
await azure_chat_client.get_response(
messages=chat_history,
)
@patch.object(AsyncChatCompletions, "create")
async def test_bad_request_non_content_filter(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
) -> None:
prompt = "some prompt that would trigger the content filtering"
chat_history.append(ChatMessage(text=prompt, role="user"))
test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
assert test_endpoint is not None
mock_create.side_effect = openai.BadRequestError(
"The request was bad.", response=Response(400, request=Request("POST", test_endpoint)), body={}
)
azure_chat_client = AzureOpenAIChatClient()
with pytest.raises(ServiceResponseException, match="service failed to complete the prompt"):
await azure_chat_client.get_response(
messages=chat_history,
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_get_streaming(
mock_create: AsyncMock,
azure_openai_unit_test_env: dict[str, str],
chat_history: list[ChatMessage],
mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk],
) -> None:
mock_create.return_value = mock_streaming_chat_completion_response
chat_history.append(ChatMessage(text="hello world", role="user"))
azure_chat_client = AzureOpenAIChatClient()
async for msg in azure_chat_client.get_streaming_response(
messages=chat_history,
):
assert msg is not None
assert msg.message_id is not None
assert msg.response_id is not None
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
stream=True,
messages=azure_chat_client._prepare_chat_history_for_request(chat_history), # type: ignore
# NOTE: The `stream_options={"include_usage": True}` is explicitly enforced in
# `OpenAIChatCompletionBase._inner_get_streaming_response`.
# To ensure consistency, we align the arguments here accordingly.
stream_options={"include_usage": True},
)
@ai_function
def get_story_text() -> str:
"""Returns a story about Emily and David."""
return (
"Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change."
)
@ai_function
def get_weather(location: str) -> str:
"""Get the current weather for a location."""
return f"The weather in {location} is sunny and 72°F."
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_response() -> None:
"""Test Azure OpenAI chat completion responses."""
azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
assert isinstance(azure_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await azure_chat_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
# Check for any relevant keywords that indicate the AI understood the context
assert any(
word in response.text.lower() for word in ["scientists", "research", "antarctica", "glaciology", "climate"]
)
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_response_tools() -> None:
"""Test AzureOpenAI chat completion responses."""
azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
assert isinstance(azure_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await azure_chat_client.get_response(
messages=messages,
tools=[get_story_text],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert "scientists" in response.text
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_streaming() -> None:
"""Test Azure OpenAI chat completion responses."""
azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
assert isinstance(azure_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = azure_chat_client.get_streaming_response(messages=messages)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
assert chunk.message_id is not None
assert chunk.response_id is not None
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "scientists" in full_message
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_streaming_tools() -> None:
"""Test AzureOpenAI chat completion responses."""
azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
assert isinstance(azure_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = azure_chat_client.get_streaming_response(
messages=messages,
tools=[get_story_text],
tool_choice="auto",
)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "scientists" in full_message
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_agent_basic_run():
"""Test Azure OpenAI chat client agent basic run functionality with AzureOpenAIChatClient."""
async with ChatAgent(
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
) as agent:
# Test basic run
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
assert "hello world" in response.text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_agent_basic_run_streaming():
"""Test Azure OpenAI chat client agent basic streaming functionality with AzureOpenAIChatClient."""
async with ChatAgent(
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
) as agent:
# Test streaming run
full_text = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_text += chunk.text
assert len(full_text) > 0
assert "streaming response test" in full_text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_agent_thread_persistence():
"""Test Azure OpenAI chat client agent thread persistence across runs with AzureOpenAIChatClient."""
async with ChatAgent(
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# First interaction
response1 = await agent.run("My name is Alice. Remember this.", thread=thread)
assert isinstance(response1, AgentRunResponse)
assert response1.text is not None
# Second interaction - test memory
response2 = await agent.run("What is my name?", thread=thread)
assert isinstance(response2, AgentRunResponse)
assert response2.text is not None
assert "alice" in response2.text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_agent_existing_thread():
"""Test Azure OpenAI chat client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async with ChatAgent(
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
async with ChatAgent(
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved thread
second_response = await second_agent.run("What is my name?", thread=preserved_thread)
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
assert "alice" in second_response.text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_chat_client_agent_level_tool_persistence():
"""Test that agent-level tools persist across multiple runs with Azure Chat Client."""
async with ChatAgent(
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather], # Agent-level tool
) as agent:
# First run - agent-level tool should be available
first_response = await agent.run("What's the weather like in Chicago?")
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the agent-level weather tool
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - agent-level tool should still be available (persistence test)
second_response = await agent.run("What's the weather in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should use the agent-level weather tool again
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
@@ -0,0 +1,624 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Annotated
import pytest
from azure.identity import AzureCliCredential
from pydantic import BaseModel
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatResponse,
ChatResponseUpdate,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
TextContent,
ai_function,
)
from agent_framework.azure import AzureOpenAIResponsesClient
from agent_framework.exceptions import ServiceInitializationError
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"),
reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
class OutputStruct(BaseModel):
"""A structured output for testing purposes."""
location: str
weather: str
@ai_function
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
"""Get the current weather in a given location."""
# Implementation of the tool to get weather
return f"The weather in {location} is sunny and 72°F."
async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str, HostedVectorStoreContent]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="assistants"
)
vector_store = await client.client.vector_stores.create(
name="knowledge_base",
expires_after={"anchor": "last_active_at", "days": 1},
)
result = await client.client.vector_stores.files.create_and_poll(vector_store_id=vector_store.id, file_id=file.id)
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
async def delete_vector_store(client: AzureOpenAIResponsesClient, file_id: str, vector_store_id: str) -> None:
"""Delete the vector store after tests."""
await client.client.vector_stores.delete(vector_store_id=vector_store_id)
await client.client.files.delete(file_id=file_id)
def test_init(azure_openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
azure_responses_client = AzureOpenAIResponsesClient()
assert azure_responses_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
assert isinstance(azure_responses_client, ChatClientProtocol)
def test_init_validation_fail() -> None:
# Test successful initialization
with pytest.raises(ServiceInitializationError):
AzureOpenAIResponsesClient(api_key="34523", deployment_name={"test": "dict"}) # type: ignore
def test_init_ai_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
ai_model_id = "test_model_id"
azure_responses_client = AzureOpenAIResponsesClient(deployment_name=ai_model_id)
assert azure_responses_client.ai_model_id == ai_model_id
assert isinstance(azure_responses_client, ChatClientProtocol)
def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
# Test successful initialization
azure_responses_client = AzureOpenAIResponsesClient(
default_headers=default_headers,
)
assert azure_responses_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
assert isinstance(azure_responses_client, ChatClientProtocol)
# Assert that the default header we added is present in the client's default headers
for key, value in default_headers.items():
assert key in azure_responses_client.client.default_headers
assert azure_responses_client.client.default_headers[key] == value
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]], indirect=True)
def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
AzureOpenAIResponsesClient(
env_file_path="test.env",
)
def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
settings = {
"ai_model_id": azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"],
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
"default_headers": default_headers,
}
azure_responses_client = AzureOpenAIResponsesClient.from_dict(settings)
dumped_settings = azure_responses_client.to_dict()
assert dumped_settings["ai_model_id"] == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
assert dumped_settings["api_key"] == azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"]
# Assert that the default header we added is present in the dumped_settings default headers
for key, value in default_headers.items():
assert key in dumped_settings["default_headers"]
assert dumped_settings["default_headers"][key] == value
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
assert "User-Agent" not in dumped_settings["default_headers"]
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_response() -> None:
"""Test azure responses client responses."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await azure_responses_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
assert "scientists" in response.text
messages.clear()
messages.append(ChatMessage(role="user", text="The weather in New York is sunny"))
messages.append(ChatMessage(role="user", text="What is the weather in New York?"))
# Test that the client can be used to get a structured response
structured_response = await azure_responses_client.get_response( # type: ignore[reportAssignmentType]
messages=messages,
response_format=OutputStruct,
)
assert structured_response is not None
assert isinstance(structured_response, ChatResponse)
assert isinstance(structured_response.value, OutputStruct)
assert structured_response.value.location == "New York"
assert "sunny" in structured_response.value.weather.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_response_tools() -> None:
"""Test azure responses client tools."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="What is the weather in New York?"))
# Test that the client can be used to get a response
response = await azure_responses_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert "sunny" in response.text
messages.clear()
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
# Test that the client can be used to get a response
structured_response: ChatResponse = await azure_responses_client.get_response( # type: ignore[reportAssignmentType]
messages=messages,
tools=[get_weather],
tool_choice="auto",
response_format=OutputStruct,
)
assert structured_response is not None
assert isinstance(structured_response, ChatResponse)
assert isinstance(structured_response.value, OutputStruct)
assert "Seattle" in structured_response.value.location
assert "sunny" in structured_response.value.weather.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_streaming() -> None:
"""Test Azure azure responses client streaming responses."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = azure_responses_client.get_streaming_response(messages=messages)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "scientists" in full_message
messages.clear()
messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny"))
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
structured_response = await ChatResponse.from_chat_response_generator(
azure_responses_client.get_streaming_response(
messages=messages,
response_format=OutputStruct,
),
output_format_type=OutputStruct,
)
assert structured_response is not None
assert isinstance(structured_response, ChatResponse)
assert isinstance(structured_response.value, OutputStruct)
assert "Seattle" in structured_response.value.location
assert "sunny" in structured_response.value.weather.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_streaming_tools() -> None:
"""Test azure responses client streaming tools."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
messages: list[ChatMessage] = [ChatMessage(role="user", text="What is the weather in Seattle?")]
# Test that the client can be used to get a response
response = azure_responses_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "sunny" in full_message
messages.clear()
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
structured_response = azure_responses_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
response_format=OutputStruct,
)
full_message = ""
async for chunk in structured_response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
output = OutputStruct.model_validate_json(full_message)
assert "Seattle" in output.location
assert "sunny" in output.weather.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_basic_run():
"""Test Azure Responses Client agent basic run functionality with AzureOpenAIResponsesClient."""
agent = AzureOpenAIResponsesClient(credential=AzureCliCredential()).create_agent(
instructions="You are a helpful assistant.",
)
# Test basic run
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
assert "hello world" in response.text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_basic_run_streaming():
"""Test Azure Responses Client agent basic streaming functionality with AzureOpenAIResponsesClient."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
) as agent:
# Test streaming run
full_text = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_text += chunk.text
assert len(full_text) > 0
assert "streaming response test" in full_text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_thread_persistence():
"""Test Azure Responses Client agent thread persistence across runs with AzureOpenAIResponsesClient."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# First interaction
first_response = await agent.run("My favorite programming language is Python. Remember this.", thread=thread)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Second interaction - test memory
second_response = await agent.run("What is my favorite programming language?", thread=thread)
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_thread_storage_with_store_true():
"""Test Azure Responses Client agent with store=True to verify service_thread_id is returned."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
) as agent:
# Create a new thread
thread = AgentThread()
# Initially, service_thread_id should be None
assert thread.service_thread_id is None
# Run with store=True to store messages on Azure/OpenAI side
response = await agent.run(
"Hello! Please remember that my name is Alex.",
thread=thread,
store=True,
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# After store=True, service_thread_id should be populated
assert thread.service_thread_id is not None
assert isinstance(thread.service_thread_id, str)
assert len(thread.service_thread_id) > 0
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_existing_thread():
"""Test Azure Responses Client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved thread
second_response = await second_agent.run("What is my hobby?", thread=preserved_thread)
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
assert "photography" in second_response.text.lower()
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_hosted_code_interpreter_tool():
"""Test Azure Responses Client agent with HostedCodeInterpreterTool through AzureOpenAIResponsesClient."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that can execute Python code.",
tools=[HostedCodeInterpreterTool()],
) as agent:
# Test code interpreter functionality
response = await agent.run("Calculate the sum of numbers from 1 to 10 using Python code.")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# Should contain calculation result (sum of 1-10 = 55) or code execution content
contains_relevant_content = any(
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
)
assert contains_relevant_content or len(response.text.strip()) > 10
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_level_tool_persistence():
"""Test that agent-level tools persist across multiple runs with Azure Responses Client."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather], # Agent-level tool
) as agent:
# First run - agent-level tool should be available
first_response = await agent.run("What's the weather like in Chicago?")
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the agent-level weather tool
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - agent-level tool should still be available (persistence test)
second_response = await agent.run("What's the weather in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should use the agent-level weather tool again
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_chat_options_run_level() -> None:
"""Integration test for comprehensive ChatOptions parameter coverage with Azure Response Agent."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_chat_options_agent_level() -> None:
"""Integration test for comprehensive ChatOptions parameter coverage with Azure Response Agent."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_hosted_mcp_tool() -> None:
"""Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP."""
mcp_tool = HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
)
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=[mcp_tool],
) as agent:
response = await agent.run(
"How to create an Azure storage account using az cli?",
max_tokens=200,
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@skip_if_azure_integration_tests_disabled
@pytest.mark.skip(reason="File search requires API key auth, subscription only allows token auth")
async def test_azure_responses_client_file_search() -> None:
"""Test Azure responses client with file search tool."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
file_id, vector_store = await create_vector_store(azure_responses_client)
# Test that the client will use the web search tool
response = await azure_responses_client.get_response(
messages=[
ChatMessage(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
tools=[HostedFileSearchTool(inputs=vector_store)],
tool_choice="auto",
)
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
assert "sunny" in response.text.lower()
assert "75" in response.text
@skip_if_azure_integration_tests_disabled
@pytest.mark.skip(reason="File search requires API key auth, subscription only allows token auth")
async def test_azure_responses_client_file_search_streaming() -> None:
"""Test Azure responses client with file search tool and streaming."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
file_id, vector_store = await create_vector_store(azure_responses_client)
# Test that the client will use the web search tool
response = azure_responses_client.get_streaming_response(
messages=[
ChatMessage(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
tools=[HostedFileSearchTool(inputs=vector_store)],
tool_choice="auto",
)
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
assert "sunny" in full_message.lower()
assert "75" in full_message
@@ -0,0 +1,156 @@
# Copyright (c) Microsoft. All rights reserved.
from unittest.mock import AsyncMock, MagicMock
import pytest
from azure.core.exceptions import ClientAuthenticationError
from agent_framework.azure._entra_id_authentication import (
get_entra_auth_token,
get_entra_auth_token_async,
)
from agent_framework.exceptions import ServiceInvalidAuthError
@pytest.fixture
def mock_credential() -> MagicMock:
"""Mock synchronous TokenCredential."""
mock_cred = MagicMock()
# Create a mock token object with a .token attribute
mock_token = MagicMock()
mock_token.token = "test-access-token-12345"
mock_cred.get_token.return_value = mock_token
return mock_cred
@pytest.fixture
def mock_async_credential() -> MagicMock:
"""Mock asynchronous AsyncTokenCredential."""
mock_cred = MagicMock()
# Create a mock token object with a .token attribute
mock_token = MagicMock()
mock_token.token = "test-async-access-token-12345"
mock_cred.get_token = AsyncMock(return_value=mock_token)
return mock_cred
def test_get_entra_auth_token_success(mock_credential: MagicMock) -> None:
"""Test successful token retrieval with sync function."""
token_endpoint = "https://test-endpoint.com/.default"
result = get_entra_auth_token(mock_credential, token_endpoint)
# Assert - check the results
assert result == "test-access-token-12345"
mock_credential.get_token.assert_called_once_with(token_endpoint)
async def test_get_entra_auth_token_async_success(mock_async_credential: MagicMock) -> None:
"""Test successful token retrieval with async function."""
token_endpoint = "https://test-endpoint.com/.default"
result = await get_entra_auth_token_async(mock_async_credential, token_endpoint)
# Assert - check the results
assert result == "test-async-access-token-12345"
mock_async_credential.get_token.assert_called_once_with(token_endpoint)
def test_get_entra_auth_token_missing_endpoint(mock_credential: MagicMock) -> None:
"""Test that missing token endpoint raises ServiceInvalidAuthError."""
# Test with empty string
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
get_entra_auth_token(mock_credential, "")
# Test with None
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
get_entra_auth_token(mock_credential, None) # type: ignore
async def test_get_entra_auth_token_async_missing_endpoint(mock_async_credential: MagicMock) -> None:
"""Test that missing token endpoint raises ServiceInvalidAuthError in async function."""
# Test with empty string
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
await get_entra_auth_token_async(mock_async_credential, "")
# Test with None
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
await get_entra_auth_token_async(mock_async_credential, None) # type: ignore
def test_get_entra_auth_token_auth_failure(mock_credential: MagicMock) -> None:
"""Test that Azure authentication failure returns None."""
mock_credential.get_token.side_effect = ClientAuthenticationError("Auth failed")
token_endpoint = "https://test-endpoint.com/.default"
result = get_entra_auth_token(mock_credential, token_endpoint)
# Assert - should return None on auth failure
assert result is None
mock_credential.get_token.assert_called_once_with(token_endpoint)
async def test_get_entra_auth_token_async_auth_failure(mock_async_credential: MagicMock) -> None:
"""Test that Azure authentication failure returns None in async function."""
mock_async_credential.get_token.side_effect = ClientAuthenticationError("Auth failed")
token_endpoint = "https://test-endpoint.com/.default"
result = await get_entra_auth_token_async(mock_async_credential, token_endpoint)
# Assert - should return None on auth failure
assert result is None
mock_async_credential.get_token.assert_called_once_with(token_endpoint)
def test_get_entra_auth_token_none_token_response(mock_credential: MagicMock) -> None:
"""Test that None token response returns None."""
mock_credential.get_token.return_value = None
token_endpoint = "https://test-endpoint.com/.default"
result = get_entra_auth_token(mock_credential, token_endpoint)
# Assert
assert result is None
mock_credential.get_token.assert_called_once_with(token_endpoint)
async def test_get_entra_auth_token_async_none_token_response(mock_async_credential: MagicMock) -> None:
"""Test that None token response returns None in async function."""
mock_async_credential.get_token.return_value = None
token_endpoint = "https://test-endpoint.com/.default"
result = await get_entra_auth_token_async(mock_async_credential, token_endpoint)
# Assert
assert result is None
mock_async_credential.get_token.assert_called_once_with(token_endpoint)
def test_get_entra_auth_token_with_kwargs(mock_credential: MagicMock) -> None:
"""Test that kwargs are passed through to get_token."""
token_endpoint = "https://test-endpoint.com/.default"
extra_kwargs = {"scopes": ["read", "write"], "tenant_id": "test-tenant"}
result = get_entra_auth_token(mock_credential, token_endpoint, **extra_kwargs)
# Assert
assert result == "test-access-token-12345"
mock_credential.get_token.assert_called_once_with(token_endpoint, **extra_kwargs)
async def test_get_entra_auth_token_async_with_kwargs(mock_async_credential: MagicMock) -> None:
"""Test that kwargs are passed through to async get_token."""
token_endpoint = "https://test-endpoint.com/.default"
extra_kwargs = {"scopes": ["read", "write"], "tenant_id": "test-tenant"}
result = await get_entra_auth_token_async(mock_async_credential, token_endpoint, **extra_kwargs)
# Assert
assert result == "test-async-access-token-12345"
mock_async_credential.get_token.assert_called_once_with(token_endpoint, **extra_kwargs)
+71
View File
@@ -0,0 +1,71 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Generator
from typing import Any
from unittest.mock import patch
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from pytest import fixture
@fixture
def enable_otel(request: Any) -> bool:
"""Fixture that returns a boolean indicating if Otel is enabled."""
return request.param if hasattr(request, "param") else True
@fixture
def enable_sensitive_data(request: Any) -> bool:
"""Fixture that returns a boolean indicating if sensitive data is enabled."""
return request.param if hasattr(request, "param") else True
@fixture
def span_exporter(monkeypatch, enable_otel: bool, enable_sensitive_data: bool) -> Generator[SpanExporter]:
"""Fixture to remove environment variables for ObservabilitySettings."""
env_vars = [
"ENABLE_OTEL",
"ENABLE_SENSITIVE_DATA",
"OTLP_ENDPOINT",
"APPLICATIONINSIGHTS_CONNECTION_STRING",
]
for key in env_vars:
monkeypatch.delenv(key, raising=False) # type: ignore
monkeypatch.setenv("ENABLE_OTEL", str(enable_otel)) # type: ignore
if not enable_otel:
# we overwrite sensitive data for tests
enable_sensitive_data = False
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", str(enable_sensitive_data)) # type: ignore
import importlib
from opentelemetry import trace
import agent_framework.observability as observability
# Reload the module to ensure a clean state for tests, then create a
# fresh ObservabilitySettings instance and patch the module attribute.
importlib.reload(observability)
# recreate observability settings with values from above and no file.
observability_settings = observability.ObservabilitySettings(env_file_path="test.env")
observability_settings._configure() # pyright: ignore[reportPrivateUsage]
monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore
with (
patch("agent_framework.observability.OBSERVABILITY_SETTINGS", observability_settings),
patch("agent_framework.observability.setup_observability"),
):
exporter = InMemorySpanExporter()
if enable_otel or enable_sensitive_data:
tracer_provider = trace.get_tracer_provider()
if not hasattr(tracer_provider, "add_span_processor"):
raise RuntimeError("Tracer provider does not support adding span processors.")
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore
yield exporter
# Clean up
exporter.clear()
+262
View File
@@ -0,0 +1,262 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import sys
from collections.abc import AsyncIterable, MutableSequence
from typing import Any
from unittest.mock import patch
from uuid import uuid4
from pydantic import BaseModel, Field
from pytest import fixture
from agent_framework import (
AgentProtocol,
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Role,
TextContent,
ToolProtocol,
ai_function,
use_chat_middleware,
use_function_invocation,
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore
else:
from typing_extensions import override # type: ignore[import]
# region Chat History
logger = logging.getLogger(__name__)
@fixture(scope="function")
def chat_history() -> list[ChatMessage]:
return []
# region Tools
@fixture
def ai_tool() -> ToolProtocol:
"""Returns a generic ToolProtocol."""
class GenericTool(BaseModel):
name: str
description: str
additional_properties: dict[str, Any] | None = None
def parameters(self) -> dict[str, Any]:
"""Return the parameters of the tool as a JSON schema."""
return {
"name": {"type": "string"},
}
return GenericTool(name="generic_tool", description="A generic tool")
@fixture
def ai_function_tool() -> ToolProtocol:
"""Returns a executable ToolProtocol."""
@ai_function
def simple_function(x: int, y: int) -> int:
"""A simple function that adds two numbers."""
return x + y
return simple_function
# region Chat Clients
class MockChatClient:
"""Simple implementation of a chat client."""
def __init__(self) -> None:
self.additional_properties: dict[str, Any] = {}
self.call_count: int = 0
self.responses: list[ChatResponse] = []
self.streaming_responses: list[list[ChatResponseUpdate]] = []
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
**kwargs: Any,
) -> ChatResponse:
logger.debug(f"Running custom chat client, with: {messages=}, {kwargs=}")
self.call_count += 1
if self.responses:
return self.responses.pop(0)
return ChatResponse(messages=ChatMessage(role="assistant", text="test response"))
async def get_streaming_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
logger.debug(f"Running custom chat client stream, with: {messages=}, {kwargs=}")
self.call_count += 1
if self.streaming_responses:
for update in self.streaming_responses.pop(0):
yield update
else:
yield ChatResponseUpdate(text=TextContent(text="test streaming response "), role="assistant")
yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant")
@use_chat_middleware
class MockBaseChatClient(BaseChatClient):
"""Mock implementation of the BaseChatClient."""
run_responses: list[ChatResponse] = Field(default_factory=list)
streaming_responses: list[list[ChatResponseUpdate]] = Field(default_factory=list)
call_count: int = Field(default=0)
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
"""Send a chat request to the AI service.
Args:
messages: The chat messages to send.
chat_options: The options for the request.
kwargs: Any additional keyword arguments.
Returns:
The chat response contents representing the response(s).
"""
logger.debug(f"Running base chat client inner, with: {messages=}, {chat_options=}, {kwargs=}")
self.call_count += 1
if not self.run_responses:
return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[-1].text}"))
response = self.run_responses.pop(0)
if chat_options.tool_choice == "none":
return ChatResponse(
messages=ChatMessage(
role="assistant",
text="I broke out of the function invocation loop...",
),
conversation_id=response.conversation_id,
)
return response
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
logger.debug(f"Running base chat client inner stream, with: {messages=}, {chat_options=}, {kwargs=}")
if not self.streaming_responses:
yield ChatResponseUpdate(text=f"update - {messages[0].text}", role="assistant")
return
if chat_options.tool_choice == "none":
yield ChatResponseUpdate(text="I broke out of the function invocation loop...", role="assistant")
return
response = self.streaming_responses.pop(0)
for update in response:
yield update
await asyncio.sleep(0)
@fixture
def enable_function_calling(request: Any) -> bool:
return request.param if hasattr(request, "param") else True
@fixture
def max_iterations(request: Any) -> int:
return request.param if hasattr(request, "param") else 2
@fixture
def chat_client(enable_function_calling: bool, max_iterations: int) -> MockChatClient:
if enable_function_calling:
with patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", max_iterations):
return use_function_invocation(MockChatClient)()
return MockChatClient()
@fixture
def chat_client_base(enable_function_calling: bool, max_iterations: int) -> MockBaseChatClient:
if enable_function_calling:
with patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", max_iterations):
return use_function_invocation(MockBaseChatClient)()
return MockBaseChatClient()
# region Agents
class MockAgentThread(AgentThread):
pass
# Mock Agent implementation for testing
class MockAgent(AgentProtocol):
@property
def id(self) -> str:
return str(uuid4())
@property
def name(self) -> str | None:
"""Returns the name of the agent."""
return "Name"
@property
def display_name(self) -> str:
"""Returns the name of the agent."""
return "Display Name"
@property
def description(self) -> str | None:
return "Description"
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentRunResponse:
logger.debug(f"Running mock agent, with: {messages=}, {thread=}, {kwargs=}")
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("Response")])])
async def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
logger.debug(f"Running mock agent stream, with: {messages=}, {thread=}, {kwargs=}")
yield AgentRunResponseUpdate(contents=[TextContent("Response")])
def get_new_thread(self) -> AgentThread:
return MockAgentThread()
@fixture
def agent_thread() -> AgentThread:
return MockAgentThread()
@fixture
def agent() -> AgentProtocol:
return MockAgent()
@@ -0,0 +1,508 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, MutableSequence, Sequence
from typing import Any
from uuid import uuid4
from pytest import raises
from agent_framework import (
AgentProtocol,
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
AggregateContextProvider,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatMessageStore,
ChatResponse,
Context,
ContextProvider,
HostedCodeInterpreterTool,
Role,
TextContent,
)
from agent_framework.exceptions import AgentExecutionException
def test_agent_thread_type(agent_thread: AgentThread) -> None:
assert isinstance(agent_thread, AgentThread)
def test_agent_type(agent: AgentProtocol) -> None:
assert isinstance(agent, AgentProtocol)
async def test_agent_run(agent: AgentProtocol) -> None:
response = await agent.run("test")
assert response.messages[0].role == Role.ASSISTANT
assert response.messages[0].text == "Response"
async def test_agent_run_streaming(agent: AgentProtocol) -> None:
async def collect_updates(updates: AsyncIterable[AgentRunResponseUpdate]) -> list[AgentRunResponseUpdate]:
return [u async for u in updates]
updates = await collect_updates(agent.run_stream(messages="test"))
assert len(updates) == 1
assert updates[0].text == "Response"
def test_chat_client_agent_type(chat_client: ChatClientProtocol) -> None:
chat_client_agent = ChatAgent(chat_client=chat_client)
assert isinstance(chat_client_agent, AgentProtocol)
async def test_chat_client_agent_init(chat_client: ChatClientProtocol) -> None:
agent_id = str(uuid4())
agent = ChatAgent(chat_client=chat_client, id=agent_id, description="Test")
assert agent.id == agent_id
assert agent.name is None
assert agent.description == "Test"
assert agent.display_name == agent_id # Display name defaults to id if name is None
async def test_chat_client_agent_init_with_name(chat_client: ChatClientProtocol) -> None:
agent_id = str(uuid4())
agent = ChatAgent(chat_client=chat_client, id=agent_id, name="Test Agent", description="Test")
assert agent.id == agent_id
assert agent.name == "Test Agent"
assert agent.description == "Test"
assert agent.display_name == "Test Agent" # Display name is the name if present
async def test_chat_client_agent_run(chat_client: ChatClientProtocol) -> None:
agent = ChatAgent(chat_client=chat_client)
result = await agent.run("Hello")
assert result.text == "test response"
async def test_chat_client_agent_run_streaming(chat_client: ChatClientProtocol) -> None:
agent = ChatAgent(chat_client=chat_client)
result = await AgentRunResponse.from_agent_response_generator(agent.run_stream("Hello"))
assert result.text == "test streaming response another update"
async def test_chat_client_agent_get_new_thread(chat_client: ChatClientProtocol) -> None:
agent = ChatAgent(chat_client=chat_client)
thread = agent.get_new_thread()
assert isinstance(thread, AgentThread)
async def test_chat_client_agent_prepare_thread_and_messages(chat_client: ChatClientProtocol) -> None:
agent = ChatAgent(chat_client=chat_client)
message = ChatMessage(role=Role.USER, text="Hello")
thread = AgentThread(message_store=ChatMessageStore(messages=[message]))
_, _, result_messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=thread,
input_messages=[ChatMessage(role=Role.USER, text="Test")],
)
assert len(result_messages) == 2
assert result_messages[0] == message
assert result_messages[1].text == "Test"
async def test_chat_client_agent_update_thread_id(chat_client_base: ChatClientProtocol) -> None:
mock_response = ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
conversation_id="123",
)
chat_client_base.run_responses = [mock_response]
agent = ChatAgent(
chat_client=chat_client_base,
tools=HostedCodeInterpreterTool(),
)
thread = agent.get_new_thread()
result = await agent.run("Hello", thread=thread)
assert result.text == "test response"
assert thread.service_thread_id == "123"
async def test_chat_client_agent_update_thread_messages(chat_client: ChatClientProtocol) -> None:
agent = ChatAgent(chat_client=chat_client)
thread = agent.get_new_thread()
result = await agent.run("Hello", thread=thread)
assert result.text == "test response"
assert thread.service_thread_id is None
assert thread.message_store is not None
chat_messages: list[ChatMessage] = await thread.message_store.list_messages()
assert chat_messages is not None
assert len(chat_messages) == 2
assert chat_messages[0].text == "Hello"
assert chat_messages[1].text == "test response"
async def test_chat_client_agent_update_thread_conversation_id_missing(chat_client: ChatClientProtocol) -> None:
agent = ChatAgent(chat_client=chat_client)
thread = AgentThread(service_thread_id="123")
with raises(AgentExecutionException, match="Service did not return a valid conversation id"):
await agent._update_thread_with_type_and_conversation_id(thread, None) # type: ignore[reportPrivateUsage]
async def test_chat_client_agent_default_author_name(chat_client: ChatClientProtocol) -> None:
# Name is not specified here, so default name should be used
agent = ChatAgent(chat_client=chat_client)
result = await agent.run("Hello")
assert result.text == "test response"
assert result.messages[0].author_name == "UnnamedAgent"
async def test_chat_client_agent_author_name_as_agent_name(chat_client: ChatClientProtocol) -> None:
# Name is specified here, so it should be used as author name
agent = ChatAgent(chat_client=chat_client, name="TestAgent")
result = await agent.run("Hello")
assert result.text == "test response"
assert result.messages[0].author_name == "TestAgent"
async def test_chat_client_agent_author_name_is_used_from_response(chat_client_base: ChatClientProtocol) -> None:
chat_client_base.run_responses = [
ChatResponse(
messages=[
ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")], author_name="TestAuthor")
]
)
]
agent = ChatAgent(chat_client=chat_client_base, tools=HostedCodeInterpreterTool())
result = await agent.run("Hello")
assert result.text == "test response"
assert result.messages[0].author_name == "TestAuthor"
# Mock context provider for testing
class MockContextProvider(ContextProvider):
def __init__(self, messages: list[ChatMessage] | None = None) -> None:
self.context_messages = messages
self.thread_created_called = False
self.invoked_called = False
self.invoking_called = False
self.thread_created_thread_id = None
self.invoked_thread_id = None
self.new_messages: list[ChatMessage] = []
async def thread_created(self, thread_id: str | None) -> None:
self.thread_created_called = True
self.thread_created_thread_id = thread_id
async def invoked(
self,
request_messages: ChatMessage | Sequence[ChatMessage],
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
invoke_exception: Any = None,
**kwargs: Any,
) -> None:
self.invoked_called = True
if isinstance(request_messages, ChatMessage):
self.new_messages.append(request_messages)
else:
self.new_messages.extend(request_messages)
if isinstance(response_messages, ChatMessage):
self.new_messages.append(response_messages)
else:
self.new_messages.extend(response_messages)
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
self.invoking_called = True
return Context(messages=self.context_messages)
async def test_chat_agent_context_providers_model_invoking(chat_client: ChatClientProtocol) -> None:
"""Test that context providers' invoking is called during agent run."""
mock_provider = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="Test context instructions")])
agent = ChatAgent(chat_client=chat_client, context_providers=mock_provider)
await agent.run("Hello")
assert mock_provider.invoking_called
async def test_chat_agent_context_providers_thread_created(chat_client_base: ChatClientProtocol) -> None:
"""Test that context providers' thread_created is called during agent run."""
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
conversation_id="test-thread-id",
)
]
agent = ChatAgent(chat_client=chat_client_base, context_providers=mock_provider)
await agent.run("Hello")
assert mock_provider.thread_created_called
assert mock_provider.thread_created_thread_id == "test-thread-id"
async def test_chat_agent_context_providers_messages_adding(chat_client: ChatClientProtocol) -> None:
"""Test that context providers' invoked is called during agent run."""
mock_provider = MockContextProvider()
agent = ChatAgent(chat_client=chat_client, context_providers=mock_provider)
await agent.run("Hello")
assert mock_provider.invoked_called
# Should be called with both input and response messages
assert len(mock_provider.new_messages) >= 2
async def test_chat_agent_context_instructions_in_messages(chat_client: ChatClientProtocol) -> None:
"""Test that AI context instructions are included in messages."""
mock_provider = MockContextProvider(messages=[ChatMessage(role="system", text="Context-specific instructions")])
agent = ChatAgent(chat_client=chat_client, instructions="Agent instructions", context_providers=mock_provider)
# We need to test the _prepare_thread_and_messages method directly
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[ChatMessage(role=Role.USER, text="Hello")]
)
# Should have context instructions, and user message
assert len(messages) == 2
assert messages[0].role == Role.SYSTEM
assert messages[0].text == "Context-specific instructions"
assert messages[1].role == Role.USER
assert messages[1].text == "Hello"
# instructions system message is added by a chat_client
async def test_chat_agent_no_context_instructions(chat_client: ChatClientProtocol) -> None:
"""Test behavior when AI context has no instructions."""
mock_provider = MockContextProvider()
agent = ChatAgent(chat_client=chat_client, instructions="Agent instructions", context_providers=mock_provider)
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[ChatMessage(role=Role.USER, text="Hello")]
)
# Should have agent instructions and user message only
assert len(messages) == 1
assert messages[0].role == Role.USER
assert messages[0].text == "Hello"
async def test_chat_agent_run_stream_context_providers(chat_client: ChatClientProtocol) -> None:
"""Test that context providers work with run_stream method."""
mock_provider = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="Stream context instructions")])
agent = ChatAgent(chat_client=chat_client, context_providers=mock_provider)
# Collect all stream updates
updates: list[AgentRunResponseUpdate] = []
async for update in agent.run_stream("Hello"):
updates.append(update)
# Verify context provider was called
assert mock_provider.invoking_called
# no conversation id is created, so no need to thread_create to be called.
assert not mock_provider.thread_created_called
assert mock_provider.invoked_called
async def test_chat_agent_multiple_context_providers(chat_client: ChatClientProtocol) -> None:
"""Test that multiple context providers work together."""
provider1 = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="First provider instructions")])
provider2 = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="Second provider instructions")])
agent = ChatAgent(chat_client=chat_client, context_providers=[provider1, provider2])
await agent.run("Hello")
# Both providers should be called
assert provider1.invoking_called
assert not provider1.thread_created_called
assert provider1.invoked_called
assert provider2.invoking_called
assert not provider2.thread_created_called
assert provider2.invoked_called
async def test_chat_agent_aggregate_context_provider_combines_instructions() -> None:
"""Test that AggregateContextProvider combines instructions from multiple providers."""
provider1 = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="First instruction")])
provider2 = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="Second instruction")])
aggregate = AggregateContextProvider()
aggregate.providers.append(provider1)
aggregate.providers.append(provider2)
# Test invoking combines instructions
result = await aggregate.invoking([ChatMessage(role=Role.USER, text="Test")])
assert result.messages
assert isinstance(result.messages[0], ChatMessage)
assert isinstance(result.messages[1], ChatMessage)
assert result.messages[0].text == "First instruction"
assert result.messages[1].text == "Second instruction"
async def test_chat_agent_context_providers_with_thread_service_id(chat_client_base: ChatClientProtocol) -> None:
"""Test context providers with service-managed thread."""
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
conversation_id="service-thread-123",
)
]
agent = ChatAgent(chat_client=chat_client_base, context_providers=mock_provider)
# Use existing service-managed thread
thread = agent.get_new_thread(service_thread_id="existing-thread-id")
await agent.run("Hello", thread=thread)
# invoked should be called with the service thread ID from response
assert mock_provider.invoked_called
# Tests for as_tool method
async def test_chat_agent_as_tool_basic(chat_client: ChatClientProtocol) -> None:
"""Test basic as_tool functionality."""
agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent for as_tool")
tool = agent.as_tool()
assert tool.name == "TestAgent"
assert tool.description == "Test agent for as_tool"
assert hasattr(tool, "func")
assert hasattr(tool, "input_model")
async def test_chat_agent_as_tool_custom_parameters(chat_client: ChatClientProtocol) -> None:
"""Test as_tool with custom parameters."""
agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Original description")
tool = agent.as_tool(
name="CustomTool",
description="Custom description",
arg_name="query",
arg_description="Custom input description",
)
assert tool.name == "CustomTool"
assert tool.description == "Custom description"
# Check that the input model has the custom field name
schema = tool.input_model.model_json_schema()
assert "query" in schema["properties"]
assert schema["properties"]["query"]["description"] == "Custom input description"
async def test_chat_agent_as_tool_defaults(chat_client: ChatClientProtocol) -> None:
"""Test as_tool with default parameters."""
agent = ChatAgent(
chat_client=chat_client,
name="TestAgent",
# No description provided
)
tool = agent.as_tool()
assert tool.name == "TestAgent"
assert tool.description == "" # Should default to empty string
# Check default input field
schema = tool.input_model.model_json_schema()
assert "task" in schema["properties"]
assert "Task for TestAgent" in schema["properties"]["task"]["description"]
async def test_chat_agent_as_tool_no_name(chat_client: ChatClientProtocol) -> None:
"""Test as_tool when agent has no name (should raise ValueError)."""
agent = ChatAgent(chat_client=chat_client) # No name provided
# Should raise ValueError since agent has no name
with raises(ValueError, match="Agent tool name cannot be None"):
agent.as_tool()
async def test_chat_agent_as_tool_function_execution(chat_client: ChatClientProtocol) -> None:
"""Test that the generated AIFunction can be executed."""
agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent")
tool = agent.as_tool()
# Test function execution
result = await tool.invoke(arguments=tool.input_model(task="Hello"))
# Should return the agent's response text
assert isinstance(result, str)
assert result == "test response" # From mock chat client
async def test_chat_agent_as_tool_with_stream_callback(chat_client: ChatClientProtocol) -> None:
"""Test as_tool with stream callback functionality."""
agent = ChatAgent(chat_client=chat_client, name="StreamingAgent")
# Collect streaming updates
collected_updates: list[AgentRunResponseUpdate] = []
def stream_callback(update: AgentRunResponseUpdate) -> None:
collected_updates.append(update)
tool = agent.as_tool(stream_callback=stream_callback)
# Execute the tool
result = await tool.invoke(arguments=tool.input_model(task="Hello"))
# Should have collected streaming updates
assert len(collected_updates) > 0
assert isinstance(result, str)
# Result should be concatenation of all streaming updates
expected_text = "".join(update.text for update in collected_updates)
assert result == expected_text
async def test_chat_agent_as_tool_with_custom_arg_name(chat_client: ChatClientProtocol) -> None:
"""Test as_tool with custom argument name."""
agent = ChatAgent(chat_client=chat_client, name="CustomArgAgent")
tool = agent.as_tool(arg_name="prompt", arg_description="Custom prompt input")
# Test that the custom argument name works
result = await tool.invoke(arguments=tool.input_model(prompt="Test prompt"))
assert result == "test response"
async def test_chat_agent_as_tool_with_async_stream_callback(chat_client: ChatClientProtocol) -> None:
"""Test as_tool with async stream callback functionality."""
agent = ChatAgent(chat_client=chat_client, name="AsyncStreamingAgent")
# Collect streaming updates using an async callback
collected_updates: list[AgentRunResponseUpdate] = []
async def async_stream_callback(update: AgentRunResponseUpdate) -> None:
collected_updates.append(update)
tool = agent.as_tool(stream_callback=async_stream_callback)
# Execute the tool
result = await tool.invoke(arguments=tool.input_model(task="Hello"))
# Should have collected streaming updates
assert len(collected_updates) > 0
assert isinstance(result, str)
# Result should be concatenation of all streaming updates
expected_text = "".join(update.text for update in collected_updates)
assert result == expected_text
@@ -0,0 +1,163 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from agent_framework import (
BaseChatClient,
ChatClientProtocol,
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
ai_function,
)
if sys.version_info >= (3, 12):
pass # type: ignore
else:
pass # type: ignore[import]
def test_chat_client_type(chat_client: ChatClientProtocol):
assert isinstance(chat_client, ChatClientProtocol)
async def test_chat_client_get_response(chat_client: ChatClientProtocol):
response = await chat_client.get_response(ChatMessage(role="user", text="Hello"))
assert response.text == "test response"
assert response.messages[0].role == Role.ASSISTANT
async def test_chat_client_get_streaming_response(chat_client: ChatClientProtocol):
async for update in chat_client.get_streaming_response(ChatMessage(role="user", text="Hello")):
assert update.text == "test streaming response " or update.text == "another update"
assert update.role == Role.ASSISTANT
def test_base_client(chat_client_base: ChatClientProtocol):
assert isinstance(chat_client_base, BaseChatClient)
assert isinstance(chat_client_base, ChatClientProtocol)
async def test_base_client_get_response(chat_client_base: ChatClientProtocol):
response = await chat_client_base.get_response(ChatMessage(role="user", text="Hello"))
assert response.messages[0].role == Role.ASSISTANT
assert response.messages[0].text == "test response - Hello"
async def test_base_client_get_streaming_response(chat_client_base: ChatClientProtocol):
async for update in chat_client_base.get_streaming_response(ChatMessage(role="user", text="Hello")):
assert update.text == "update - Hello" or update.text == "another update"
async def test_base_client_with_function_calling(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
assert exec_counter == 1
assert len(response.messages) == 3
assert response.messages[0].role == Role.ASSISTANT
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].name == "test_function"
assert response.messages[0].contents[0].arguments == '{"arg1": "value1"}'
assert response.messages[0].contents[0].call_id == "1"
assert response.messages[1].role == Role.TOOL
assert isinstance(response.messages[1].contents[0], FunctionResultContent)
assert response.messages[1].contents[0].call_id == "1"
assert response.messages[1].contents[0].result == "Processed value1"
assert response.messages[2].role == Role.ASSISTANT
assert response.messages[2].text == "done"
async def test_base_client_with_function_calling_resets(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.run_responses = [
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="2", name="test_function", arguments='{"arg1": "value1"}')],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
assert exec_counter == 2
assert len(response.messages) == 5
assert response.messages[0].role == Role.ASSISTANT
assert response.messages[1].role == Role.TOOL
assert response.messages[2].role == Role.ASSISTANT
assert response.messages[3].role == Role.TOOL
assert response.messages[4].role == Role.ASSISTANT
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert isinstance(response.messages[1].contents[0], FunctionResultContent)
assert isinstance(response.messages[2].contents[0], FunctionCallContent)
assert isinstance(response.messages[3].contents[0], FunctionResultContent)
async def test_base_client_with_streaming_function_calling(chat_client_base: ChatClientProtocol):
exec_counter = 0
@ai_function(name="test_function")
def ai_func(arg1: str) -> str:
nonlocal exec_counter
exec_counter += 1
return f"Processed {arg1}"
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='{"arg1":')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="1", name="test_function", arguments='"value1"}')],
role="assistant",
),
],
[
ChatResponseUpdate(
contents=[TextContent(text="Processed value1")],
role="assistant",
)
],
]
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]):
updates.append(update)
assert len(updates) == 4 # two updates with the function call, the function result and the final text
assert updates[0].contents[0].call_id == "1"
assert updates[1].contents[0].call_id == "1"
assert updates[2].contents[0].call_id == "1"
assert updates[3].text == "Processed value1"
assert exec_counter == 1
@@ -0,0 +1,39 @@
# Copyright (c) Microsoft. All rights reserved.
import pytest
from agent_framework import get_logger
from agent_framework.exceptions import AgentFrameworkException
def test_get_logger():
"""Test that the logger is created with the correct name."""
logger = get_logger()
assert logger.name == "agent_framework"
def test_get_logger_custom_name():
"""Test that the logger can be created with a custom name."""
custom_name = "agent_framework.custom"
logger = get_logger(custom_name)
assert logger.name == custom_name
def test_get_logger_invalid_name():
"""Test that an exception is raised for an invalid logger name."""
with pytest.raises(AgentFrameworkException):
get_logger("invalid_name")
def test_log(caplog):
"""Test that the logger can log messages and adheres to the expected format."""
logger = get_logger()
with caplog.at_level("DEBUG"):
logger.debug("This is a debug message")
assert len(caplog.records) == 1
record = caplog.records[0]
assert record.levelname == "DEBUG"
assert record.message == "This is a debug message"
assert record.name == "agent_framework"
assert record.pathname.endswith("test_logging.py")
+536
View File
@@ -0,0 +1,536 @@
# Copyright (c) Microsoft. All rights reserved.
# type: ignore[reportPrivateUsage]
import os
from contextlib import _AsyncGeneratorContextManager # type: ignore
from typing import Any
from unittest.mock import AsyncMock, Mock
import pytest
from mcp import types
from mcp.client.session import ClientSession
from mcp.shared.exceptions import McpError
from pydantic import AnyUrl, ValidationError
from agent_framework import (
ChatMessage,
DataContent,
MCPStdioTool,
MCPStreamableHTTPTool,
MCPWebsocketTool,
Role,
TextContent,
ToolProtocol,
UriContent,
)
from agent_framework._mcp import (
MCPTool,
_ai_content_to_mcp_types,
_chat_message_to_mcp_types,
_get_input_model_from_mcp_prompt,
_get_input_model_from_mcp_tool,
_mcp_call_tool_result_to_ai_contents,
_mcp_prompt_message_to_chat_message,
_mcp_type_to_ai_content,
_normalize_mcp_name,
)
from agent_framework.exceptions import ToolExecutionException
# Integration test skip condition
skip_if_mcp_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" or os.getenv("LOCAL_MCP_URL", "") == "",
reason="No LOCAL_MCP_URL provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
# Helper function tests
def test_normalize_mcp_name():
"""Test MCP name normalization."""
assert _normalize_mcp_name("valid_name") == "valid_name"
assert _normalize_mcp_name("name-with-dashes") == "name-with-dashes"
assert _normalize_mcp_name("name.with.dots") == "name.with.dots"
assert _normalize_mcp_name("name with spaces") == "name-with-spaces"
assert _normalize_mcp_name("name@with#special$chars") == "name-with-special-chars"
assert _normalize_mcp_name("name/with\\slashes") == "name-with-slashes"
def test_mcp_prompt_message_to_ai_content():
"""Test conversion from MCP prompt message to AI content."""
mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!"))
ai_content = _mcp_prompt_message_to_chat_message(mcp_message)
assert isinstance(ai_content, ChatMessage)
assert ai_content.role.value == "user"
assert len(ai_content.contents) == 1
assert isinstance(ai_content.contents[0], TextContent)
assert ai_content.contents[0].text == "Hello, world!"
assert ai_content.raw_representation == mcp_message
def test_mcp_call_tool_result_to_ai_contents():
"""Test conversion from MCP tool result to AI contents."""
mcp_result = types.CallToolResult(
content=[
types.TextContent(type="text", text="Result text"),
types.ImageContent(type="image", data="data:image/png;base64,xyz", mimeType="image/png"),
]
)
ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)
assert len(ai_contents) == 2
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].text == "Result text"
assert isinstance(ai_contents[1], DataContent)
assert ai_contents[1].uri == "data:image/png;base64,xyz"
assert ai_contents[1].media_type == "image/png"
def test_mcp_content_types_to_ai_content_text():
"""Test conversion of MCP text content to AI content."""
mcp_content = types.TextContent(type="text", text="Sample text")
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, TextContent)
assert ai_content.text == "Sample text"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_image():
"""Test conversion of MCP image content to AI content."""
mcp_content = types.ImageContent(type="image", data="data:image/jpeg;base64,abc", mimeType="image/jpeg")
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:image/jpeg;base64,abc"
assert ai_content.media_type == "image/jpeg"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_audio():
"""Test conversion of MCP audio content to AI content."""
mcp_content = types.AudioContent(type="audio", data="data:audio/wav;base64,def", mimeType="audio/wav")
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:audio/wav;base64,def"
assert ai_content.media_type == "audio/wav"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_resource_link():
"""Test conversion of MCP resource link to AI content."""
mcp_content = types.ResourceLink(
type="resource_link",
uri=AnyUrl("https://example.com/resource"),
name="test_resource",
mimeType="application/json",
)
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, UriContent)
assert ai_content.uri == "https://example.com/resource"
assert ai_content.media_type == "application/json"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_embedded_resource_text():
"""Test conversion of MCP embedded text resource to AI content."""
text_resource = types.TextResourceContents(
uri=AnyUrl("file://test.txt"), mimeType="text/plain", text="Embedded text content"
)
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, TextContent)
assert ai_content.text == "Embedded text content"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_embedded_resource_blob():
"""Test conversion of MCP embedded blob resource to AI content."""
# Use a proper data URI in the blob field since that's what the MCP implementation expects
blob_resource = types.BlobResourceContents(
uri=AnyUrl("file://test.bin"),
mimeType="application/octet-stream",
blob="data:application/octet-stream;base64,dGVzdCBkYXRh",
)
mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource)
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
assert ai_content.media_type == "application/octet-stream"
assert ai_content.raw_representation == mcp_content
def test_ai_content_to_mcp_content_types_text():
"""Test conversion of AI text content to MCP content."""
ai_content = TextContent(text="Sample text")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.TextContent)
assert mcp_content.type == "text"
assert mcp_content.text == "Sample text"
def test_ai_content_to_mcp_content_types_data_image():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:image/png;base64,xyz", media_type="image/png")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.ImageContent)
assert mcp_content.type == "image"
assert mcp_content.data == "data:image/png;base64,xyz"
assert mcp_content.mimeType == "image/png"
def test_ai_content_to_mcp_content_types_data_audio():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.AudioContent)
assert mcp_content.type == "audio"
assert mcp_content.data == "data:audio/mpeg;base64,xyz"
assert mcp_content.mimeType == "audio/mpeg"
def test_ai_content_to_mcp_content_types_data_binary():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:application/octet-stream;base64,xyz", media_type="application/octet-stream")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.EmbeddedResource)
assert mcp_content.type == "resource"
assert mcp_content.resource.blob == "data:application/octet-stream;base64,xyz"
assert mcp_content.resource.mimeType == "application/octet-stream"
def test_ai_content_to_mcp_content_types_uri():
"""Test conversion of AI URI content to MCP content."""
ai_content = UriContent(uri="https://example.com/resource", media_type="application/json")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.ResourceLink)
assert mcp_content.type == "resource_link"
assert str(mcp_content.uri) == "https://example.com/resource"
assert mcp_content.mimeType == "application/json"
def test_chat_message_to_mcp_types():
message = ChatMessage(
role="user",
contents=[TextContent(text="test"), DataContent(uri="data:image/png;base64,xyz", media_type="image/png")],
)
mcp_contents = _chat_message_to_mcp_types(message)
assert len(mcp_contents) == 2
assert isinstance(mcp_contents[0], types.TextContent)
assert isinstance(mcp_contents[1], types.ImageContent)
def test_get_input_model_from_mcp_tool():
"""Test creation of input model from MCP tool."""
tool = types.Tool(
name="test_tool",
description="A test tool",
inputSchema={
"type": "object",
"properties": {"param1": {"type": "string"}, "param2": {"type": "number"}},
"required": ["param1"],
},
)
model = _get_input_model_from_mcp_tool(tool)
# Create an instance to verify the model works
instance = model(param1="test", param2=42)
assert instance.param1 == "test"
assert instance.param2 == 42
# Test validation
with pytest.raises(ValidationError): # Missing required param1
model(param2=42)
def test_get_input_model_from_mcp_prompt():
"""Test creation of input model from MCP prompt."""
prompt = types.Prompt(
name="test_prompt",
description="A test prompt",
arguments=[
types.PromptArgument(name="arg1", description="First argument", required=True),
types.PromptArgument(name="arg2", description="Second argument", required=False),
],
)
model = _get_input_model_from_mcp_prompt(prompt)
# Create an instance to verify the model works
instance = model(arg1="test", arg2="optional")
assert instance.arg1 == "test"
assert instance.arg2 == "optional"
# Test validation
with pytest.raises(ValidationError): # Missing required arg1
model(arg2="optional")
# MCPTool tests
async def test_local_mcp_server_initialization():
"""Test MCPTool initialization."""
server = MCPTool(name="test_server")
assert isinstance(server, ToolProtocol)
assert server.name == "test_server"
assert server.session is None
assert server.functions == []
async def test_local_mcp_server_context_manager():
"""Test MCPTool as context manager."""
class TestServer(MCPTool):
async def connect(self):
# Mock connection
self.session = Mock(spec=ClientSession)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
assert server.session is not None
assert server.session is None
async def test_local_mcp_server_load_functions():
"""Test loading functions from MCP server."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
# Mock tools list response
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
assert isinstance(server, ToolProtocol)
async with server:
await server.load_tools()
assert len(server.functions) == 1
assert server.functions[0].name == "test_tool"
async def test_local_mcp_server_load_prompts():
"""Test loading prompts from MCP server."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
# Mock prompts list response
self.session.list_prompts = AsyncMock(
return_value=types.ListPromptsResult(
prompts=[
types.Prompt(
name="test_prompt",
description="Test prompt",
arguments=[types.PromptArgument(name="arg", description="Test arg", required=True)],
)
]
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_prompts()
assert len(server.functions) == 1
assert server.functions[0].name == "test_prompt"
async def test_local_mcp_server_function_execution():
"""Test function execution through MCP server."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="Tool executed successfully")]
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
func = server.functions[0]
result = await func.invoke(param="test_value")
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "Tool executed successfully"
async def test_local_mcp_server_function_execution_error():
"""Test function execution error handling."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
# Mock a tool call that raises an MCP error
self.session.call_tool = AsyncMock(
side_effect=McpError(types.ErrorData(code=-1, message="Tool execution failed"))
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
func = server.functions[0]
with pytest.raises(ToolExecutionException):
await func.invoke(param="test_value")
async def test_local_mcp_server_prompt_execution():
"""Test prompt execution through MCP server."""
class TestMCPTool(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_prompts = AsyncMock(
return_value=types.ListPromptsResult(
prompts=[
types.Prompt(
name="test_prompt",
description="Test prompt",
arguments=[types.PromptArgument(name="arg", description="Test arg", required=True)],
)
]
)
)
self.session.get_prompt = AsyncMock(
return_value=types.GetPromptResult(
description="Generated prompt",
messages=[
types.PromptMessage(role="user", content=types.TextContent(type="text", text="Test message"))
],
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestMCPTool(name="test_server")
async with server:
await server.load_prompts()
prompt = server.functions[0]
result = await prompt.invoke(arg="test_value")
assert len(result) == 1
assert isinstance(result[0], ChatMessage)
assert result[0].role == Role.USER
assert len(result[0].contents) == 1
assert result[0].contents[0].text == "Test message"
# Server implementation tests
def test_local_mcp_stdio_tool_init():
"""Test MCPStdioTool initialization."""
tool = MCPStdioTool(name="test", command="echo", args=["hello"])
assert tool.name == "test"
assert tool.command == "echo"
assert tool.args == ["hello"]
def test_local_mcp_websocket_tool_init():
"""Test MCPWebsocketTool initialization."""
tool = MCPWebsocketTool(name="test", url="ws://localhost:8080")
assert tool.name == "test"
assert tool.url == "ws://localhost:8080"
def test_local_mcp_streamable_http_tool_init():
"""Test MCPStreamableHTTPTool initialization."""
tool = MCPStreamableHTTPTool(name="test", url="http://localhost:8080")
assert tool.name == "test"
assert tool.url == "http://localhost:8080"
# Integration test
@skip_if_mcp_integration_tests_disabled
async def test_streamable_http_integration():
"""Test MCP StreamableHTTP integration."""
url = os.environ.get("LOCAL_MCP_URL", "")
if not url.startswith("http"):
pytest.skip("LOCAL_MCP_URL is not an HTTP URL")
tool = MCPStreamableHTTPTool(name="integration_test", url=url)
async with tool:
# Test that we can connect and load tools
assert tool.session is not None
assert isinstance(tool.functions, list)
# If there are functions available, try to get information about one
assert tool.functions, "The MCP server should have at least one function."
func = tool.functions[0]
assert hasattr(func, "name")
assert hasattr(func, "description")
result = await func.invoke(query="What is Agent Framework?")
assert result[0].text is not None
@@ -0,0 +1,296 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import MutableSequence
from typing import Any
from unittest.mock import AsyncMock, Mock
from agent_framework import ChatMessage, Role, TextContent
from agent_framework._memory import AggregateContextProvider, Context, ContextProvider
class MockContextProvider(ContextProvider):
"""Mock ContextProvider for testing."""
def __init__(self, messages: list[ChatMessage] | None = None) -> None:
self.context_messages = messages
self.thread_created_called = False
self.invoked_called = False
self.invoking_called = False
self.thread_created_thread_id = None
self.new_messages = None
self.model_invoking_messages = None
async def thread_created(self, thread_id: str | None) -> None:
"""Track thread_created calls."""
self.thread_created_called = True
self.thread_created_thread_id = thread_id
async def invoked(
self,
request_messages: Any,
response_messages: Any | None = None,
invoke_exception: Exception | None = None,
**kwargs: Any,
) -> None:
"""Track invoked calls."""
self.invoked_called = True
self.new_messages = request_messages
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
"""Track invoking calls and return context."""
self.invoking_called = True
self.model_invoking_messages = messages
context = Context()
context.messages = self.context_messages
return context
class TestAggregateContextProvider:
"""Tests for AggregateContextProvider class."""
def test_init_with_no_providers(self) -> None:
"""Test initialization with no providers."""
aggregate = AggregateContextProvider()
assert aggregate.providers == []
def test_init_with_none_providers(self) -> None:
"""Test initialization with None providers."""
aggregate = AggregateContextProvider(None)
assert aggregate.providers == []
def test_init_with_providers(self) -> None:
"""Test initialization with providers."""
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
provider3 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 3")])
providers = [provider1, provider2, provider3]
aggregate = AggregateContextProvider(providers)
assert len(aggregate.providers) == 3
assert aggregate.providers[0] is provider1
assert aggregate.providers[1] is provider2
assert aggregate.providers[2] is provider3
def test_add_provider(self) -> None:
"""Test adding a provider."""
aggregate = AggregateContextProvider()
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions")])
aggregate.add(provider)
assert len(aggregate.providers) == 1
assert aggregate.providers[0] is provider
def test_add_multiple_providers(self) -> None:
"""Test adding multiple providers."""
aggregate = AggregateContextProvider()
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
aggregate.add(provider1)
aggregate.add(provider2)
assert len(aggregate.providers) == 2
assert aggregate.providers[0] is provider1
assert aggregate.providers[1] is provider2
async def test_thread_created_with_no_providers(self) -> None:
"""Test thread_created with no providers."""
aggregate = AggregateContextProvider()
# Should not raise an exception
await aggregate.thread_created("thread-123")
async def test_thread_created_with_providers(self) -> None:
"""Test thread_created calls all providers."""
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
aggregate = AggregateContextProvider([provider1, provider2])
thread_id = "thread-123"
await aggregate.thread_created(thread_id)
assert provider1.thread_created_called
assert provider1.thread_created_thread_id == thread_id
assert provider2.thread_created_called
assert provider2.thread_created_thread_id == thread_id
async def test_thread_created_with_none_thread_id(self) -> None:
"""Test thread_created with None thread_id."""
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions")])
aggregate = AggregateContextProvider([provider])
await aggregate.thread_created(None)
assert provider.thread_created_called
assert provider.thread_created_thread_id is None
async def test_messages_adding_with_no_providers(self) -> None:
"""Test invoked with no providers."""
aggregate = AggregateContextProvider()
message = ChatMessage(text="Hello", role=Role.USER)
# Should not raise an exception
await aggregate.invoked(message)
async def test_messages_adding_with_single_message(self) -> None:
"""Test invoked with a single message."""
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
aggregate = AggregateContextProvider([provider1, provider2])
message = ChatMessage(text="Hello", role=Role.USER)
await aggregate.invoked(message)
assert provider1.invoked_called
assert provider1.new_messages == message
assert provider2.invoked_called
assert provider2.new_messages == message
async def test_messages_adding_with_message_sequence(self) -> None:
"""Test invoked with a sequence of messages."""
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions")])
aggregate = AggregateContextProvider([provider])
messages = [
ChatMessage(text="Hello", role=Role.USER),
ChatMessage(text="Hi there", role=Role.ASSISTANT),
]
await aggregate.invoked(messages)
assert provider.invoked_called
assert provider.new_messages == messages
async def test_model_invoking_with_no_providers(self) -> None:
"""Test invoking with no providers."""
aggregate = AggregateContextProvider()
message = ChatMessage(text="Hello", role=Role.USER)
context = await aggregate.invoking(message)
assert isinstance(context, Context)
assert not context.messages
async def test_model_invoking_with_single_provider(self) -> None:
"""Test invoking with a single provider."""
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Test instructions")])
aggregate = AggregateContextProvider([provider])
message = [ChatMessage(text="Hello", role=Role.USER)]
context = await aggregate.invoking(message)
assert provider.invoking_called
assert provider.model_invoking_messages == message
assert isinstance(context, Context)
assert context.messages
assert isinstance(context.messages[0].contents[0], TextContent)
assert context.messages[0].text == "Test instructions"
async def test_model_invoking_with_multiple_providers(self) -> None:
"""Test invoking combines contexts from multiple providers."""
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
provider3 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 3")])
aggregate = AggregateContextProvider([provider1, provider2, provider3])
messages = [ChatMessage(text="Hello", role=Role.USER)]
context = await aggregate.invoking(messages)
assert provider1.invoking_called
assert provider1.model_invoking_messages == messages
assert provider2.invoking_called
assert provider2.model_invoking_messages == messages
assert provider3.invoking_called
assert provider3.model_invoking_messages == messages
assert isinstance(context, Context)
assert context.messages
assert isinstance(context.messages[0].contents[0], TextContent)
assert isinstance(context.messages[1].contents[0], TextContent)
assert isinstance(context.messages[2].contents[0], TextContent)
assert context.messages[0].text == "Instructions 1"
assert context.messages[1].text == "Instructions 2"
assert context.messages[2].text == "Instructions 3"
async def test_model_invoking_with_none_instructions(self) -> None:
"""Test invoking filters out None instructions."""
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
provider2 = MockContextProvider(messages=None) # None instructions
provider3 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 3")])
aggregate = AggregateContextProvider([provider1, provider2, provider3])
message = ChatMessage(text="Hello", role=Role.USER)
context = await aggregate.invoking(message)
assert isinstance(context, Context)
assert context.messages
assert isinstance(context.messages[0].contents[0], TextContent)
assert isinstance(context.messages[1].contents[0], TextContent)
assert context.messages[0].text == "Instructions 1"
assert context.messages[1].text == "Instructions 3"
async def test_model_invoking_with_all_none_instructions(self) -> None:
"""Test invoking when all providers return None instructions."""
provider1 = MockContextProvider(None)
provider2 = MockContextProvider(None)
aggregate = AggregateContextProvider([provider1, provider2])
message = ChatMessage(text="Hello", role=Role.USER)
context = await aggregate.invoking(message)
assert isinstance(context, Context)
assert not context.messages
async def test_model_invoking_with_mutable_sequence(self) -> None:
"""Test invoking with MutableSequence of messages."""
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Test instructions")])
aggregate = AggregateContextProvider([provider])
messages = [ChatMessage(text="Hello", role=Role.USER)]
context = await aggregate.invoking(messages)
assert provider.invoking_called
assert provider.model_invoking_messages == messages
assert isinstance(context, Context)
assert context.messages
assert isinstance(context.messages[0].contents[0], TextContent)
assert context.messages[0].text == "Test instructions"
async def test_async_methods_concurrent_execution(self) -> None:
"""Test that async methods execute providers concurrently."""
# Use AsyncMock to verify concurrent execution
provider1 = Mock(spec=ContextProvider)
provider1.thread_created = AsyncMock()
provider1.invoked = AsyncMock()
provider1.invoking = AsyncMock(return_value=Context(messages=[ChatMessage(role="user", text="Test 1")]))
provider2 = Mock(spec=ContextProvider)
provider2.thread_created = AsyncMock()
provider2.invoked = AsyncMock()
provider2.invoking = AsyncMock(return_value=Context(messages=[ChatMessage(role="user", text="Test 2")]))
aggregate = AggregateContextProvider([provider1, provider2])
# Test thread_created
await aggregate.thread_created("thread-123")
provider1.thread_created.assert_called_once_with("thread-123")
provider2.thread_created.assert_called_once_with("thread-123")
# Test invoked
message = ChatMessage(text="Hello", role=Role.USER)
await aggregate.invoked(message)
provider1.invoked.assert_called_once_with(
request_messages=message, response_messages=None, invoke_exception=None
)
provider2.invoked.assert_called_once_with(
request_messages=message, response_messages=None, invoke_exception=None
)
# Test invoking
context = await aggregate.invoking(message)
provider1.invoking.assert_called_once_with(message)
provider2.invoking.assert_called_once_with(message)
assert context.messages
assert context.messages[0].text == "Test 1"
assert context.messages[1].text == "Test 2"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,463 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock
import pytest
from pydantic import BaseModel, Field
from agent_framework import (
AgentProtocol,
AgentRunResponse,
AgentRunResponseUpdate,
ChatAgent,
ChatMessage,
Role,
TextContent,
)
from agent_framework._middleware import (
AgentMiddleware,
AgentMiddlewarePipeline,
AgentRunContext,
FunctionInvocationContext,
FunctionMiddleware,
FunctionMiddlewarePipeline,
)
from agent_framework._tools import AIFunction
from .conftest import MockChatClient
class FunctionTestArgs(BaseModel):
"""Test arguments for function middleware tests."""
name: str = Field(description="Test name parameter")
class TestResultOverrideMiddleware:
"""Test cases for middleware result override functionality."""
async def test_agent_middleware_response_override_non_streaming(self, mock_agent: AgentProtocol) -> None:
"""Test that agent middleware can override response for non-streaming execution."""
override_response = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="overridden response")])
class ResponseOverrideMiddleware(AgentMiddleware):
async def process(
self, context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
) -> None:
# Execute the pipeline first, then override the response
await next(context)
context.result = override_response
middleware = ResponseOverrideMiddleware()
pipeline = AgentMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
context = AgentRunContext(agent=mock_agent, messages=messages)
handler_called = False
async def final_handler(ctx: AgentRunContext) -> AgentRunResponse:
nonlocal handler_called
handler_called = True
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="original response")])
result = await pipeline.execute(mock_agent, messages, context, final_handler)
# Verify the overridden response is returned
assert result is not None
assert result == override_response
assert result.messages[0].text == "overridden response"
# Verify original handler was called since middleware called next()
assert handler_called
async def test_agent_middleware_response_override_streaming(self, mock_agent: AgentProtocol) -> None:
"""Test that agent middleware can override response for streaming execution."""
async def override_stream() -> AsyncIterable[AgentRunResponseUpdate]:
yield AgentRunResponseUpdate(contents=[TextContent(text="overridden")])
yield AgentRunResponseUpdate(contents=[TextContent(text=" stream")])
class StreamResponseOverrideMiddleware(AgentMiddleware):
async def process(
self, context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
) -> None:
# Execute the pipeline first, then override the response stream
await next(context)
context.result = override_stream()
middleware = StreamResponseOverrideMiddleware()
pipeline = AgentMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
context = AgentRunContext(agent=mock_agent, messages=messages)
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentRunResponseUpdate]:
yield AgentRunResponseUpdate(contents=[TextContent(text="original")])
updates: list[AgentRunResponseUpdate] = []
async for update in pipeline.execute_stream(mock_agent, messages, context, final_handler):
updates.append(update)
# Verify the overridden response stream is returned
assert len(updates) == 2
assert updates[0].text == "overridden"
assert updates[1].text == " stream"
async def test_function_middleware_result_override(self, mock_function: AIFunction[Any, Any]) -> None:
"""Test that function middleware can override result."""
override_result = "overridden function result"
class ResultOverrideMiddleware(FunctionMiddleware):
async def process(
self,
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
# Execute the pipeline first, then override the result
await next(context)
context.result = override_result
middleware = ResultOverrideMiddleware()
pipeline = FunctionMiddlewarePipeline([middleware])
arguments = FunctionTestArgs(name="test")
context = FunctionInvocationContext(function=mock_function, arguments=arguments)
handler_called = False
async def final_handler(ctx: FunctionInvocationContext) -> str:
nonlocal handler_called
handler_called = True
return "original function result"
result = await pipeline.execute(mock_function, arguments, context, final_handler)
# Verify the overridden result is returned
assert result == override_result
# Verify original handler was called since middleware called next()
assert handler_called
async def test_chat_agent_middleware_response_override(self) -> None:
"""Test result override functionality with ChatAgent integration."""
mock_chat_client = MockChatClient()
class ChatAgentResponseOverrideMiddleware(AgentMiddleware):
async def process(
self, context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
) -> None:
# Always call next() first to allow execution
await next(context)
# Then conditionally override based on content
if any("special" in msg.text for msg in context.messages if msg.text):
context.result = AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Special response from middleware!")]
)
# Create ChatAgent with override middleware
middleware = ChatAgentResponseOverrideMiddleware()
agent = ChatAgent(chat_client=mock_chat_client, middleware=[middleware])
# Test override case
override_messages = [ChatMessage(role=Role.USER, text="Give me a special response")]
override_response = await agent.run(override_messages)
assert override_response.messages[0].text == "Special response from middleware!"
# Verify chat client was called since middleware called next()
assert mock_chat_client.call_count == 1
# Test normal case
normal_messages = [ChatMessage(role=Role.USER, text="Normal request")]
normal_response = await agent.run(normal_messages)
assert normal_response.messages[0].text == "test response"
# Verify chat client was called for normal case
assert mock_chat_client.call_count == 2
async def test_chat_agent_middleware_streaming_override(self) -> None:
"""Test streaming result override functionality with ChatAgent integration."""
mock_chat_client = MockChatClient()
async def custom_stream() -> AsyncIterable[AgentRunResponseUpdate]:
yield AgentRunResponseUpdate(contents=[TextContent(text="Custom")])
yield AgentRunResponseUpdate(contents=[TextContent(text=" streaming")])
yield AgentRunResponseUpdate(contents=[TextContent(text=" response!")])
class ChatAgentStreamOverrideMiddleware(AgentMiddleware):
async def process(
self, context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
) -> None:
# Always call next() first to allow execution
await next(context)
# Then conditionally override based on content
if any("custom stream" in msg.text for msg in context.messages if msg.text):
context.result = custom_stream()
# Create ChatAgent with override middleware
middleware = ChatAgentStreamOverrideMiddleware()
agent = ChatAgent(chat_client=mock_chat_client, middleware=[middleware])
# Test streaming override case
override_messages = [ChatMessage(role=Role.USER, text="Give me a custom stream")]
override_updates: list[AgentRunResponseUpdate] = []
async for update in agent.run_stream(override_messages):
override_updates.append(update)
assert len(override_updates) == 3
assert override_updates[0].text == "Custom"
assert override_updates[1].text == " streaming"
assert override_updates[2].text == " response!"
# Test normal streaming case
normal_messages = [ChatMessage(role=Role.USER, text="Normal streaming request")]
normal_updates: list[AgentRunResponseUpdate] = []
async for update in agent.run_stream(normal_messages):
normal_updates.append(update)
assert len(normal_updates) == 2
assert normal_updates[0].text == "test streaming response "
assert normal_updates[1].text == "another update"
async def test_agent_middleware_conditional_no_next(self, mock_agent: AgentProtocol) -> None:
"""Test that when agent middleware conditionally doesn't call next(), no execution happens."""
class ConditionalNoNextMiddleware(AgentMiddleware):
async def process(
self, context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
) -> None:
# Only call next() if message contains "execute"
if any("execute" in msg.text for msg in context.messages if msg.text):
await next(context)
# Otherwise, don't call next() - no execution should happen
middleware = ConditionalNoNextMiddleware()
pipeline = AgentMiddlewarePipeline([middleware])
handler_called = False
async def final_handler(ctx: AgentRunContext) -> AgentRunResponse:
nonlocal handler_called
handler_called = True
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="executed response")])
# Test case where next() is NOT called
no_execute_messages = [ChatMessage(role=Role.USER, text="Don't run this")]
no_execute_context = AgentRunContext(agent=mock_agent, messages=no_execute_messages)
no_execute_result = await pipeline.execute(mock_agent, no_execute_messages, no_execute_context, final_handler)
# When middleware doesn't call next(), result should be empty AgentRunResponse
assert no_execute_result is not None
assert isinstance(no_execute_result, AgentRunResponse)
assert no_execute_result.messages == [] # Empty response
assert not handler_called
assert no_execute_context.result is None
# Reset for next test
handler_called = False
# Test case where next() IS called
execute_messages = [ChatMessage(role=Role.USER, text="Please execute this")]
execute_context = AgentRunContext(agent=mock_agent, messages=execute_messages)
execute_result = await pipeline.execute(mock_agent, execute_messages, execute_context, final_handler)
assert execute_result is not None
assert execute_result.messages[0].text == "executed response"
assert handler_called
async def test_function_middleware_conditional_no_next(self, mock_function: AIFunction[Any, Any]) -> None:
"""Test that when function middleware conditionally doesn't call next(), no execution happens."""
class ConditionalNoNextFunctionMiddleware(FunctionMiddleware):
async def process(
self,
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
# Only call next() if argument name contains "execute"
args = context.arguments
assert isinstance(args, FunctionTestArgs)
if "execute" in args.name:
await next(context)
# Otherwise, don't call next() - no execution should happen
middleware = ConditionalNoNextFunctionMiddleware()
pipeline = FunctionMiddlewarePipeline([middleware])
handler_called = False
async def final_handler(ctx: FunctionInvocationContext) -> str:
nonlocal handler_called
handler_called = True
return "executed function result"
# Test case where next() is NOT called
no_execute_args = FunctionTestArgs(name="test_no_action")
no_execute_context = FunctionInvocationContext(function=mock_function, arguments=no_execute_args)
no_execute_result = await pipeline.execute(mock_function, no_execute_args, no_execute_context, final_handler)
# When middleware doesn't call next(), function result should be None (functions can return None)
assert no_execute_result is None
assert not handler_called
assert no_execute_context.result is None
# Reset for next test
handler_called = False
# Test case where next() IS called
execute_args = FunctionTestArgs(name="test_execute")
execute_context = FunctionInvocationContext(function=mock_function, arguments=execute_args)
execute_result = await pipeline.execute(mock_function, execute_args, execute_context, final_handler)
assert execute_result == "executed function result"
assert handler_called
class TestResultObservability:
"""Test cases for middleware result observability functionality."""
async def test_agent_middleware_response_observability(self, mock_agent: AgentProtocol) -> None:
"""Test that middleware can observe response after execution."""
observed_responses: list[AgentRunResponse] = []
class ObservabilityMiddleware(AgentMiddleware):
async def process(
self, context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
) -> None:
# Context should be empty before next()
assert context.result is None
# Call next to execute
await next(context)
# Context should now contain the response for observability
assert context.result is not None
assert isinstance(context.result, AgentRunResponse)
observed_responses.append(context.result)
middleware = ObservabilityMiddleware()
pipeline = AgentMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
context = AgentRunContext(agent=mock_agent, messages=messages)
async def final_handler(ctx: AgentRunContext) -> AgentRunResponse:
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="executed response")])
result = await pipeline.execute(mock_agent, messages, context, final_handler)
# Verify response was observed
assert len(observed_responses) == 1
assert observed_responses[0].messages[0].text == "executed response"
assert result == observed_responses[0]
async def test_function_middleware_result_observability(self, mock_function: AIFunction[Any, Any]) -> None:
"""Test that middleware can observe function result after execution."""
observed_results: list[str] = []
class ObservabilityMiddleware(FunctionMiddleware):
async def process(
self,
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
# Context should be empty before next()
assert context.result is None
# Call next to execute
await next(context)
# Context should now contain the result for observability
assert context.result is not None
observed_results.append(context.result)
middleware = ObservabilityMiddleware()
pipeline = FunctionMiddlewarePipeline([middleware])
arguments = FunctionTestArgs(name="test")
context = FunctionInvocationContext(function=mock_function, arguments=arguments)
async def final_handler(ctx: FunctionInvocationContext) -> str:
return "executed function result"
result = await pipeline.execute(mock_function, arguments, context, final_handler)
# Verify result was observed
assert len(observed_results) == 1
assert observed_results[0] == "executed function result"
assert result == observed_results[0]
async def test_agent_middleware_post_execution_override(self, mock_agent: AgentProtocol) -> None:
"""Test that middleware can override response after observing execution."""
class PostExecutionOverrideMiddleware(AgentMiddleware):
async def process(
self, context: AgentRunContext, next: Callable[[AgentRunContext], Awaitable[None]]
) -> None:
# Call next to execute first
await next(context)
# Now observe and conditionally override
assert context.result is not None
assert isinstance(context.result, AgentRunResponse)
if "modify" in context.result.messages[0].text:
# Override after observing
context.result = AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="modified after execution")]
)
middleware = PostExecutionOverrideMiddleware()
pipeline = AgentMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
context = AgentRunContext(agent=mock_agent, messages=messages)
async def final_handler(ctx: AgentRunContext) -> AgentRunResponse:
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="response to modify")])
result = await pipeline.execute(mock_agent, messages, context, final_handler)
# Verify response was modified after execution
assert result is not None
assert result.messages[0].text == "modified after execution"
async def test_function_middleware_post_execution_override(self, mock_function: AIFunction[Any, Any]) -> None:
"""Test that middleware can override function result after observing execution."""
class PostExecutionOverrideMiddleware(FunctionMiddleware):
async def process(
self,
context: FunctionInvocationContext,
next: Callable[[FunctionInvocationContext], Awaitable[None]],
) -> None:
# Call next to execute first
await next(context)
# Now observe and conditionally override
assert context.result is not None
if "modify" in context.result:
# Override after observing
context.result = "modified after execution"
middleware = PostExecutionOverrideMiddleware()
pipeline = FunctionMiddlewarePipeline([middleware])
arguments = FunctionTestArgs(name="test")
context = FunctionInvocationContext(function=mock_function, arguments=arguments)
async def final_handler(ctx: FunctionInvocationContext) -> str:
return "result to modify"
result = await pipeline.execute(mock_function, arguments, context, final_handler)
# Verify result was modified after execution
assert result == "modified after execution"
@pytest.fixture
def mock_agent() -> AgentProtocol:
"""Mock agent for testing."""
agent = MagicMock(spec=AgentProtocol)
agent.name = "test_agent"
return agent
@pytest.fixture
def mock_function() -> AIFunction[Any, Any]:
"""Mock function for testing."""
function = MagicMock(spec=AIFunction[Any, Any])
function.name = "test_function"
return function
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,436 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Awaitable, Callable
from typing import Any
from agent_framework import (
ChatAgent,
ChatContext,
ChatMessage,
ChatMiddleware,
ChatResponse,
FunctionCallContent,
FunctionInvocationContext,
Role,
chat_middleware,
function_middleware,
use_function_invocation,
)
from .conftest import MockBaseChatClient
class TestChatMiddleware:
"""Test cases for chat middleware functionality."""
async def test_class_based_chat_middleware(self, chat_client_base: "MockBaseChatClient") -> None:
"""Test class-based chat middleware with ChatClient."""
execution_order: list[str] = []
class LoggingChatMiddleware(ChatMiddleware):
async def process(
self,
context: ChatContext,
next: Callable[[ChatContext], Awaitable[None]],
) -> None:
execution_order.append("chat_middleware_before")
await next(context)
execution_order.append("chat_middleware_after")
# Add middleware to chat client
chat_client_base.middleware = [LoggingChatMiddleware()]
# Execute chat client directly
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await chat_client_base.get_response(messages)
# Verify response
assert response is not None
assert len(response.messages) > 0
assert response.messages[0].role == Role.ASSISTANT
# Verify middleware execution order
assert execution_order == ["chat_middleware_before", "chat_middleware_after"]
async def test_function_based_chat_middleware(self, chat_client_base: "MockBaseChatClient") -> None:
"""Test function-based chat middleware with ChatClient."""
execution_order: list[str] = []
@chat_middleware
async def logging_chat_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
execution_order.append("function_middleware_before")
await next(context)
execution_order.append("function_middleware_after")
# Add middleware to chat client
chat_client_base.middleware = [logging_chat_middleware]
# Execute chat client directly
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await chat_client_base.get_response(messages)
# Verify response
assert response is not None
assert len(response.messages) > 0
assert response.messages[0].role == Role.ASSISTANT
# Verify middleware execution order
assert execution_order == ["function_middleware_before", "function_middleware_after"]
async def test_chat_middleware_can_modify_messages(self, chat_client_base: "MockBaseChatClient") -> None:
"""Test that chat middleware can modify messages before sending to model."""
@chat_middleware
async def message_modifier_middleware(
context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]
) -> None:
# Modify the first message by adding a prefix
if context.messages and len(context.messages) > 0:
original_text = context.messages[0].text or ""
context.messages[0] = ChatMessage(role=context.messages[0].role, text=f"MODIFIED: {original_text}")
await next(context)
# Add middleware to chat client
chat_client_base.middleware = [message_modifier_middleware]
# Execute chat client
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await chat_client_base.get_response(messages)
# Verify that the message was modified (MockChatClient echoes back the input)
assert response is not None
assert len(response.messages) > 0
# The mock client should receive the modified message
assert "MODIFIED: test message" in response.messages[0].text
async def test_chat_middleware_can_override_response(self, chat_client_base: "MockBaseChatClient") -> None:
"""Test that chat middleware can override the response."""
@chat_middleware
async def response_override_middleware(
context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]
) -> None:
# Override the response without calling next()
context.result = ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Middleware overridden response")],
response_id="middleware-response-123",
)
context.terminate = True
# Add middleware to chat client
chat_client_base.middleware = [response_override_middleware]
# Execute chat client
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await chat_client_base.get_response(messages)
# Verify that the response was overridden
assert response is not None
assert len(response.messages) > 0
assert response.messages[0].text == "Middleware overridden response"
assert response.response_id == "middleware-response-123"
async def test_multiple_chat_middleware_execution_order(self, chat_client_base: "MockBaseChatClient") -> None:
"""Test that multiple chat middleware execute in the correct order."""
execution_order: list[str] = []
@chat_middleware
async def first_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
execution_order.append("first_before")
await next(context)
execution_order.append("first_after")
@chat_middleware
async def second_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
execution_order.append("second_before")
await next(context)
execution_order.append("second_after")
# Add middleware to chat client (order should be preserved)
chat_client_base.middleware = [first_middleware, second_middleware]
# Execute chat client
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await chat_client_base.get_response(messages)
# Verify response
assert response is not None
# Verify middleware execution order (nested execution)
expected_order = ["first_before", "second_before", "second_after", "first_after"]
assert execution_order == expected_order
async def test_chat_agent_with_chat_middleware(self) -> None:
"""Test ChatAgent with chat middleware specified at agent level."""
execution_order: list[str] = []
@chat_middleware
async def agent_level_chat_middleware(
context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]
) -> None:
execution_order.append("agent_chat_middleware_before")
await next(context)
execution_order.append("agent_chat_middleware_after")
chat_client = MockBaseChatClient()
# Create ChatAgent with chat middleware
agent = ChatAgent(chat_client=chat_client, middleware=[agent_level_chat_middleware])
# Execute the agent
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await agent.run(messages)
# Verify response
assert response is not None
assert len(response.messages) > 0
assert response.messages[0].role == Role.ASSISTANT
# Verify middleware execution order
assert execution_order == ["agent_chat_middleware_before", "agent_chat_middleware_after"]
async def test_chat_agent_with_multiple_chat_middleware(self, chat_client_base: "MockBaseChatClient") -> None:
"""Test that ChatAgent can have multiple chat middleware."""
execution_order: list[str] = []
@chat_middleware
async def first_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
execution_order.append("first_before")
await next(context)
execution_order.append("first_after")
@chat_middleware
async def second_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
execution_order.append("second_before")
await next(context)
execution_order.append("second_after")
# Create ChatAgent with multiple chat middleware
agent = ChatAgent(chat_client=chat_client_base, middleware=[first_middleware, second_middleware])
# Execute the agent
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await agent.run(messages)
# Verify response
assert response is not None
# Verify both middleware executed (nested execution order)
expected_order = ["first_before", "second_before", "second_after", "first_after"]
assert execution_order == expected_order
async def test_chat_middleware_with_streaming(self, chat_client_base: "MockBaseChatClient") -> None:
"""Test chat middleware with streaming responses."""
execution_order: list[str] = []
@chat_middleware
async def streaming_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
execution_order.append("streaming_before")
# Verify it's a streaming context
assert context.is_streaming is True
await next(context)
execution_order.append("streaming_after")
# Add middleware to chat client
chat_client_base.middleware = [streaming_middleware]
# Execute streaming response
messages = [ChatMessage(role=Role.USER, text="test message")]
updates: list[object] = []
async for update in chat_client_base.get_streaming_response(messages):
updates.append(update)
# Verify we got updates
assert len(updates) > 0
# Verify middleware executed
assert execution_order == ["streaming_before", "streaming_after"]
async def test_run_level_middleware_isolation(self, chat_client_base: "MockBaseChatClient") -> None:
"""Test that run-level middleware is isolated and doesn't persist across calls."""
execution_count = {"count": 0}
@chat_middleware
async def counting_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
execution_count["count"] += 1
await next(context)
# First call with run-level middleware
messages = [ChatMessage(role=Role.USER, text="first message")]
response1 = await chat_client_base.get_response(messages, middleware=[counting_middleware])
assert response1 is not None
assert execution_count["count"] == 1
# Second call WITHOUT run-level middleware - should not execute the middleware
messages = [ChatMessage(role=Role.USER, text="second message")]
response2 = await chat_client_base.get_response(messages)
assert response2 is not None
assert execution_count["count"] == 1 # Should still be 1, not 2
# Third call with run-level middleware again - should execute
messages = [ChatMessage(role=Role.USER, text="third message")]
response3 = await chat_client_base.get_response(messages, middleware=[counting_middleware])
assert response3 is not None
assert execution_count["count"] == 2 # Should be 2 now
async def test_chat_client_middleware_can_access_and_override_custom_kwargs(
self, chat_client_base: "MockBaseChatClient"
) -> None:
"""Test that chat client middleware can access and override custom parameters like temperature."""
captured_kwargs: dict[str, Any] = {}
modified_kwargs: dict[str, Any] = {}
@chat_middleware
async def kwargs_middleware(context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]) -> None:
# Capture the original kwargs
captured_kwargs.update(context.kwargs)
# Modify some kwargs
context.kwargs["temperature"] = 0.9
context.kwargs["max_tokens"] = 500
context.kwargs["new_param"] = "added_by_middleware"
# Store modified kwargs for verification
modified_kwargs.update(context.kwargs)
await next(context)
# Add middleware to chat client
chat_client_base.middleware = [kwargs_middleware]
# Execute chat client with custom parameters
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await chat_client_base.get_response(
messages, temperature=0.7, max_tokens=100, custom_param="test_value"
)
# Verify response
assert response is not None
assert len(response.messages) > 0
assert captured_kwargs["temperature"] == 0.7
assert captured_kwargs["max_tokens"] == 100
assert captured_kwargs["custom_param"] == "test_value"
# Verify middleware could modify the kwargs
assert modified_kwargs["temperature"] == 0.9
assert modified_kwargs["max_tokens"] == 500
assert modified_kwargs["new_param"] == "added_by_middleware"
assert modified_kwargs["custom_param"] == "test_value" # Should still be there
async def test_function_middleware_registration_on_chat_client(self) -> None:
"""Test function middleware registered on ChatClient is executed during function calls."""
execution_order: list[str] = []
@function_middleware
async def test_function_middleware(
context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]]
) -> None:
execution_order.append(f"function_middleware_before_{context.function.name}")
await next(context)
execution_order.append(f"function_middleware_after_{context.function.name}")
# Define a simple tool function
def sample_tool(location: str) -> str:
"""Get weather for a location."""
return f"Weather in {location}: sunny"
# Create function-invocation enabled chat client
chat_client = use_function_invocation(MockBaseChatClient)()
# Set function middleware directly on the chat client
chat_client.middleware = [test_function_middleware]
# Prepare responses that will trigger function invocation
function_call_response = ChatResponse(
messages=[
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
call_id="call_1",
name="sample_tool",
arguments={"location": "San Francisco"},
)
],
)
]
)
final_response = ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Based on the weather data, it's sunny!")]
)
chat_client.run_responses = [function_call_response, final_response]
# Execute the chat client directly with tools - this should trigger function invocation and middleware
messages = [ChatMessage(role=Role.USER, text="What's the weather in San Francisco?")]
response = await chat_client.get_response(messages, tools=[sample_tool])
# Verify response
assert response is not None
assert len(response.messages) > 0
assert chat_client.call_count == 2 # Two calls: function call + final response
# Verify function middleware was executed
assert execution_order == [
"function_middleware_before_sample_tool",
"function_middleware_after_sample_tool",
]
async def test_run_level_function_middleware(self) -> None:
"""Test that function middleware passed to get_response method is also invoked."""
execution_order: list[str] = []
@function_middleware
async def run_level_function_middleware(
context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]]
) -> None:
execution_order.append("run_level_function_middleware_before")
await next(context)
execution_order.append("run_level_function_middleware_after")
# Define a simple tool function
def sample_tool(location: str) -> str:
"""Get weather for a location."""
return f"Weather in {location}: sunny"
# Create function-invocation enabled chat client
chat_client = use_function_invocation(MockBaseChatClient)()
# Prepare responses that will trigger function invocation
function_call_response = ChatResponse(
messages=[
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
call_id="call_2",
name="sample_tool",
arguments={"location": "New York"},
)
],
)
]
)
final_response = ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="The weather information has been retrieved!")]
)
chat_client.run_responses = [function_call_response, final_response]
# Execute the chat client directly with run-level middleware and tools
messages = [ChatMessage(role=Role.USER, text="What's the weather in New York?")]
response = await chat_client.get_response(
messages, tools=[sample_tool], middleware=[run_level_function_middleware]
)
# Verify response
assert response is not None
assert len(response.messages) > 0
assert chat_client.call_count == 2 # Two calls: function call + final response
# Verify run-level function middleware was executed once (during function invocation)
assert execution_order == [
"run_level_function_middleware_before",
"run_level_function_middleware_after",
]
@@ -0,0 +1,469 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import MutableSequence
from typing import Any
from unittest.mock import MagicMock, Mock, patch
import pytest
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.semconv_ai import SpanAttributes
from opentelemetry.trace import StatusCode
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AgentProtocol,
AgentRunResponse,
AgentThread,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Role,
UsageDetails,
prepend_agent_framework_to_user_agent,
)
from agent_framework.exceptions import AgentInitializationError, ChatClientInitializationError
from agent_framework.observability import (
OPEN_TELEMETRY_AGENT_MARKER,
OPEN_TELEMETRY_CHAT_CLIENT_MARKER,
ROLE_EVENT_MAP,
ChatMessageListTimestampFilter,
OtelAttr,
get_function_span,
use_agent_observability,
use_observability,
)
# region Test constants
def test_role_event_map():
"""Test that ROLE_EVENT_MAP contains expected mappings."""
assert ROLE_EVENT_MAP["system"] == OtelAttr.SYSTEM_MESSAGE
assert ROLE_EVENT_MAP["user"] == OtelAttr.USER_MESSAGE
assert ROLE_EVENT_MAP["assistant"] == OtelAttr.ASSISTANT_MESSAGE
assert ROLE_EVENT_MAP["tool"] == OtelAttr.TOOL_MESSAGE
def test_enum_values():
"""Test that OtelAttr enum has expected values."""
assert OtelAttr.OPERATION == "gen_ai.operation.name"
assert SpanAttributes.LLM_SYSTEM == "gen_ai.system"
assert SpanAttributes.LLM_REQUEST_MODEL == "gen_ai.request.model"
assert OtelAttr.CHAT_COMPLETION_OPERATION == "chat"
assert OtelAttr.TOOL_EXECUTION_OPERATION == "execute_tool"
assert OtelAttr.AGENT_INVOKE_OPERATION == "invoke_agent"
# region Test ChatMessageListTimestampFilter
def test_filter_without_index_key():
"""Test filter method when record doesn't have INDEX_KEY."""
log_filter = ChatMessageListTimestampFilter()
record = logging.LogRecord(
name="test", level=logging.INFO, pathname="", lineno=0, msg="test message", args=(), exc_info=None
)
original_created = record.created
result = log_filter.filter(record)
assert result is True
assert record.created == original_created
def test_filter_with_index_key():
"""Test filter method when record has INDEX_KEY."""
log_filter = ChatMessageListTimestampFilter()
record = logging.LogRecord(
name="test", level=logging.INFO, pathname="", lineno=0, msg="test message", args=(), exc_info=None
)
original_created = record.created
# Add the index key
setattr(record, ChatMessageListTimestampFilter.INDEX_KEY, 5)
result = log_filter.filter(record)
assert result is True
# Should increment by 5 microseconds (5 * 1e-6)
assert record.created == original_created + 5 * 1e-6
def test_index_key_constant():
"""Test that INDEX_KEY constant is correctly defined."""
assert ChatMessageListTimestampFilter.INDEX_KEY == "chat_message_index"
# region Test get_function_span
def test_start_span_basic(span_exporter: InMemorySpanExporter):
"""Test starting a span with basic function info."""
# Create a mock function
mock_function = Mock()
mock_function.name = "test_function"
mock_function.description = "Test function description"
attributes = {
OtelAttr.OPERATION: OtelAttr.TOOL_EXECUTION_OPERATION,
OtelAttr.TOOL_NAME: "test_function",
OtelAttr.TOOL_DESCRIPTION: "Test function description",
OtelAttr.TOOL_TYPE: "function",
}
span_exporter.clear()
with get_function_span(attributes) as function_span:
assert function_span is not None
function_span.set_attribute("test_attr", "test_value")
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "execute_tool test_function"
assert span.attributes["test_attr"] == "test_value"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.TOOL_EXECUTION_OPERATION
assert span.attributes[OtelAttr.TOOL_NAME] == "test_function"
assert span.attributes[OtelAttr.TOOL_DESCRIPTION] == "Test function description"
def test_start_span_with_tool_call_id(span_exporter: InMemorySpanExporter):
"""Test starting a span with tool_call_id."""
tool_call_id = "test_call_123"
attributes = {
OtelAttr.OPERATION: OtelAttr.TOOL_EXECUTION_OPERATION,
OtelAttr.TOOL_NAME: "test_function",
OtelAttr.TOOL_DESCRIPTION: "Test function",
OtelAttr.TOOL_TYPE: "function",
OtelAttr.TOOL_CALL_ID: tool_call_id,
}
span_exporter.clear()
with get_function_span(attributes) as function_span:
assert function_span is not None
function_span.set_attribute("test_attr", "test_value")
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "execute_tool test_function"
assert span.attributes["test_attr"] == "test_value"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == tool_call_id
# Verify all attributes
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.TOOL_EXECUTION_OPERATION
assert span.attributes[OtelAttr.TOOL_NAME] == "test_function"
assert span.attributes[OtelAttr.TOOL_DESCRIPTION] == "Test function"
assert span.attributes[OtelAttr.TOOL_TYPE] == "function"
# region Test use_observability decorator
def test_decorator_with_valid_class():
"""Test that decorator works with a valid BaseChatClient-like class."""
# Create a mock class with the required methods
class MockChatClient:
async def get_response(self, messages, **kwargs):
return Mock()
async def get_streaming_response(self, messages, **kwargs):
async def gen():
yield Mock()
return gen()
# Apply the decorator
decorated_class = use_observability(MockChatClient)
assert hasattr(decorated_class, OPEN_TELEMETRY_CHAT_CLIENT_MARKER)
def test_decorator_with_missing_methods():
"""Test that decorator handles classes missing required methods gracefully."""
class MockChatClient:
OTEL_PROVIDER_NAME = "test_provider"
# Apply the decorator - should not raise an error
with pytest.raises(ChatClientInitializationError):
use_observability(MockChatClient)
def test_decorator_with_partial_methods():
"""Test decorator when only one method is present."""
class MockChatClient:
OTEL_PROVIDER_NAME = "test_provider"
async def get_response(self, messages, **kwargs):
return Mock()
with pytest.raises(ChatClientInitializationError):
use_observability(MockChatClient)
# region Test telemetry decorator with mock client
@pytest.fixture
def mock_chat_client():
"""Create a mock chat client for testing."""
class MockChatClient(BaseChatClient):
def service_url(self):
return "https://test.example.com"
async def _inner_get_response(
self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
):
return ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Test response")],
usage_details=UsageDetails(input_token_count=10, output_token_count=20),
finish_reason=None,
)
async def _inner_get_streaming_response(
self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
):
yield ChatResponseUpdate(text="Hello", role=Role.ASSISTANT)
yield ChatResponseUpdate(text=" world", role=Role.ASSISTANT)
return MockChatClient
@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True)
async def test_chat_client_observability(mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data):
"""Test that when diagnostics are enabled, telemetry is applied."""
client = use_observability(mock_chat_client)()
messages = [ChatMessage(role=Role.USER, text="Test message")]
span_exporter.clear()
response = await client.get_response(messages=messages, model="Test")
assert response is not None
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "chat Test"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.CHAT_COMPLETION_OPERATION
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "Test"
assert span.attributes[OtelAttr.INPUT_TOKENS] == 10
assert span.attributes[OtelAttr.OUTPUT_TOKENS] == 20
if enable_sensitive_data:
assert span.attributes[OtelAttr.INPUT_MESSAGES] is not None
assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None
@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True)
async def test_chat_client_streaming_observability(
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Test streaming telemetry through the use_observability decorator."""
client = use_observability(mock_chat_client)()
messages = [ChatMessage(role=Role.USER, text="Test")]
span_exporter.clear()
# Collect all yielded updates
updates = []
async for update in client.get_streaming_response(messages=messages, model="Test"):
updates.append(update)
# Verify we got the expected updates, this shouldn't be dependent on otel
assert len(updates) == 2
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "chat Test"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.CHAT_COMPLETION_OPERATION
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "Test"
if enable_sensitive_data:
assert span.attributes[OtelAttr.INPUT_MESSAGES] is not None
assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None
def test_prepend_user_agent_with_none_value():
"""Test prepend user agent with None value in headers."""
headers = {"User-Agent": None}
result = prepend_agent_framework_to_user_agent(headers)
# Should handle None gracefully
assert "User-Agent" in result
assert AGENT_FRAMEWORK_USER_AGENT in str(result["User-Agent"])
# region Test use_agent_observability decorator
def test_agent_decorator_with_valid_class():
"""Test that agent decorator works with a valid ChatAgent-like class."""
# Create a mock class with the required methods
class MockChatClientAgent:
AGENT_SYSTEM_NAME = "test_agent_system"
def __init__(self):
self.id = "test_agent_id"
self.name = "test_agent"
self.display_name = "Test Agent"
self.description = "Test agent description"
async def run(self, messages=None, *, thread=None, **kwargs):
return Mock()
async def run_stream(self, messages=None, *, thread=None, **kwargs):
async def gen():
yield Mock()
return gen()
def get_new_thread(self) -> AgentThread:
return AgentThread()
# Apply the decorator
decorated_class = use_agent_observability(MockChatClientAgent)
assert hasattr(decorated_class, OPEN_TELEMETRY_AGENT_MARKER)
def test_agent_decorator_with_missing_methods():
"""Test that agent decorator handles classes missing required methods gracefully."""
class MockAgent:
AGENT_SYSTEM_NAME = "test_agent_system"
# Apply the decorator - should not raise an error
with pytest.raises(AgentInitializationError):
use_agent_observability(MockAgent)
def test_agent_decorator_with_partial_methods():
"""Test agent decorator when only one method is present."""
from agent_framework.observability import use_agent_observability
class MockAgent:
AGENT_SYSTEM_NAME = "test_agent_system"
def __init__(self):
self.id = "test_agent_id"
self.name = "test_agent"
self.display_name = "Test Agent"
async def run(self, messages=None, *, thread=None, **kwargs):
return Mock()
with pytest.raises(AgentInitializationError):
use_agent_observability(MockAgent)
# region Test agent telemetry decorator with mock agent
@pytest.fixture
def mock_chat_agent():
"""Create a mock chat client agent for testing."""
class MockChatClientAgent:
AGENT_SYSTEM_NAME = "test_agent_system"
def __init__(self):
self.id = "test_agent_id"
self.name = "test_agent"
self.display_name = "Test Agent"
self.description = "Test agent description"
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Agent response")],
usage_details=UsageDetails(input_token_count=15, output_token_count=25),
response_id="test_response_id",
raw_representation=Mock(finish_reason=Mock(value="stop")),
)
async def run_stream(self, messages=None, *, thread=None, **kwargs):
from agent_framework import AgentRunResponseUpdate
yield AgentRunResponseUpdate(text="Hello", role=Role.ASSISTANT)
yield AgentRunResponseUpdate(text=" from agent", role=Role.ASSISTANT)
return MockChatClientAgent
@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True)
async def test_agent_instrumentation_enabled(
mock_chat_agent: AgentProtocol, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Test that when agent diagnostics are enabled, telemetry is applied."""
agent = use_agent_observability(mock_chat_agent)()
span_exporter.clear()
response = await agent.run("Test message")
assert response is not None
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "invoke_agent Test Agent"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.AGENT_INVOKE_OPERATION
assert span.attributes[OtelAttr.AGENT_ID] == "test_agent_id"
assert span.attributes[OtelAttr.AGENT_NAME] == "Test Agent"
assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
assert span.attributes[OtelAttr.INPUT_TOKENS] == 15
assert span.attributes[OtelAttr.OUTPUT_TOKENS] == 25
if enable_sensitive_data:
assert span.attributes[OtelAttr.OUTPUT_MESSAGES] is not None
@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True)
async def test_agent_streaming_response_with_diagnostics_enabled_via_decorator(
mock_chat_agent: AgentProtocol, span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""Test agent streaming telemetry through the use_agent_observability decorator."""
agent = use_agent_observability(mock_chat_agent)()
span_exporter.clear()
updates = []
async for update in agent.run_stream("Test message"):
updates.append(update)
# Verify we got the expected updates
assert len(updates) == 2
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.name == "invoke_agent Test Agent"
assert span.attributes[OtelAttr.OPERATION.value] == OtelAttr.AGENT_INVOKE_OPERATION
assert span.attributes[OtelAttr.AGENT_ID] == "test_agent_id"
assert span.attributes[OtelAttr.AGENT_NAME] == "Test Agent"
assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description"
assert span.attributes[SpanAttributes.LLM_REQUEST_MODEL] == "unknown"
if enable_sensitive_data:
assert span.attributes.get(OtelAttr.OUTPUT_MESSAGES) is not None # Streaming, so no usage yet
async def test_agent_run_with_exception_handling(mock_chat_agent: AgentProtocol):
"""Test agent run with exception handling."""
async def run_with_error(self, messages=None, *, thread=None, **kwargs):
raise RuntimeError("Agent run error")
mock_chat_agent.run = run_with_error
agent = use_agent_observability(mock_chat_agent)()
from opentelemetry.trace import Span
with (
patch("agent_framework.observability._get_span") as mock_get_span,
):
mock_span = MagicMock(spec=Span)
# Ensure the patched context manager returns mock_span when entered
mock_get_span.return_value.__enter__.return_value = mock_span
# Should raise the exception and call error handler
with pytest.raises(RuntimeError, match="Agent run error"):
await agent.run("Test message")
# Verify error was recorded
# Check that both error attributes were set on the span
mock_span.set_attribute.assert_called_with(OtelAttr.ERROR_TYPE, "RuntimeError")
mock_span.record_exception.assert_called_once()
mock_span.set_status.assert_called_once_with(
status=StatusCode.ERROR, description=repr(RuntimeError("Agent run error"))
)
@@ -0,0 +1,98 @@
# Copyright (c) Microsoft. All rights reserved.
from unittest.mock import patch
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
USER_AGENT_KEY,
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
prepend_agent_framework_to_user_agent,
)
# region Test constants
def test_telemetry_disabled_env_var():
"""Test that the telemetry disabled environment variable is correctly defined."""
assert USER_AGENT_TELEMETRY_DISABLED_ENV_VAR == "AGENT_FRAMEWORK_USER_AGENT_DISABLED"
def test_user_agent_key():
"""Test that the user agent key is correctly defined."""
assert USER_AGENT_KEY == "User-Agent"
def test_agent_framework_user_agent_format():
"""Test that the agent framework user agent is correctly formatted."""
assert AGENT_FRAMEWORK_USER_AGENT.startswith("agent-framework-python/")
def test_app_info_when_telemetry_enabled():
"""Test that APP_INFO is set when telemetry is enabled."""
with patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True):
import importlib
import agent_framework._telemetry
importlib.reload(agent_framework._telemetry)
from agent_framework import APP_INFO
assert APP_INFO is not None
assert "agent-framework-version" in APP_INFO
assert APP_INFO["agent-framework-version"].startswith("python/")
def test_app_info_when_telemetry_disabled():
"""Test that APP_INFO is None when telemetry is disabled."""
# Test the logic directly since APP_INFO is set at module import time
with patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", False):
# Simulate the module's logic for APP_INFO
test_app_info = (
{
"agent-framework-version": "python/test",
}
if False # This simulates IS_TELEMETRY_ENABLED being False
else None
)
assert test_app_info is None
# region Test prepend_agent_framework_to_user_agent
def test_prepend_to_existing_user_agent():
"""Test prepending to existing User-Agent header."""
headers = {"User-Agent": "existing-agent/1.0"}
result = prepend_agent_framework_to_user_agent(headers)
assert "User-Agent" in result
assert result["User-Agent"].startswith("agent-framework-python/")
assert "existing-agent/1.0" in result["User-Agent"]
def test_prepend_to_empty_headers():
"""Test prepending to headers without User-Agent."""
headers = {"Content-Type": "application/json"}
result = prepend_agent_framework_to_user_agent(headers)
assert "User-Agent" in result
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
assert "Content-Type" in result
def test_prepend_to_empty_dict():
"""Test prepending to empty headers dict."""
headers = {}
result = prepend_agent_framework_to_user_agent(headers)
assert "User-Agent" in result
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_modifies_original_dict():
"""Test that the function modifies the original headers dict."""
headers = {"Other-Header": "value"}
result = prepend_agent_framework_to_user_agent(headers)
assert result is headers # Same object
assert "User-Agent" in headers
@@ -0,0 +1,398 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Sequence
from typing import Any
import pytest
from agent_framework import AgentThread, ChatMessage, ChatMessageStore, Role
from agent_framework._threads import AgentThreadState, ChatMessageStoreState
from agent_framework.exceptions import AgentThreadException
class MockChatMessageStore:
"""Mock implementation of ChatMessageStoreProtocol for testing."""
def __init__(self, messages: list[ChatMessage] | None = None) -> None:
self._messages = messages or []
self._serialize_calls = 0
self._deserialize_calls = 0
async def list_messages(self) -> list[ChatMessage]:
return self._messages
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
self._messages.extend(messages)
async def serialize(self, **kwargs: Any) -> Any:
self._serialize_calls += 1
return {"messages": [msg.__dict__ for msg in self._messages], "kwargs": kwargs}
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
self._deserialize_calls += 1
if serialized_store_state and "messages" in serialized_store_state:
self._messages = serialized_store_state["messages"]
@classmethod
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "MockChatMessageStore":
instance = cls()
await instance.update_from_state(serialized_store_state, **kwargs)
return instance
@pytest.fixture
def sample_messages() -> list[ChatMessage]:
"""Fixture providing sample chat messages for testing."""
return [
ChatMessage(role=Role.USER, text="Hello", message_id="msg1"),
ChatMessage(role=Role.ASSISTANT, text="Hi there!", message_id="msg2"),
ChatMessage(role=Role.USER, text="How are you?", message_id="msg3"),
]
@pytest.fixture
def sample_message() -> ChatMessage:
"""Fixture providing a single sample chat message for testing."""
return ChatMessage(role=Role.USER, text="Test message", message_id="test1")
class TestAgentThread:
"""Test cases for AgentThread class."""
def test_init_with_no_parameters(self) -> None:
"""Test AgentThread initialization with no parameters."""
thread = AgentThread()
assert thread.service_thread_id is None
assert thread.message_store is None
def test_init_with_service_thread_id(self) -> None:
"""Test AgentThread initialization with service_thread_id."""
service_thread_id = "test-conversation-123"
thread = AgentThread(service_thread_id=service_thread_id)
assert thread.service_thread_id == service_thread_id
assert thread.message_store is None
def test_init_with_message_store(self) -> None:
"""Test AgentThread initialization with message_store."""
store = ChatMessageStore()
thread = AgentThread(message_store=store)
assert thread.service_thread_id is None
assert thread.message_store is store
def test_service_thread_id_property_setter(self) -> None:
"""Test service_thread_id property setter."""
thread = AgentThread()
service_thread_id = "test-conversation-456"
thread.service_thread_id = service_thread_id
assert thread.service_thread_id == service_thread_id
def test_service_thread_id_setter_with_existing_message_store_raises_error(self) -> None:
"""Test that setting service_thread_id when message_store exists raises AgentThreadException."""
store = ChatMessageStore()
thread = AgentThread(message_store=store)
with pytest.raises(AgentThreadException, match="Only the service_thread_id or message_store may be set"):
thread.service_thread_id = "test-conversation-789"
def test_service_thread_id_setter_with_none_values(self) -> None:
"""Test service_thread_id setter with None values does nothing."""
thread = AgentThread()
thread.service_thread_id = None # Should not raise error
assert thread.service_thread_id is None
def test_message_store_property_setter(self) -> None:
"""Test message_store property setter."""
thread = AgentThread()
store = ChatMessageStore()
thread.message_store = store
assert thread.message_store is store
def test_message_store_setter_with_existing_service_thread_id_raises_error(self) -> None:
"""Test that setting message_store when service_thread_id exists raises AgentThreadException."""
service_thread_id = "test-conversation-999"
thread = AgentThread(service_thread_id=service_thread_id)
store = ChatMessageStore()
with pytest.raises(AgentThreadException, match="Only the service_thread_id or message_store may be set"):
thread.message_store = store
def test_message_store_setter_with_none_values(self) -> None:
"""Test message_store setter with None values does nothing."""
thread = AgentThread()
thread.message_store = None # Should not raise error
assert thread.message_store is None
async def test_get_messages_with_message_store(self, sample_messages: list[ChatMessage]) -> None:
"""Test get_messages when message_store is set."""
store = ChatMessageStore(sample_messages)
thread = AgentThread(message_store=store)
assert thread.message_store is not None
messages: list[ChatMessage] = await thread.message_store.list_messages()
assert messages is not None
assert len(messages) == 3
assert messages[0].text == "Hello"
assert messages[1].text == "Hi there!"
assert messages[2].text == "How are you?"
async def test_get_messages_with_no_message_store(self) -> None:
"""Test get_messages when no message_store is set."""
thread = AgentThread()
assert thread.message_store is None
async def test_on_new_messages_with_service_thread_id(self, sample_message: ChatMessage) -> None:
"""Test _on_new_messages when service_thread_id is set (should do nothing)."""
thread = AgentThread(service_thread_id="test-conv")
await thread.on_new_messages(sample_message)
# Should not create a message store
assert thread.message_store is None
async def test_on_new_messages_single_message_creates_store(self, sample_message: ChatMessage) -> None:
"""Test _on_new_messages with single message creates ChatMessageStore."""
thread = AgentThread()
await thread.on_new_messages(sample_message)
assert thread.message_store is not None
assert isinstance(thread.message_store, ChatMessageStore)
messages = await thread.message_store.list_messages()
assert len(messages) == 1
assert messages[0].text == "Test message"
async def test_on_new_messages_multiple_messages(self, sample_messages: list[ChatMessage]) -> None:
"""Test _on_new_messages with multiple messages."""
thread = AgentThread()
await thread.on_new_messages(sample_messages)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 3
async def test_on_new_messages_with_existing_store(self, sample_message: ChatMessage) -> None:
"""Test _on_new_messages adds to existing message store."""
initial_messages = [ChatMessage(role=Role.USER, text="Initial", message_id="init1")]
store = ChatMessageStore(initial_messages)
thread = AgentThread(message_store=store)
await thread.on_new_messages(sample_message)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 2
assert messages[0].text == "Initial"
assert messages[1].text == "Test message"
async def test_deserialize_with_service_thread_id(self) -> None:
"""Test _deserialize with service_thread_id."""
serialized_data = {"service_thread_id": "test-conv-123", "chat_message_store_state": None}
thread = await AgentThread.deserialize(serialized_data)
assert thread.service_thread_id == "test-conv-123"
assert thread.message_store is None
async def test_deserialize_with_store_state(self, sample_messages: list[ChatMessage]) -> None:
"""Test _deserialize with chat_message_store_state."""
store_state = {"messages": sample_messages}
serialized_data = {"service_thread_id": None, "chat_message_store_state": store_state}
thread = await AgentThread.deserialize(serialized_data)
assert thread.service_thread_id is None
assert thread.message_store is not None
assert isinstance(thread.message_store, ChatMessageStore)
async def test_deserialize_with_no_state(self) -> None:
"""Test _deserialize with no state."""
thread = AgentThread()
serialized_data = {"service_thread_id": None, "chat_message_store_state": None}
await thread.deserialize(serialized_data)
assert thread.service_thread_id is None
assert thread.message_store is None
async def test_deserialize_with_existing_store(self) -> None:
"""Test _deserialize with existing message store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
serialized_data: dict[str, Any] = {"service_thread_id": None, "chat_message_store_state": {"messages": []}}
await thread.update_from_thread_state(serialized_data)
assert store._deserialize_calls == 1 # pyright: ignore[reportPrivateUsage]
async def test_serialize_with_service_thread_id(self) -> None:
"""Test serialize with service_thread_id."""
thread = AgentThread(service_thread_id="test-conv-456")
result = await thread.serialize()
assert result["service_thread_id"] == "test-conv-456"
assert result["chat_message_store_state"] is None
async def test_serialize_with_message_store(self) -> None:
"""Test serialize with message_store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
result = await thread.serialize()
assert result["service_thread_id"] is None
assert result["chat_message_store_state"] is not None
assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage]
async def test_serialize_with_no_state(self) -> None:
"""Test serialize with no state."""
thread = AgentThread()
result = await thread.serialize()
assert result["service_thread_id"] is None
assert result["chat_message_store_state"] is None
async def test_serialize_with_kwargs(self) -> None:
"""Test serialize passes kwargs to message store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
await thread.serialize(custom_param="test_value")
assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage]
class TestChatMessageList:
"""Test cases for ChatMessageStore class."""
def test_init_empty(self) -> None:
"""Test ChatMessageStore initialization with no messages."""
store = ChatMessageStore()
assert len(store.messages) == 0
def test_init_with_messages(self, sample_messages: list[ChatMessage]) -> None:
"""Test ChatMessageStore initialization with messages."""
store = ChatMessageStore(sample_messages)
assert len(store.messages) == 3
async def test_add_messages(self, sample_messages: list[ChatMessage]) -> None:
"""Test adding messages to the store."""
store = ChatMessageStore()
await store.add_messages(sample_messages)
assert len(store.messages) == 3
messages = await store.list_messages()
assert messages[0].text == "Hello"
async def test_get_messages(self, sample_messages: list[ChatMessage]) -> None:
"""Test getting messages from the store."""
store = ChatMessageStore(sample_messages)
messages = await store.list_messages()
assert len(messages) == 3
assert messages[0].message_id == "msg1"
async def test_serialize_state(self, sample_messages: list[ChatMessage]) -> None:
"""Test serializing store state."""
store = ChatMessageStore(sample_messages)
result = await store.serialize()
assert "messages" in result
assert len(result["messages"]) == 3
async def test_serialize_state_empty(self) -> None:
"""Test serializing empty store state."""
store = ChatMessageStore()
result = await store.serialize()
assert "messages" in result
assert len(result["messages"]) == 0
async def test_deserialize_state(self, sample_messages: list[ChatMessage]) -> None:
"""Test deserializing store state."""
store = ChatMessageStore()
state_data = {"messages": sample_messages}
await store.update_from_state(state_data)
messages = await store.list_messages()
assert len(messages) == 3
assert messages[0].text == "Hello"
async def test_deserialize_state_none(self) -> None:
"""Test deserializing None state."""
store = ChatMessageStore()
await store.update_from_state(None)
assert len(store.messages) == 0
async def test_deserialize_state_empty(self) -> None:
"""Test deserializing empty state."""
store = ChatMessageStore()
await store.update_from_state({})
assert len(store.messages) == 0
class TestStoreState:
"""Test cases for ChatMessageStoreState class."""
def test_init(self, sample_messages: list[ChatMessage]) -> None:
"""Test ChatMessageStoreState initialization."""
state = ChatMessageStoreState(messages=sample_messages)
assert len(state.messages) == 3
assert state.messages[0].text == "Hello"
def test_init_empty(self) -> None:
"""Test ChatMessageStoreState initialization with empty messages."""
state = ChatMessageStoreState(messages=[])
assert len(state.messages) == 0
class TestThreadState:
"""Test cases for AgentThreadState class."""
def test_init_with_service_thread_id(self) -> None:
"""Test AgentThreadState initialization with service_thread_id."""
state = AgentThreadState(service_thread_id="test-conv-123")
assert state.service_thread_id == "test-conv-123"
assert state.chat_message_store_state is None
def test_init_with_chat_message_store_state(self) -> None:
"""Test AgentThreadState initialization with chat_message_store_state."""
store_data: dict[str, Any] = {"messages": []}
state = AgentThreadState(chat_message_store_state=store_data)
assert state.service_thread_id is None
assert state.chat_message_store_state == store_data
def test_init_with_both(self) -> None:
"""Test AgentThreadState initialization with both parameters."""
store_data: dict[str, Any] = {"messages": []}
with pytest.raises(
AgentThreadException, match="Only one of service_thread_id or chat_message_store_state may be set"
):
AgentThreadState(service_thread_id="test-conv-123", chat_message_store_state=store_data)
def test_init_defaults(self) -> None:
"""Test AgentThreadState initialization with defaults."""
state = AgentThreadState()
assert state.service_thread_id is None
assert state.chat_message_store_state is None
@@ -0,0 +1,618 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from unittest.mock import Mock
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from pydantic import BaseModel
from agent_framework import (
AIFunction,
HostedCodeInterpreterTool,
HostedMCPTool,
ToolProtocol,
ai_function,
)
from agent_framework._tools import _parse_inputs
from agent_framework.exceptions import ToolException
from agent_framework.observability import OtelAttr
# region AIFunction and ai_function decorator tests
def test_ai_function_decorator():
"""Test the ai_function decorator."""
@ai_function(name="test_tool", description="A test tool")
def test_tool(x: int, y: int) -> int:
"""A simple function that adds two numbers."""
return x + y
assert isinstance(test_tool, ToolProtocol)
assert isinstance(test_tool, AIFunction)
assert test_tool.name == "test_tool"
assert test_tool.description == "A test tool"
assert test_tool.parameters() == {
"properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}},
"required": ["x", "y"],
"title": "test_tool_input",
"type": "object",
}
assert test_tool(1, 2) == 3
def test_ai_function_decorator_without_args():
"""Test the ai_function decorator."""
@ai_function
def test_tool(x: int, y: int) -> int:
"""A simple function that adds two numbers."""
return x + y
assert isinstance(test_tool, ToolProtocol)
assert isinstance(test_tool, AIFunction)
assert test_tool.name == "test_tool"
assert test_tool.description == "A simple function that adds two numbers."
assert test_tool.parameters() == {
"properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}},
"required": ["x", "y"],
"title": "test_tool_input",
"type": "object",
}
assert test_tool(1, 2) == 3
async def test_ai_function_decorator_with_async():
"""Test the ai_function decorator with an async function."""
@ai_function(name="async_test_tool", description="An async test tool")
async def async_test_tool(x: int, y: int) -> int:
"""An async function that adds two numbers."""
return x + y
assert isinstance(async_test_tool, ToolProtocol)
assert isinstance(async_test_tool, AIFunction)
assert async_test_tool.name == "async_test_tool"
assert async_test_tool.description == "An async test tool"
assert async_test_tool.parameters() == {
"properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}},
"required": ["x", "y"],
"title": "async_test_tool_input",
"type": "object",
}
assert (await async_test_tool(1, 2)) == 3
async def test_ai_function_invoke_telemetry_enabled(span_exporter: InMemorySpanExporter):
"""Test the ai_function invoke method with telemetry enabled."""
@ai_function(
name="telemetry_test_tool",
description="A test tool for telemetry",
)
def telemetry_test_tool(x: int, y: int) -> int:
"""A function that adds two numbers for telemetry testing."""
return x + y
# Mock the histogram
mock_histogram = Mock()
telemetry_test_tool._invocation_duration_histogram = mock_histogram
span_exporter.clear()
# Call invoke
result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id")
# Verify result
assert result == 3
# Verify telemetry calls
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert OtelAttr.TOOL_EXECUTION_OPERATION.value in span.name
assert "telemetry_test_tool" in span.name
assert span.attributes[OtelAttr.TOOL_NAME] == "telemetry_test_tool"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "test_call_id"
assert span.attributes[OtelAttr.TOOL_TYPE] == "function"
assert span.attributes[OtelAttr.TOOL_DESCRIPTION] == "A test tool for telemetry"
assert span.attributes[OtelAttr.TOOL_ARGUMENTS] == '{"x": 1, "y": 2}'
assert span.attributes[OtelAttr.TOOL_RESULT] == "3"
# Verify histogram was called with correct attributes
mock_histogram.record.assert_called_once()
call_args = mock_histogram.record.call_args
assert call_args[0][0] > 0 # duration should be positive
attributes = call_args[1]["attributes"]
assert attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] == "telemetry_test_tool"
assert attributes[OtelAttr.TOOL_CALL_ID] == "test_call_id"
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
async def test_ai_function_invoke_telemetry_sensitive_disabled(span_exporter: InMemorySpanExporter):
"""Test the ai_function invoke method with telemetry enabled."""
@ai_function(
name="telemetry_test_tool",
description="A test tool for telemetry",
)
def telemetry_test_tool(x: int, y: int) -> int:
"""A function that adds two numbers for telemetry testing."""
return x + y
# Mock the histogram
mock_histogram = Mock()
telemetry_test_tool._invocation_duration_histogram = mock_histogram
span_exporter.clear()
# Call invoke
result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id")
# Verify result
assert result == 3
# Verify telemetry calls
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert OtelAttr.TOOL_EXECUTION_OPERATION.value in span.name
assert "telemetry_test_tool" in span.name
assert span.attributes[OtelAttr.TOOL_NAME] == "telemetry_test_tool"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "test_call_id"
assert span.attributes[OtelAttr.TOOL_TYPE] == "function"
assert span.attributes[OtelAttr.TOOL_DESCRIPTION] == "A test tool for telemetry"
assert OtelAttr.TOOL_ARGUMENTS not in span.attributes
assert OtelAttr.TOOL_RESULT not in span.attributes
# Verify histogram was called with correct attributes
mock_histogram.record.assert_called_once()
call_args = mock_histogram.record.call_args
assert call_args[0][0] > 0 # duration should be positive
attributes = call_args[1]["attributes"]
assert attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] == "telemetry_test_tool"
assert attributes[OtelAttr.TOOL_CALL_ID] == "test_call_id"
async def test_ai_function_invoke_telemetry_with_pydantic_args(span_exporter: InMemorySpanExporter):
"""Test the ai_function invoke method with Pydantic model arguments."""
@ai_function(
name="pydantic_test_tool",
description="A test tool with Pydantic args",
)
def pydantic_test_tool(x: int, y: int) -> int:
"""A function that adds two numbers using Pydantic args."""
return x + y
# Create arguments as Pydantic model instance
args_model = pydantic_test_tool.input_model(x=5, y=10)
mock_histogram = Mock()
pydantic_test_tool._invocation_duration_histogram = mock_histogram
span_exporter.clear()
# Call invoke with Pydantic model
result = await pydantic_test_tool.invoke(arguments=args_model, tool_call_id="pydantic_call")
# Verify result
assert result == 15
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert OtelAttr.TOOL_EXECUTION_OPERATION.value in span.name
assert "pydantic_test_tool" in span.name
assert span.attributes[OtelAttr.TOOL_NAME] == "pydantic_test_tool"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "pydantic_call"
assert span.attributes[OtelAttr.TOOL_TYPE] == "function"
assert span.attributes[OtelAttr.TOOL_DESCRIPTION] == "A test tool with Pydantic args"
assert span.attributes[OtelAttr.TOOL_ARGUMENTS] == '{"x":5,"y":10}'
async def test_ai_function_invoke_telemetry_with_exception(span_exporter: InMemorySpanExporter):
"""Test the ai_function invoke method with telemetry when an exception occurs."""
@ai_function(
name="exception_test_tool",
description="A test tool that raises an exception",
)
def exception_test_tool(x: int, y: int) -> int:
"""A function that raises an exception for telemetry testing."""
raise ValueError("Test exception for telemetry")
mock_histogram = Mock()
exception_test_tool._invocation_duration_histogram = mock_histogram
span_exporter.clear()
# Call invoke and expect exception
with pytest.raises(ValueError, match="Test exception for telemetry"):
await exception_test_tool.invoke(x=1, y=2, tool_call_id="exception_call")
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert OtelAttr.TOOL_EXECUTION_OPERATION.value in span.name
assert "exception_test_tool" in span.name
assert span.attributes[OtelAttr.TOOL_NAME] == "exception_test_tool"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "exception_call"
assert span.attributes[OtelAttr.TOOL_TYPE] == "function"
assert span.attributes[OtelAttr.TOOL_DESCRIPTION] == "A test tool that raises an exception"
assert span.attributes[OtelAttr.TOOL_ARGUMENTS] == '{"x": 1, "y": 2}'
assert span.attributes[OtelAttr.ERROR_TYPE] == ValueError.__name__
assert span.status.status_code == trace.StatusCode.ERROR
# Verify histogram was called with error attributes
mock_histogram.record.assert_called_once()
call_args = mock_histogram.record.call_args
attributes = call_args[1]["attributes"]
assert attributes[OtelAttr.ERROR_TYPE] == ValueError.__name__
async def test_ai_function_invoke_telemetry_async_function(span_exporter: InMemorySpanExporter):
"""Test the ai_function invoke method with telemetry on async function."""
@ai_function(
name="async_telemetry_test",
description="An async test tool for telemetry",
)
async def async_telemetry_test(x: int, y: int) -> int:
"""An async function for telemetry testing."""
return x * y
mock_histogram = Mock()
async_telemetry_test._invocation_duration_histogram = mock_histogram
span_exporter.clear()
# Call invoke
result = await async_telemetry_test.invoke(x=3, y=4, tool_call_id="async_call")
# Verify result
assert result == 12
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert OtelAttr.TOOL_EXECUTION_OPERATION.value in span.name
assert "async_telemetry_test" in span.name
assert span.attributes[OtelAttr.TOOL_NAME] == "async_telemetry_test"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "async_call"
assert span.attributes[OtelAttr.TOOL_TYPE] == "function"
assert span.attributes[OtelAttr.TOOL_DESCRIPTION] == "An async test tool for telemetry"
assert span.attributes[OtelAttr.TOOL_ARGUMENTS] == '{"x": 3, "y": 4}'
# Verify histogram recording
mock_histogram.record.assert_called_once()
call_args = mock_histogram.record.call_args
attributes = call_args[1]["attributes"]
assert attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] == "async_telemetry_test"
async def test_ai_function_invoke_invalid_pydantic_args():
"""Test the ai_function invoke method with invalid Pydantic model arguments."""
@ai_function(name="invalid_args_test", description="A test tool for invalid args")
def invalid_args_test(x: int, y: int) -> int:
"""A function for testing invalid Pydantic args."""
return x + y
# Create a different Pydantic model
class WrongModel(BaseModel):
a: str
b: str
wrong_args = WrongModel(a="hello", b="world")
# Call invoke with wrong model type
with pytest.raises(TypeError, match="Expected invalid_args_test_input, got WrongModel"):
await invalid_args_test.invoke(arguments=wrong_args)
# region HostedCodeInterpreterTool and _parse_inputs
def test_hosted_code_interpreter_tool_default():
"""Test HostedCodeInterpreterTool with default parameters."""
tool = HostedCodeInterpreterTool()
assert tool.name == "code_interpreter"
assert tool.inputs == []
assert tool.description == ""
assert tool.additional_properties is None
assert str(tool) == "HostedCodeInterpreterTool(name=code_interpreter)"
def test_hosted_code_interpreter_tool_with_description():
"""Test HostedCodeInterpreterTool with description and additional properties."""
tool = HostedCodeInterpreterTool(
description="A test code interpreter",
additional_properties={"version": "1.0", "language": "python"},
)
assert tool.name == "code_interpreter"
assert tool.description == "A test code interpreter"
assert tool.additional_properties == {"version": "1.0", "language": "python"}
def test_parse_inputs_none():
"""Test _parse_inputs with None input."""
result = _parse_inputs(None)
assert result == []
def test_parse_inputs_string():
"""Test _parse_inputs with string input."""
from agent_framework import UriContent
result = _parse_inputs("http://example.com")
assert len(result) == 1
assert isinstance(result[0], UriContent)
assert result[0].uri == "http://example.com"
assert result[0].media_type == "text/plain"
def test_parse_inputs_list_of_strings():
"""Test _parse_inputs with list of strings."""
from agent_framework import UriContent
inputs = ["http://example.com", "https://test.org"]
result = _parse_inputs(inputs)
assert len(result) == 2
assert all(isinstance(item, UriContent) for item in result)
assert result[0].uri == "http://example.com"
assert result[1].uri == "https://test.org"
assert all(item.media_type == "text/plain" for item in result)
def test_parse_inputs_uri_dict():
"""Test _parse_inputs with URI dictionary."""
from agent_framework import UriContent
input_dict = {"uri": "http://example.com", "media_type": "application/json"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], UriContent)
assert result[0].uri == "http://example.com"
assert result[0].media_type == "application/json"
def test_parse_inputs_hosted_file_dict():
"""Test _parse_inputs with hosted file dictionary."""
from agent_framework import HostedFileContent
input_dict = {"file_id": "file-123"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], HostedFileContent)
assert result[0].file_id == "file-123"
def test_parse_inputs_hosted_vector_store_dict():
"""Test _parse_inputs with hosted vector store dictionary."""
from agent_framework import HostedVectorStoreContent
input_dict = {"vector_store_id": "vs-789"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], HostedVectorStoreContent)
assert result[0].vector_store_id == "vs-789"
def test_parse_inputs_data_dict():
"""Test _parse_inputs with data dictionary."""
from agent_framework import DataContent
input_dict = {"data": b"test data", "media_type": "application/octet-stream"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], DataContent)
assert result[0].uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
assert result[0].media_type == "application/octet-stream"
def test_parse_inputs_ai_contents_instance():
"""Test _parse_inputs with Contents instance."""
from agent_framework import TextContent
text_content = TextContent(text="Hello, world!")
result = _parse_inputs(text_content)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "Hello, world!"
def test_parse_inputs_mixed_list():
"""Test _parse_inputs with mixed input types."""
from agent_framework import HostedFileContent, TextContent, UriContent
inputs = [
"http://example.com", # string
{"uri": "https://test.org", "media_type": "text/html"}, # URI dict
{"file_id": "file-456"}, # hosted file dict
TextContent(text="Hello"), # Contents instance
]
result = _parse_inputs(inputs)
assert len(result) == 4
assert isinstance(result[0], UriContent)
assert result[0].uri == "http://example.com"
assert isinstance(result[1], UriContent)
assert result[1].uri == "https://test.org"
assert result[1].media_type == "text/html"
assert isinstance(result[2], HostedFileContent)
assert result[2].file_id == "file-456"
assert isinstance(result[3], TextContent)
assert result[3].text == "Hello"
def test_parse_inputs_unsupported_dict():
"""Test _parse_inputs with unsupported dictionary format."""
input_dict = {"unsupported_key": "value"}
with pytest.raises(ValueError, match="Unsupported input type"):
_parse_inputs(input_dict)
def test_parse_inputs_unsupported_type():
"""Test _parse_inputs with unsupported input type."""
with pytest.raises(TypeError, match="Unsupported input type: int"):
_parse_inputs(123)
def test_hosted_code_interpreter_tool_with_string_input():
"""Test HostedCodeInterpreterTool with string input."""
from agent_framework import UriContent
tool = HostedCodeInterpreterTool(inputs="http://example.com")
assert len(tool.inputs) == 1
assert isinstance(tool.inputs[0], UriContent)
assert tool.inputs[0].uri == "http://example.com"
def test_hosted_code_interpreter_tool_with_dict_inputs():
"""Test HostedCodeInterpreterTool with dictionary inputs."""
from agent_framework import HostedFileContent, UriContent
inputs = [{"uri": "http://example.com", "media_type": "text/html"}, {"file_id": "file-123"}]
tool = HostedCodeInterpreterTool(inputs=inputs)
assert len(tool.inputs) == 2
assert isinstance(tool.inputs[0], UriContent)
assert tool.inputs[0].uri == "http://example.com"
assert tool.inputs[0].media_type == "text/html"
assert isinstance(tool.inputs[1], HostedFileContent)
assert tool.inputs[1].file_id == "file-123"
def test_hosted_code_interpreter_tool_with_ai_contents():
"""Test HostedCodeInterpreterTool with Contents instances."""
from agent_framework import DataContent, TextContent
inputs = [TextContent(text="Hello, world!"), DataContent(data=b"test", media_type="text/plain")]
tool = HostedCodeInterpreterTool(inputs=inputs)
assert len(tool.inputs) == 2
assert isinstance(tool.inputs[0], TextContent)
assert tool.inputs[0].text == "Hello, world!"
assert isinstance(tool.inputs[1], DataContent)
assert tool.inputs[1].media_type == "text/plain"
def test_hosted_code_interpreter_tool_with_single_input():
"""Test HostedCodeInterpreterTool with single input (not in list)."""
from agent_framework import HostedFileContent
input_dict = {"file_id": "file-single"}
tool = HostedCodeInterpreterTool(inputs=input_dict)
assert len(tool.inputs) == 1
assert isinstance(tool.inputs[0], HostedFileContent)
assert tool.inputs[0].file_id == "file-single"
def test_hosted_code_interpreter_tool_with_unknown_input():
"""Test HostedCodeInterpreterTool with single unknown input."""
with pytest.raises(ValueError, match="Unsupported input type"):
HostedCodeInterpreterTool(inputs={"hosted_file": "file-single"})
# region HostedMCPTool tests
def test_hosted_mcp_tool_with_other_fields():
"""Test creating a HostedMCPTool with a specific approval dict, headers and additional properties."""
tool = HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
description="A test MCP tool",
headers={"x": "y"},
additional_properties={"p": 1},
)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
assert tool.headers == {"x": "y"}
assert tool.additional_properties == {"p": 1}
assert tool.description == "A test MCP tool"
@pytest.mark.parametrize(
"approval_mode",
[
"always_require",
"never_require",
{
"always_require_approval": {"toolA"},
"never_require_approval": {"toolB"},
},
{
"always_require_approval": ["toolA"],
"never_require_approval": ("toolB",),
},
],
ids=["always_require", "never_require", "specific", "specific_with_parsing"],
)
def test_hosted_mcp_tool_with_approval_mode(approval_mode: str | dict[str, Any]):
"""Test creating a HostedMCPTool with a specific approval dict, headers and additional properties."""
tool = HostedMCPTool(name="mcp-tool", url="https://mcp.example", approval_mode=approval_mode)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
if not isinstance(approval_mode, dict):
assert tool.approval_mode == approval_mode
else:
# approval_mode parsed to sets
assert isinstance(tool.approval_mode["always_require_approval"], set)
assert isinstance(tool.approval_mode["never_require_approval"], set)
assert "toolA" in tool.approval_mode["always_require_approval"]
assert "toolB" in tool.approval_mode["never_require_approval"]
def test_hosted_mcp_tool_invalid_approval_mode_raises():
"""Invalid approval_mode string should raise ServiceInitializationError."""
with pytest.raises(ToolException):
HostedMCPTool(name="bad", url="https://x", approval_mode="invalid_mode")
@pytest.mark.parametrize(
"tools",
[
{"toolA", "toolB"},
("toolA", "toolB"),
["toolA", "toolB"],
["toolA", "toolB", "toolA"],
],
ids=[
"set",
"tuple",
"list",
"list_with_duplicates",
],
)
def test_hosted_mcp_tool_with_allowed_tools(tools: list[str] | tuple[str, ...] | set[str]):
"""Test creating a HostedMCPTool with a list of allowed tools."""
tool = HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
allowed_tools=tools,
)
assert tool.name == "mcp-tool"
# pydantic AnyUrl preserves as string-like
assert str(tool.url).startswith("https://")
# approval_mode parsed to set
assert isinstance(tool.allowed_tools, set)
assert tool.allowed_tools == {"toolA", "toolB"}
def test_hosted_mcp_tool_with_dict_of_allowed_tools():
"""Test creating a HostedMCPTool with a dict of allowed tools."""
with pytest.raises(ToolException):
HostedMCPTool(
name="mcp-tool",
url="https://mcp.example",
allowed_tools={"toolA": "Tool A", "toolC": "Tool C"},
)
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.
from copy import deepcopy
from unittest.mock import MagicMock
class CopyingMock(MagicMock):
def __call__(self, *args, **kwargs):
args = deepcopy(args)
kwargs = deepcopy(kwargs)
return super().__call__(*args, **kwargs)
@@ -0,0 +1,51 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from pytest import fixture
# region Connector Settings fixtures
@fixture
def exclude_list(request: Any) -> list[str]:
"""Fixture that returns a list of environment variables to exclude."""
return request.param if hasattr(request, "param") else []
@fixture
def override_env_param_dict(request: Any) -> dict[str, str]:
"""Fixture that returns a dict of environment variables to override."""
return request.param if hasattr(request, "param") else {}
@fixture()
def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
"""Fixture to set environment variables for OpenAISettings."""
if exclude_list is None:
exclude_list = []
if override_env_param_dict is None:
override_env_param_dict = {}
env_vars = {
"OPENAI_API_KEY": "test-dummy-key",
"OPENAI_ORG_ID": "test_org_id",
"OPENAI_RESPONSES_MODEL_ID": "test_responses_model_id",
"OPENAI_CHAT_MODEL_ID": "test_chat_model_id",
"OPENAI_TEXT_MODEL_ID": "test_text_model_id",
"OPENAI_EMBEDDING_MODEL_ID": "test_embedding_model_id",
"OPENAI_TEXT_TO_IMAGE_MODEL_ID": "test_text_to_image_model_id",
"OPENAI_AUDIO_TO_TEXT_MODEL_ID": "test_audio_to_text_model_id",
"OPENAI_TEXT_TO_AUDIO_MODEL_ID": "test_text_to_audio_model_id",
"OPENAI_REALTIME_MODEL_ID": "test_realtime_model_id",
}
env_vars.update(override_env_param_dict) # type: ignore
for key, value in env_vars.items():
if key in exclude_list:
monkeypatch.delenv(key, raising=False) # type: ignore
continue
monkeypatch.setenv(key, value) # type: ignore
return env_vars
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,775 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Annotated
from unittest.mock import MagicMock, patch
import pytest
from openai import BadRequestError
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
DataContent,
FunctionResultContent,
HostedWebSearchTool,
TextContent,
ToolProtocol,
ai_function,
)
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.openai import OpenAIChatClient
from agent_framework.openai._exceptions import OpenAIContentFilterException
from agent_framework.openai._shared import prepare_function_call_results
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"),
reason="No real OPENAI_API_KEY provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
def test_init(openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
open_ai_chat_completion = OpenAIChatClient()
assert open_ai_chat_completion.ai_model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
assert isinstance(open_ai_chat_completion, ChatClientProtocol)
def test_init_validation_fail() -> None:
# Test successful initialization
with pytest.raises(ServiceInitializationError):
OpenAIChatClient(api_key="34523", ai_model_id={"test": "dict"}) # type: ignore
def test_init_ai_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
ai_model_id = "test_model_id"
open_ai_chat_completion = OpenAIChatClient(ai_model_id=ai_model_id)
assert open_ai_chat_completion.ai_model_id == ai_model_id
assert isinstance(open_ai_chat_completion, ChatClientProtocol)
def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
# Test successful initialization
open_ai_chat_completion = OpenAIChatClient(
default_headers=default_headers,
)
assert open_ai_chat_completion.ai_model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
assert isinstance(open_ai_chat_completion, ChatClientProtocol)
# Assert that the default header we added is present in the client's default headers
for key, value in default_headers.items():
assert key in open_ai_chat_completion.client.default_headers
assert open_ai_chat_completion.client.default_headers[key] == value
def test_init_base_url(openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
open_ai_chat_completion = OpenAIChatClient(base_url="http://localhost:1234/v1")
assert str(open_ai_chat_completion.client.base_url) == "http://localhost:1234/v1/"
def test_init_base_url_from_settings_env() -> None:
"""Test that base_url from OpenAISettings environment variable is properly used."""
# Set environment variable for base_url
with patch.dict(
os.environ,
{
"OPENAI_API_KEY": "dummy",
"OPENAI_CHAT_MODEL_ID": "gpt-5",
"OPENAI_BASE_URL": "https://custom-openai-endpoint.com/v1",
},
):
client = OpenAIChatClient()
assert client.ai_model_id == "gpt-5"
assert str(client.client.base_url) == "https://custom-openai-endpoint.com/v1/"
@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True)
def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
OpenAIChatClient(
env_file_path="test.env",
)
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None:
ai_model_id = "test_model_id"
with pytest.raises(ServiceInitializationError):
OpenAIChatClient(
ai_model_id=ai_model_id,
env_file_path="test.env",
)
def test_serialize(openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
settings = {
"ai_model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
"default_headers": default_headers,
}
open_ai_chat_completion = OpenAIChatClient.from_dict(settings)
dumped_settings = open_ai_chat_completion.to_dict()
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
# Assert that the default header we added is present in the dumped_settings default headers
for key, value in default_headers.items():
assert key in dumped_settings["default_headers"]
assert dumped_settings["default_headers"][key] == value
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
assert "User-Agent" not in dumped_settings["default_headers"]
def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None:
settings = {
"ai_model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
"org_id": openai_unit_test_env["OPENAI_ORG_ID"],
}
open_ai_chat_completion = OpenAIChatClient.from_dict(settings)
dumped_settings = open_ai_chat_completion.to_dict()
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"]
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
assert "User-Agent" not in dumped_settings["default_headers"]
async def test_content_filter_exception_handling(openai_unit_test_env: dict[str, str]) -> None:
"""Test that content filter errors are properly handled."""
client = OpenAIChatClient()
messages = [ChatMessage(role="user", text="test message")]
# Create a mock BadRequestError with content_filter code
mock_response = MagicMock()
mock_error = BadRequestError(
message="Content filter error", response=mock_response, body={"error": {"code": "content_filter"}}
)
mock_error.code = "content_filter"
# Mock the client to raise the content filter error
with (
patch.object(client.client.chat.completions, "create", side_effect=mock_error),
pytest.raises(OpenAIContentFilterException),
):
await client._inner_get_response(messages=messages, chat_options=ChatOptions()) # type: ignore
def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None:
"""Test that unsupported tool types are handled correctly."""
client = OpenAIChatClient()
# Create a mock ToolProtocol that's not an AIFunction
unsupported_tool = MagicMock(spec=ToolProtocol)
unsupported_tool.__class__.__name__ = "UnsupportedAITool"
# This should ignore the unsupported ToolProtocol and return empty list
result = client._chat_to_tool_spec([unsupported_tool]) # type: ignore
assert result == []
# Also test with a non-ToolProtocol that should be converted to dict
dict_tool = {"type": "function", "name": "test"}
result = client._chat_to_tool_spec([dict_tool]) # type: ignore
assert result == [dict_tool]
@ai_function
def get_story_text() -> str:
"""Returns a story about Emily and David."""
return (
"Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change."
)
@ai_function
def get_weather(location: str) -> str:
"""Get the current weather for a location."""
return f"The weather in {location} is sunny and 72°F."
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_completion_response() -> None:
"""Test OpenAI chat completion responses."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await openai_chat_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
assert "scientists" in response.text
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_completion_response_tools() -> None:
"""Test OpenAI chat completion responses."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await openai_chat_client.get_response(
messages=messages,
tools=[get_story_text],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert "scientists" in response.text
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_streaming() -> None:
"""Test Azure OpenAI chat completion responses."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = openai_chat_client.get_streaming_response(messages=messages)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
assert chunk.message_id is not None
assert chunk.response_id is not None
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "scientists" in full_message
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_streaming_tools() -> None:
"""Test AzureOpenAI chat completion responses."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = openai_chat_client.get_streaming_response(
messages=messages,
tools=[get_story_text],
tool_choice="auto",
)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "scientists" in full_message
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_web_search() -> None:
# Currently only a select few models support web search tool calls
openai_chat_client = OpenAIChatClient(ai_model_id="gpt-4o-search-preview")
assert isinstance(openai_chat_client, ChatClientProtocol)
# Test that the client will use the web search tool
response = await openai_chat_client.get_response(
messages=[
ChatMessage(
role="user",
text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
)
],
tools=[HostedWebSearchTool()],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert "Rumi" in response.text
assert "Mira" in response.text
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
response = await openai_chat_client.get_response(
messages=[ChatMessage(role="user", text="What is the current weather? Do not ask for my current location.")],
tools=[HostedWebSearchTool(additional_properties=additional_properties)],
tool_choice="auto",
)
assert response.text is not None
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_web_search_streaming() -> None:
openai_chat_client = OpenAIChatClient(ai_model_id="gpt-4o-search-preview")
assert isinstance(openai_chat_client, ChatClientProtocol)
# Test that the client will use the web search tool
response = openai_chat_client.get_streaming_response(
messages=[
ChatMessage(
role="user",
text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
)
],
tools=[HostedWebSearchTool()],
tool_choice="auto",
)
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "Rumi" in full_message
assert "Mira" in full_message
assert "Zoey" in full_message
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
response = openai_chat_client.get_streaming_response(
messages=[ChatMessage(role="user", text="What is the current weather? Do not ask for my current location.")],
tools=[HostedWebSearchTool(additional_properties=additional_properties)],
tool_choice="auto",
)
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert full_message is not None
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_agent_basic_run():
"""Test OpenAI chat client agent basic run functionality with OpenAIChatClient."""
async with ChatAgent(
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
) as agent:
# Test basic run
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
assert "hello world" in response.text.lower()
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_agent_basic_run_streaming():
"""Test OpenAI chat client agent basic streaming functionality with OpenAIChatClient."""
async with ChatAgent(
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
) as agent:
# Test streaming run
full_text = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_text += chunk.text
assert len(full_text) > 0
assert "streaming response test" in full_text.lower()
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_agent_thread_persistence():
"""Test OpenAI chat client agent thread persistence across runs with OpenAIChatClient."""
async with ChatAgent(
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# First interaction
response1 = await agent.run("My name is Alice. Remember this.", thread=thread)
assert isinstance(response1, AgentRunResponse)
assert response1.text is not None
# Second interaction - test memory
response2 = await agent.run("What is my name?", thread=thread)
assert isinstance(response2, AgentRunResponse)
assert response2.text is not None
assert "alice" in response2.text.lower()
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_agent_existing_thread():
"""Test OpenAI chat client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async with ChatAgent(
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
async with ChatAgent(
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved thread
second_response = await second_agent.run("What is my name?", thread=preserved_thread)
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
assert "alice" in second_response.text.lower()
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_agent_level_tool_persistence():
"""Test that agent-level tools persist across multiple runs with OpenAI Chat Client."""
async with ChatAgent(
chat_client=OpenAIChatClient(ai_model_id="gpt-4.1"),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather], # Agent-level tool
) as agent:
# First run - agent-level tool should be available
first_response = await agent.run("What's the weather like in Chicago?")
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the agent-level weather tool
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - agent-level tool should still be available (persistence test)
second_response = await agent.run("What's the weather in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should use the agent-level weather tool again
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_client_run_level_tool_isolation():
"""Test that run-level tools are isolated to specific runs and don't persist with OpenAI Chat Client."""
# Counter to track how many times the weather tool is called
call_count = 0
@ai_function
async def get_weather_with_counter(location: Annotated[str, "The location as a city name"]) -> str:
"""Get the current weather in a given location."""
nonlocal call_count
call_count += 1
return f"The weather in {location} is sunny and 72°F."
async with ChatAgent(
chat_client=OpenAIChatClient(ai_model_id="gpt-4.1"),
instructions="You are a helpful assistant.",
) as agent:
# First run - use run-level tool
first_response = await agent.run(
"What's the weather like in Chicago?",
tools=[get_weather_with_counter], # Run-level tool
)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the run-level weather tool (call count should be 1)
assert call_count == 1
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - run-level tool should NOT persist (key isolation test)
second_response = await agent.run("What's the weather like in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should NOT use the weather tool since it was only run-level in previous call
# Call count should still be 1 (no additional calls)
assert call_count == 1
async def test_exception_message_includes_original_error_details() -> None:
"""Test that exception messages include original error details in the new format."""
client = OpenAIChatClient(ai_model_id="test-model", api_key="test-key")
messages = [ChatMessage(role="user", text="test message")]
mock_response = MagicMock()
original_error_message = "Invalid API request format"
mock_error = BadRequestError(
message=original_error_message,
response=mock_response,
body={"error": {"code": "invalid_request", "message": original_error_message}},
)
mock_error.code = "invalid_request"
with (
patch.object(client.client.chat.completions, "create", side_effect=mock_error),
pytest.raises(ServiceResponseException) as exc_info,
):
await client._inner_get_response(messages=messages, chat_options=ChatOptions()) # type: ignore
exception_message = str(exc_info.value)
assert "service failed to complete the prompt:" in exception_message
assert original_error_message in exception_message
def test_chat_response_content_order_text_before_tool_calls(openai_unit_test_env: dict[str, str]):
"""Test that text content appears before tool calls in ChatResponse contents."""
# Import locally to avoid break other tests when the import changes
from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
# Create a mock OpenAI response with both text and tool calls
mock_response = ChatCompletion(
id="test-response",
object="chat.completion",
created=1234567890,
model="gpt-4o-mini",
choices=[
Choice(
index=0,
message=ChatCompletionMessage(
role="assistant",
content="I'll help you with that calculation.",
tool_calls=[
ChatCompletionMessageToolCall(
id="call-123",
type="function",
function=Function(name="calculate", arguments='{"x": 5, "y": 3}'),
)
],
),
finish_reason="tool_calls",
)
],
)
client = OpenAIChatClient()
response = client._create_chat_response(mock_response, ChatOptions())
# Verify we have both text and tool call content
assert len(response.messages) == 1
message = response.messages[0]
assert len(message.contents) == 2
# Verify text content comes first, tool call comes second
assert message.contents[0].type == "text"
assert message.contents[0].text == "I'll help you with that calculation."
assert message.contents[1].type == "function_call"
assert message.contents[1].name == "calculate"
def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, str]):
"""Test that falsy values (like empty list) in function result are properly handled."""
client = OpenAIChatClient()
# Test with empty list (falsy but not None)
message_with_empty_list = ChatMessage(role="tool", contents=[FunctionResultContent(call_id="call-123", result=[])])
openai_messages = client._openai_chat_message_parser(message_with_empty_list)
assert len(openai_messages) == 1
assert openai_messages[0]["content"] == "[]" # Empty list should be JSON serialized
# Test with empty string (falsy but not None)
message_with_empty_string = ChatMessage(
role="tool", contents=[FunctionResultContent(call_id="call-456", result="")]
)
openai_messages = client._openai_chat_message_parser(message_with_empty_string)
assert len(openai_messages) == 1
assert openai_messages[0]["content"] == "" # Empty string should be preserved
# Test with False (falsy but not None)
message_with_false = ChatMessage(role="tool", contents=[FunctionResultContent(call_id="call-789", result=False)])
openai_messages = client._openai_chat_message_parser(message_with_false)
assert len(openai_messages) == 1
assert openai_messages[0]["content"] == "false" # False should be JSON serialized
def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]):
"""Test that exceptions in function result are properly handled.
Feel free to remove this test in case there's another new behavior.
"""
client = OpenAIChatClient()
# Test with exception (no result)
test_exception = ValueError("Test error message")
message_with_exception = ChatMessage(
role="tool", contents=[FunctionResultContent(call_id="call-123", exception=test_exception)]
)
openai_messages = client._openai_chat_message_parser(message_with_exception)
assert len(openai_messages) == 1
assert openai_messages[0]["content"] == "Error: Test error message"
assert openai_messages[0]["tool_call_id"] == "call-123"
def test_prepare_function_call_results_string_passthrough():
"""Test that string values are passed through directly without JSON encoding."""
result = prepare_function_call_results("simple string")
assert result == "simple string"
assert isinstance(result, str)
def test_openai_content_parser_data_content_image(openai_unit_test_env: dict[str, str]) -> None:
"""Test _openai_content_parser converts DataContent with image media type to OpenAI format."""
client = OpenAIChatClient()
# Test DataContent with image media type
image_data_content = DataContent(
uri="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
media_type="image/png",
)
result = client._openai_content_parser(image_data_content) # type: ignore
# Should convert to OpenAI image_url format
assert result["type"] == "image_url"
assert result["image_url"]["url"] == image_data_content.uri
# Test DataContent with non-image media type should use default model_dump
text_data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
result = client._openai_content_parser(text_data_content) # type: ignore
# Should use default model_dump format
assert result["type"] == "data"
assert result["uri"] == text_data_content.uri
assert result["media_type"] == "text/plain"
# Test DataContent with audio media type
audio_data_content = DataContent(
uri="data:audio/wav;base64,UklGRjBEAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQwEAAAAAAAAAAAA",
media_type="audio/wav",
)
result = client._openai_content_parser(audio_data_content) # type: ignore
# Should convert to OpenAI input_audio format
assert result["type"] == "input_audio"
# Data should contain just the base64 part, not the full data URI
assert result["input_audio"]["data"] == "UklGRjBEAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQwEAAAAAAAAAAAA"
assert result["input_audio"]["format"] == "wav"
# Test DataContent with MP3 audio
mp3_data_content = DataContent(uri="data:audio/mp3;base64,//uQAAAAWGluZwAAAA8AAAACAAACcQ==", media_type="audio/mp3")
result = client._openai_content_parser(mp3_data_content) # type: ignore
# Should convert to OpenAI input_audio format with mp3
assert result["type"] == "input_audio"
# Data should contain just the base64 part, not the full data URI
assert result["input_audio"]["data"] == "//uQAAAAWGluZwAAAA8AAAACAAACcQ=="
assert result["input_audio"]["format"] == "mp3"
# Test DataContent with PDF file
pdf_data_content = DataContent(
uri="data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKNSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXS9QYXJlbnQgMiAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDQgMCBSPj4+Pi9Db250ZW50cyA1IDAgUj4+CmVuZG9iago0IDAgb2JqCjw8L1R5cGUvRm9udC9TdWJ0eXBlL1R5cGUxL0Jhc2VGb250L0hlbHZldGljYT4+CmVuZG9iago1IDAgb2JqCjw8L0xlbmd0aCA0ND4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSA4IFRmCihIZWxsbyBXb3JsZCEpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQ1IDAwMDAwIG4gCjAwMDAwMDAzMDcgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDYvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgo0MDUKJSVFT0Y=",
media_type="application/pdf",
)
result = client._openai_content_parser(pdf_data_content) # type: ignore
# Should convert to OpenAI file format
assert result["type"] == "file"
assert result["file"]["filename"] == "document.pdf"
assert "file_data" in result["file"]
# Base64 data should be the full data URI (OpenAI requirement)
assert result["file"]["file_data"].startswith("data:application/pdf;base64,")
# Test DataContent with PDF and custom filename
pdf_with_filename = DataContent(
uri="data:application/pdf;base64,JVBERi0xLjQ=",
media_type="application/pdf",
additional_properties={"filename": "report.pdf"},
)
result = client._openai_content_parser(pdf_with_filename) # type: ignore
# Should use custom filename
assert result["type"] == "file"
assert result["file"]["filename"] == "report.pdf"
@@ -0,0 +1,354 @@
# Copyright (c) Microsoft. All rights reserved.
from copy import deepcopy
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openai import AsyncStream
from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions
from openai.types.chat import ChatCompletion, ChatCompletionChunk
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from pydantic import BaseModel
from agent_framework import ChatMessage, ChatResponseUpdate
from agent_framework.exceptions import (
ServiceResponseException,
)
from agent_framework.openai import OpenAIChatClient
async def mock_async_process_chat_stream_response(_):
mock_content = MagicMock(spec=ChatResponseUpdate)
yield mock_content, None
@pytest.fixture(scope="function")
def chat_history() -> list[ChatMessage]:
return []
@pytest.fixture
def mock_chat_completion_response() -> ChatCompletion:
return ChatCompletion(
id="test_id",
choices=[
Choice(index=0, message=ChatCompletionMessage(content="test", role="assistant"), finish_reason="stop")
],
created=0,
model="test",
object="chat.completion",
)
@pytest.fixture
def mock_streaming_chat_completion_response() -> AsyncStream[ChatCompletionChunk]:
content = ChatCompletionChunk(
id="test_id",
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
created=0,
model="test",
object="chat.completion.chunk",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [content]
return stream
# region Chat Message Content
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
openai_unit_test_env: dict[str, str],
):
mock_create.return_value = mock_chat_completion_response
chat_history.append(ChatMessage(role="user", text="hello world"))
openai_chat_completion = OpenAIChatClient()
await openai_chat_completion.get_response(
messages=chat_history,
)
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
stream=False,
messages=openai_chat_completion._prepare_chat_history_for_request(chat_history), # type: ignore
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc_chat_options(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
openai_unit_test_env: dict[str, str],
):
mock_create.return_value = mock_chat_completion_response
chat_history.append(ChatMessage(role="user", text="hello world"))
openai_chat_completion = OpenAIChatClient()
await openai_chat_completion.get_response(
messages=chat_history,
)
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
stream=False,
messages=openai_chat_completion._prepare_chat_history_for_request(chat_history), # type: ignore
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc_no_fcc_in_response(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
openai_unit_test_env: dict[str, str],
):
mock_create.return_value = mock_chat_completion_response
chat_history.append(ChatMessage(role="user", text="hello world"))
orig_chat_history = deepcopy(chat_history)
openai_chat_completion = OpenAIChatClient()
await openai_chat_completion.get_response(
messages=chat_history,
arguments={},
)
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
stream=False,
messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), # type: ignore
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_cmc_structured_output_no_fcc(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
openai_unit_test_env: dict[str, str],
):
mock_create.return_value = mock_chat_completion_response
chat_history.append(ChatMessage(role="user", text="hello world"))
# Define a mock response format
class Test(BaseModel):
name: str
openai_chat_completion = OpenAIChatClient()
await openai_chat_completion.get_response(
messages=chat_history,
response_format=Test,
)
mock_create.assert_awaited_once()
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_scmc_chat_options(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk],
openai_unit_test_env: dict[str, str],
):
mock_create.return_value = mock_streaming_chat_completion_response
chat_history.append(ChatMessage(role="user", text="hello world"))
openai_chat_completion = OpenAIChatClient()
async for msg in openai_chat_completion.get_streaming_response(
messages=chat_history,
):
assert isinstance(msg, ChatResponseUpdate)
assert msg.message_id is not None
assert msg.response_id is not None
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
stream=True,
stream_options={"include_usage": True},
messages=openai_chat_completion._prepare_chat_history_for_request(chat_history), # type: ignore
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock, side_effect=Exception)
async def test_cmc_general_exception(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
mock_chat_completion_response: ChatCompletion,
openai_unit_test_env: dict[str, str],
):
mock_create.return_value = mock_chat_completion_response
chat_history.append(ChatMessage(role="user", text="hello world"))
openai_chat_completion = OpenAIChatClient()
with pytest.raises(ServiceResponseException):
await openai_chat_completion.get_response(
messages=chat_history,
)
# region Streaming
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_get_streaming(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
openai_unit_test_env: dict[str, str],
):
content1 = ChatCompletionChunk(
id="test_id",
choices=[],
created=0,
model="test",
object="chat.completion.chunk",
)
content2 = ChatCompletionChunk(
id="test_id",
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
created=0,
model="test",
object="chat.completion.chunk",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [content1, content2]
mock_create.return_value = stream
chat_history.append(ChatMessage(role="user", text="hello world"))
orig_chat_history = deepcopy(chat_history)
openai_chat_completion = OpenAIChatClient()
async for msg in openai_chat_completion.get_streaming_response(
messages=chat_history,
):
assert isinstance(msg, ChatResponseUpdate)
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
stream=True,
stream_options={"include_usage": True},
messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), # type: ignore
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_get_streaming_singular(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
openai_unit_test_env: dict[str, str],
):
content1 = ChatCompletionChunk(
id="test_id",
choices=[],
created=0,
model="test",
object="chat.completion.chunk",
)
content2 = ChatCompletionChunk(
id="test_id",
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
created=0,
model="test",
object="chat.completion.chunk",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [content1, content2]
mock_create.return_value = stream
chat_history.append(ChatMessage(role="user", text="hello world"))
orig_chat_history = deepcopy(chat_history)
openai_chat_completion = OpenAIChatClient()
async for msg in openai_chat_completion.get_streaming_response(
messages=chat_history,
):
assert isinstance(msg, ChatResponseUpdate)
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
stream=True,
stream_options={"include_usage": True},
messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), # type: ignore
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_get_streaming_structured_output_no_fcc(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
openai_unit_test_env: dict[str, str],
):
content1 = ChatCompletionChunk(
id="test_id",
choices=[],
created=0,
model="test",
object="chat.completion.chunk",
)
content2 = ChatCompletionChunk(
id="test_id",
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
created=0,
model="test",
object="chat.completion.chunk",
)
stream = MagicMock(spec=AsyncStream)
stream.__aiter__.return_value = [content1, content2]
mock_create.return_value = stream
chat_history.append(ChatMessage(role="user", text="hello world"))
# Define a mock response format
class Test(BaseModel):
name: str
openai_chat_completion = OpenAIChatClient()
async for msg in openai_chat_completion.get_streaming_response(
messages=chat_history,
response_format=Test,
):
assert isinstance(msg, ChatResponseUpdate)
mock_create.assert_awaited_once()
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_get_streaming_no_fcc_in_response(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
mock_streaming_chat_completion_response: ChatCompletion,
openai_unit_test_env: dict[str, str],
):
mock_create.return_value = mock_streaming_chat_completion_response
chat_history.append(ChatMessage(role="user", text="hello world"))
orig_chat_history = deepcopy(chat_history)
openai_chat_completion = OpenAIChatClient()
[
msg
async for msg in openai_chat_completion.get_streaming_response(
messages=chat_history,
)
]
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
stream=True,
stream_options={"include_usage": True},
messages=openai_chat_completion._prepare_chat_history_for_request(orig_chat_history), # type: ignore
)
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
async def test_get_streaming_no_stream(
mock_create: AsyncMock,
chat_history: list[ChatMessage],
openai_unit_test_env: dict[str, str],
mock_chat_completion_response: ChatCompletion, # AsyncStream[ChatCompletionChunk]?
):
mock_create.return_value = mock_chat_completion_response
chat_history.append(ChatMessage(role="user", text="hello world"))
openai_chat_completion = OpenAIChatClient()
with pytest.raises(ServiceResponseException):
[
msg
async for msg in openai_chat_completion.get_streaming_response(
messages=chat_history,
)
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,334 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from agent_framework import (
FileCheckpointStorage,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
def test_workflow_checkpoint_default_values():
checkpoint = WorkflowCheckpoint()
assert checkpoint.checkpoint_id != ""
assert checkpoint.workflow_id == ""
assert checkpoint.timestamp != ""
assert checkpoint.messages == {}
assert checkpoint.shared_state == {}
assert checkpoint.executor_states == {}
assert checkpoint.iteration_count == 0
assert checkpoint.max_iterations == 100
assert checkpoint.metadata == {}
assert checkpoint.version == "1.0"
def test_workflow_checkpoint_custom_values():
custom_timestamp = datetime.now(timezone.utc).isoformat()
checkpoint = WorkflowCheckpoint(
checkpoint_id="test-checkpoint-123",
workflow_id="test-workflow-456",
timestamp=custom_timestamp,
messages={"executor1": [{"data": "test"}]},
shared_state={"key": "value"},
executor_states={"executor1": {"state": "active"}},
iteration_count=5,
max_iterations=50,
metadata={"test": True},
version="2.0",
)
assert checkpoint.checkpoint_id == "test-checkpoint-123"
assert checkpoint.workflow_id == "test-workflow-456"
assert checkpoint.timestamp == custom_timestamp
assert checkpoint.messages == {"executor1": [{"data": "test"}]}
assert checkpoint.shared_state == {"key": "value"}
assert checkpoint.executor_states == {"executor1": {"state": "active"}}
assert checkpoint.iteration_count == 5
assert checkpoint.max_iterations == 50
assert checkpoint.metadata == {"test": True}
assert checkpoint.version == "2.0"
async def test_memory_checkpoint_storage_save_and_load():
storage = InMemoryCheckpointStorage()
checkpoint = WorkflowCheckpoint(workflow_id="test-workflow", messages={"executor1": [{"data": "hello"}]})
# Save checkpoint
saved_id = await storage.save_checkpoint(checkpoint)
assert saved_id == checkpoint.checkpoint_id
# Load checkpoint
loaded_checkpoint = await storage.load_checkpoint(checkpoint.checkpoint_id)
assert loaded_checkpoint is not None
assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id
assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
assert loaded_checkpoint.messages == checkpoint.messages
async def test_memory_checkpoint_storage_load_nonexistent():
storage = InMemoryCheckpointStorage()
result = await storage.load_checkpoint("nonexistent-id")
assert result is None
async def test_memory_checkpoint_storage_list_checkpoints():
storage = InMemoryCheckpointStorage()
# Create checkpoints for different workflows
checkpoint1 = WorkflowCheckpoint(workflow_id="workflow-1")
checkpoint2 = WorkflowCheckpoint(workflow_id="workflow-1")
checkpoint3 = WorkflowCheckpoint(workflow_id="workflow-2")
await storage.save_checkpoint(checkpoint1)
await storage.save_checkpoint(checkpoint2)
await storage.save_checkpoint(checkpoint3)
# Test list_checkpoint_ids for workflow-1
workflow1_checkpoint_ids = await storage.list_checkpoint_ids("workflow-1")
assert len(workflow1_checkpoint_ids) == 2
assert checkpoint1.checkpoint_id in workflow1_checkpoint_ids
assert checkpoint2.checkpoint_id in workflow1_checkpoint_ids
# Test list_checkpoints for workflow-1 (returns objects)
workflow1_checkpoints = await storage.list_checkpoints("workflow-1")
assert len(workflow1_checkpoints) == 2
assert all(isinstance(cp, WorkflowCheckpoint) for cp in workflow1_checkpoints)
assert {cp.checkpoint_id for cp in workflow1_checkpoints} == {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id}
# Test list_checkpoint_ids for workflow-2
workflow2_checkpoint_ids = await storage.list_checkpoint_ids("workflow-2")
assert len(workflow2_checkpoint_ids) == 1
assert checkpoint3.checkpoint_id in workflow2_checkpoint_ids
# Test list_checkpoints for workflow-2 (returns objects)
workflow2_checkpoints = await storage.list_checkpoints("workflow-2")
assert len(workflow2_checkpoints) == 1
assert workflow2_checkpoints[0].checkpoint_id == checkpoint3.checkpoint_id
# Test list_checkpoint_ids for non-existent workflow
empty_checkpoint_ids = await storage.list_checkpoint_ids("nonexistent-workflow")
assert len(empty_checkpoint_ids) == 0
# Test list_checkpoints for non-existent workflow
empty_checkpoints = await storage.list_checkpoints("nonexistent-workflow")
assert len(empty_checkpoints) == 0
# Test list_checkpoint_ids without workflow filter (all checkpoints)
all_checkpoint_ids = await storage.list_checkpoint_ids()
assert len(all_checkpoint_ids) == 3
expected_ids = {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id, checkpoint3.checkpoint_id}
assert expected_ids.issubset(set(all_checkpoint_ids))
# Test list_checkpoints without workflow filter (all checkpoints)
all_checkpoints = await storage.list_checkpoints()
assert len(all_checkpoints) == 3
assert all(isinstance(cp, WorkflowCheckpoint) for cp in all_checkpoints)
async def test_memory_checkpoint_storage_delete():
storage = InMemoryCheckpointStorage()
checkpoint = WorkflowCheckpoint(workflow_id="test-workflow")
# Save checkpoint
await storage.save_checkpoint(checkpoint)
assert await storage.load_checkpoint(checkpoint.checkpoint_id) is not None
# Delete checkpoint
result = await storage.delete_checkpoint(checkpoint.checkpoint_id)
assert result is True
# Verify deletion
assert await storage.load_checkpoint(checkpoint.checkpoint_id) is None
# Try to delete again
result = await storage.delete_checkpoint(checkpoint.checkpoint_id)
assert result is False
async def test_file_checkpoint_storage_save_and_load():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
messages={"executor1": [{"data": "hello", "source_id": "test", "target_id": None}]},
shared_state={"key": "value"},
)
# Save checkpoint
saved_id = await storage.save_checkpoint(checkpoint)
assert saved_id == checkpoint.checkpoint_id
# Verify file was created
file_path = Path(temp_dir) / f"{checkpoint.checkpoint_id}.json"
assert file_path.exists()
# Load checkpoint
loaded_checkpoint = await storage.load_checkpoint(checkpoint.checkpoint_id)
assert loaded_checkpoint is not None
assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id
assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
assert loaded_checkpoint.messages == checkpoint.messages
assert loaded_checkpoint.shared_state == checkpoint.shared_state
async def test_file_checkpoint_storage_load_nonexistent():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
result = await storage.load_checkpoint("nonexistent-id")
assert result is None
async def test_file_checkpoint_storage_list_checkpoints():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Create checkpoints for different workflows
checkpoint1 = WorkflowCheckpoint(workflow_id="workflow-1")
checkpoint2 = WorkflowCheckpoint(workflow_id="workflow-1")
checkpoint3 = WorkflowCheckpoint(workflow_id="workflow-2")
await storage.save_checkpoint(checkpoint1)
await storage.save_checkpoint(checkpoint2)
await storage.save_checkpoint(checkpoint3)
# Test list_checkpoint_ids for workflow-1
workflow1_checkpoint_ids = await storage.list_checkpoint_ids("workflow-1")
assert len(workflow1_checkpoint_ids) == 2
assert checkpoint1.checkpoint_id in workflow1_checkpoint_ids
assert checkpoint2.checkpoint_id in workflow1_checkpoint_ids
# Test list_checkpoints for workflow-1 (returns objects)
workflow1_checkpoints = await storage.list_checkpoints("workflow-1")
assert len(workflow1_checkpoints) == 2
assert all(isinstance(cp, WorkflowCheckpoint) for cp in workflow1_checkpoints)
checkpoint_ids = {cp.checkpoint_id for cp in workflow1_checkpoints}
assert checkpoint_ids == {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id}
# Test list_checkpoint_ids for workflow-2
workflow2_checkpoint_ids = await storage.list_checkpoint_ids("workflow-2")
assert len(workflow2_checkpoint_ids) == 1
assert checkpoint3.checkpoint_id in workflow2_checkpoint_ids
# Test list_checkpoints for workflow-2 (returns objects)
workflow2_checkpoints = await storage.list_checkpoints("workflow-2")
assert len(workflow2_checkpoints) == 1
assert workflow2_checkpoints[0].checkpoint_id == checkpoint3.checkpoint_id
# Test list all checkpoints
all_checkpoint_ids = await storage.list_checkpoint_ids()
assert len(all_checkpoint_ids) == 3
all_checkpoints = await storage.list_checkpoints()
assert len(all_checkpoints) == 3
assert all(isinstance(cp, WorkflowCheckpoint) for cp in all_checkpoints)
async def test_file_checkpoint_storage_delete():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
checkpoint = WorkflowCheckpoint(workflow_id="test-workflow")
# Save checkpoint
await storage.save_checkpoint(checkpoint)
file_path = Path(temp_dir) / f"{checkpoint.checkpoint_id}.json"
assert file_path.exists()
# Delete checkpoint
result = await storage.delete_checkpoint(checkpoint.checkpoint_id)
assert result is True
assert not file_path.exists()
# Try to delete again
result = await storage.delete_checkpoint(checkpoint.checkpoint_id)
assert result is False
async def test_file_checkpoint_storage_directory_creation():
with tempfile.TemporaryDirectory() as temp_dir:
nested_path = Path(temp_dir) / "nested" / "checkpoint" / "storage"
storage = FileCheckpointStorage(nested_path)
# Directory should be created
assert nested_path.exists()
assert nested_path.is_dir()
# Should be able to save checkpoints
checkpoint = WorkflowCheckpoint(workflow_id="test")
await storage.save_checkpoint(checkpoint)
file_path = nested_path / f"{checkpoint.checkpoint_id}.json"
assert file_path.exists()
async def test_file_checkpoint_storage_corrupted_file():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Create a corrupted JSON file
corrupted_file = Path(temp_dir) / "corrupted.json"
with open(corrupted_file, "w") as f: # noqa: ASYNC230
f.write("{ invalid json }")
# list_checkpoints should handle the corrupted file gracefully
checkpoints = await storage.list_checkpoints("any-workflow")
assert checkpoints == []
async def test_file_checkpoint_storage_json_serialization():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Create checkpoint with complex nested data
checkpoint = WorkflowCheckpoint(
workflow_id="complex-workflow",
messages={"executor1": [{"data": {"nested": {"value": 42}}, "source_id": "test", "target_id": None}]},
shared_state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None},
executor_states={"executor1": {"state": "active", "config": {"timeout": 30, "retries": 3}}},
)
# Save and load
await storage.save_checkpoint(checkpoint)
loaded = await storage.load_checkpoint(checkpoint.checkpoint_id)
assert loaded is not None
assert loaded.messages == checkpoint.messages
assert loaded.shared_state == checkpoint.shared_state
assert loaded.executor_states == checkpoint.executor_states
# Verify the JSON file is properly formatted
file_path = Path(temp_dir) / f"{checkpoint.checkpoint_id}.json"
with open(file_path) as f: # noqa: ASYNC230
data = json.load(f)
assert data["messages"]["executor1"][0]["data"]["nested"]["value"] == 42
assert data["shared_state"]["list"] == [1, 2, 3]
assert data["shared_state"]["bool"] is True
assert data["shared_state"]["null"] is None
def test_checkpoint_storage_protocol_compliance():
# This test ensures both implementations have all required methods
memory_storage = InMemoryCheckpointStorage()
with tempfile.TemporaryDirectory() as temp_dir:
file_storage = FileCheckpointStorage(temp_dir)
for storage in [memory_storage, file_storage]:
# Test that all protocol methods exist and are callable
assert hasattr(storage, "save_checkpoint")
assert callable(storage.save_checkpoint)
assert hasattr(storage, "load_checkpoint")
assert callable(storage.load_checkpoint)
assert hasattr(storage, "list_checkpoint_ids")
assert callable(storage.list_checkpoint_ids)
assert hasattr(storage, "list_checkpoints")
assert callable(storage.list_checkpoints)
assert hasattr(storage, "delete_checkpoint")
assert callable(storage.delete_checkpoint)
@@ -0,0 +1,50 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from typing import Any, cast
from agent_framework._workflow._executor import RequestInfoMessage, RequestResponse
from agent_framework._workflow._runner_context import _decode_checkpoint_value, _encode_checkpoint_value # type: ignore
from agent_framework._workflow._typing_utils import is_instance_of
@dataclass(kw_only=True)
class SampleRequest(RequestInfoMessage):
prompt: str
def test_decode_dataclass_with_nested_request() -> None:
original = RequestResponse[SampleRequest, str](
data="approve",
original_request=SampleRequest(request_id="abc", prompt="prompt"),
request_id="abc",
)
encoded = _encode_checkpoint_value(original)
decoded = cast(RequestResponse[SampleRequest, str], _decode_checkpoint_value(encoded))
assert isinstance(decoded, RequestResponse)
assert decoded.data == "approve"
assert decoded.request_id == "abc"
assert isinstance(decoded.original_request, SampleRequest)
assert decoded.original_request.prompt == "prompt"
def test_is_instance_of_coerces_request_response_original_request_dict() -> None:
response = RequestResponse[SampleRequest, str](
data="approve",
original_request=SampleRequest(request_id="req-1", prompt="prompt"),
request_id="req-1",
)
# Simulate checkpoint decode fallback leaving a dict
response.original_request = cast(
Any,
{
"request_id": "req-1",
"prompt": "prompt",
},
)
assert is_instance_of(response, RequestResponse[SampleRequest, str])
assert isinstance(response.original_request, SampleRequest)
@@ -0,0 +1,74 @@
# Copyright (c) Microsoft. All rights reserved.
import pytest
from typing_extensions import Never
from agent_framework import WorkflowBuilder, WorkflowContext, WorkflowRunState, WorkflowStatusEvent, handler
from agent_framework._workflow._checkpoint import InMemoryCheckpointStorage
from agent_framework._workflow._executor import Executor
class StartExecutor(Executor):
@handler
async def run(self, message: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(message, target_id="finish")
class FinishExecutor(Executor):
@handler
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(message)
def build_workflow(storage: InMemoryCheckpointStorage, finish_id: str = "finish"):
start = StartExecutor(id="start")
finish = FinishExecutor(id=finish_id)
builder = WorkflowBuilder(max_iterations=3).set_start_executor(start).add_edge(start, finish)
builder = builder.with_checkpointing(checkpoint_storage=storage)
return builder.build()
async def test_resume_fails_when_graph_mismatch() -> None:
storage = InMemoryCheckpointStorage()
workflow = build_workflow(storage, finish_id="finish")
# Run once to create checkpoints
_ = [event async for event in workflow.run_stream("hello")] # noqa: F841
checkpoints = await storage.list_checkpoints()
assert checkpoints, "expected at least one checkpoint to be created"
target_checkpoint = checkpoints[-1]
# Build a structurally different workflow (different finish executor id)
mismatched_workflow = build_workflow(storage, finish_id="finish_alt")
with pytest.raises(ValueError, match="Workflow graph has changed"):
_ = [
event
async for event in mismatched_workflow.run_stream_from_checkpoint(
target_checkpoint.checkpoint_id,
checkpoint_storage=storage,
)
]
async def test_resume_succeeds_when_graph_matches() -> None:
storage = InMemoryCheckpointStorage()
workflow = build_workflow(storage, finish_id="finish")
_ = [event async for event in workflow.run_stream("hello")] # noqa: F841
checkpoints = sorted(await storage.list_checkpoints(), key=lambda c: c.timestamp)
target_checkpoint = checkpoints[0]
resumed_workflow = build_workflow(storage, finish_id="finish")
events = [
event
async for event in resumed_workflow.run_stream_from_checkpoint(
target_checkpoint.checkpoint_id,
checkpoint_storage=storage,
)
]
assert any(isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE for event in events)
@@ -0,0 +1,209 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any, cast
import pytest
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentRunResponse,
ChatMessage,
ConcurrentBuilder,
Executor,
Role,
WorkflowContext,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
)
from agent_framework._workflow._checkpoint import InMemoryCheckpointStorage
class _FakeAgentExec(Executor):
"""Test executor that mimics an agent by emitting an AgentExecutorResponse.
It takes the incoming AgentExecutorRequest, produces a single assistant message
with the configured reply text, and sends an AgentExecutorResponse that includes
full_conversation (the original user prompt followed by the assistant message).
"""
def __init__(self, id: str, reply_text: str) -> None:
super().__init__(id)
self._reply_text = reply_text
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
response = AgentRunResponse(messages=ChatMessage(Role.ASSISTANT, text=self._reply_text))
full_conversation = list(request.messages) + list(response.messages)
await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation))
def test_concurrent_builder_rejects_empty_participants() -> None:
with pytest.raises(ValueError):
ConcurrentBuilder().participants([])
def test_concurrent_builder_rejects_duplicate_executors() -> None:
a = _FakeAgentExec("dup", "A")
b = _FakeAgentExec("dup", "B") # same executor id
with pytest.raises(ValueError):
ConcurrentBuilder().participants([a, b])
async def test_concurrent_default_aggregator_emits_single_user_and_assistants() -> None:
# Three synthetic agent executors
e1 = _FakeAgentExec("agentA", "Alpha")
e2 = _FakeAgentExec("agentB", "Beta")
e3 = _FakeAgentExec("agentC", "Gamma")
wf = ConcurrentBuilder().participants([e1, e2, e3]).build()
completed = False
output: list[ChatMessage] | None = None
async for ev in wf.run_stream("prompt: hello world"):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
output = cast(list[ChatMessage], ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
messages: list[ChatMessage] = output
# Expect one user message + one assistant message per participant
assert len(messages) == 1 + 3
assert messages[0].role == Role.USER
assert "hello world" in messages[0].text
assistant_texts = {m.text for m in messages[1:]}
assert assistant_texts == {"Alpha", "Beta", "Gamma"}
assert all(m.role == Role.ASSISTANT for m in messages[1:])
async def test_concurrent_custom_aggregator_callback_is_used() -> None:
# Two synthetic agent executors for brevity
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
async def summarize(results: list[AgentExecutorResponse]) -> str:
texts: list[str] = []
for r in results:
msgs: list[ChatMessage] = r.agent_run_response.messages
texts.append(msgs[-1].text if msgs else "")
return " | ".join(sorted(texts))
wf = ConcurrentBuilder().participants([e1, e2]).with_aggregator(summarize).build()
completed = False
output: str | None = None
async for ev in wf.run_stream("prompt: custom"):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
output = cast(str, ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
# Custom aggregator returns a string payload
assert isinstance(output, str)
assert output == "One | Two"
async def test_concurrent_custom_aggregator_sync_callback_is_used() -> None:
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
# Sync callback with ctx parameter (should run via asyncio.to_thread)
def summarize_sync(results: list[AgentExecutorResponse], _ctx: WorkflowContext[Any]) -> str: # type: ignore[unused-argument]
texts: list[str] = []
for r in results:
msgs: list[ChatMessage] = r.agent_run_response.messages
texts.append(msgs[-1].text if msgs else "")
return " | ".join(sorted(texts))
wf = ConcurrentBuilder().participants([e1, e2]).with_aggregator(summarize_sync).build()
completed = False
output: str | None = None
async for ev in wf.run_stream("prompt: custom sync"):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
output = cast(str, ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
assert isinstance(output, str)
assert output == "One | Two"
def test_concurrent_custom_aggregator_uses_callback_name_for_id() -> None:
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
def summarize(results: list[AgentExecutorResponse]) -> str: # type: ignore[override]
return str(len(results))
wf = ConcurrentBuilder().participants([e1, e2]).with_aggregator(summarize).build()
assert "summarize" in wf.executors
aggregator = wf.executors["summarize"]
assert aggregator.id == "summarize"
async def test_concurrent_checkpoint_resume_round_trip() -> None:
storage = InMemoryCheckpointStorage()
participants = (
_FakeAgentExec("agentA", "Alpha"),
_FakeAgentExec("agentB", "Beta"),
_FakeAgentExec("agentC", "Gamma"),
)
wf = ConcurrentBuilder().participants(list(participants)).with_checkpointing(storage).build()
baseline_output: list[ChatMessage] | None = None
async for ev in wf.run_stream("checkpoint concurrent"):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
)
resumed_participants = (
_FakeAgentExec("agentA", "Alpha"),
_FakeAgentExec("agentB", "Beta"),
_FakeAgentExec("agentC", "Gamma"),
)
wf_resume = ConcurrentBuilder().participants(list(resumed_participants)).with_checkpointing(storage).build()
resumed_output: list[ChatMessage] | None = None
async for ev in wf_resume.run_stream_from_checkpoint(resume_checkpoint.checkpoint_id):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_output is not None
assert [m.role for m in resumed_output] == [m.role for m in baseline_output]
assert [m.text for m in resumed_output] == [m.text for m in baseline_output]

Some files were not shown because too many files have changed in this diff Show More