mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Python: Azure Functions feature branch (#1916)
* Python: Add Scaffolding for Durable AzureFunctions package to Agent Framework (#1823) * Add scafolding * update readme * add code owners and label * update owners * .NET: Durable extension: initial src and unit tests (#1900) * Python: Add Durable Agent Wrapper code (#1913) * add initial changes * Move code and add single sample * Update logger * Remove unused code * address PR comments * cleanup code and address comments --------- Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> * Azure Functions .NET samples (#1939) * Python: Add Unit tests for Azurefunctions package (#1976) * Add Unit tests for Azurefunctions * remove duplicate import * .NET: [Feature Branch] Migrate state schema updates and support for agents as MCP tools (#1979) * Python: Add more samples for Azure Functions (#1980) * Move all samples * fix comments * remove dead lines * Make samples simpler * .NET: [Feature Branch] Durable Task extension integration tests (#2017) * .NET: [Feature Branch] Update OpenAI config for integration tests (#2063) * Python: Add Integration tests for AzureFunctions (#2020) * Add Integration tests * Remove DTS extension * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add pyi file for type safety * Add samples in readme * Updated all readme instructions * Address comments * Update readmes * Fix requirements * Address comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * .NET: [Feature Branch] Update dotnet-build-and-test.yml to support integration tests (#2070) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix DTS startup issue and improve logging (#2103) * .NET: [Feature Branch] Introduce Azure OpenAI config for .NET pipeline (#2106) Also fixes an issue where we were trying to start docker containers for integration tests on Windows, which doesn't work. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix uv.lock after merge * Python: Add README for Azure Functions samples setup (#2100) * Add README for Azure Functions samples setup Added setup instructions for Azure Functions samples, including environment setup, virtual environment creation, and running samples. * Update python/samples/getting_started/azure_functions/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Laveesh Rohra <larohra@microsoft.com> * Fix or remove broken markdown file links (#2115) * .NET: [Feature Branch] Update HTTP API to be consistent across languages (#2118) * Python: Fix AzureFunctions Integration Tests (#2116) * Add Identity Auth to samples * Update python/samples/getting_started/azure_functions/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/samples/getting_started/azure_functions/01_single_agent/function_app.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/samples/getting_started/azure_functions/02_multi_agent/function_app.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Python: Fix Http Schema (#2112) * Rename to threadid * Respond in plain text * Make snake-case * Add http prefix * rename to wait-for-response * Add query param check * address comments * .NET: Remove IsPackable=false in preparation for nuget release (#2142) * Python: Move `azurefunctions` to `azure` for import (#2141) * Move import to Azure * fix mypy * Update python/packages/azurefunctions/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add missing types * Address comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azurefunctions/pyproject.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/azurefunctions/agent_framework_azurefunctions/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix imports * Address PR feedback from westey-m (#2150) - Adds a link from the /dotnet/samples/README.md to /dotnet/samples/AzureFunctions - Make DurableAgentThread deserialization internal for future-proofing - Update JSON serialization logic to address recently discovered issues with source generator serialization * Address comments (#2160) --------- Co-authored-by: Laveesh Rohra <larohra@microsoft.com> Co-authored-by: Chris Gillum <cgillum@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Anirudh Garg <anirudhg@microsoft.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
5537b1da79
commit
67a8147151
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,28 @@
|
||||
# Get Started with Microsoft Agent Framework Durable Functions
|
||||
|
||||
[](https://pypi.org/project/agent-framework-azurefunctions/)
|
||||
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-azurefunctions --pre
|
||||
```
|
||||
|
||||
## Durable Agent Extension
|
||||
|
||||
The durable agent extension lets you host Microsoft Agent Framework agents on Azure Durable Functions so they can persist state, replay conversation history, and recover from failures automatically.
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the durable functions integration sample in the repository to learn how to:
|
||||
|
||||
```python
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
|
||||
_app = AgentFunctionApp()
|
||||
```
|
||||
|
||||
- Register agents with `AgentFunctionApp`
|
||||
- Post messages using the generated `/api/agents/{agent_name}/run` endpoint
|
||||
|
||||
For more details, review the Python [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) and the samples directory.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._app import AgentFunctionApp
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._orchestration import DurableAIAgent
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"AgentCallbackContext",
|
||||
"AgentFunctionApp",
|
||||
"AgentResponseCallbackProtocol",
|
||||
"DurableAIAgent",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,802 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AgentFunctionApp - Main application class.
|
||||
|
||||
This module provides the AgentFunctionApp class that integrates Microsoft Agent Framework
|
||||
with Azure Durable Entities, enabling stateful and durable AI agent execution.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework import AgentProtocol, get_logger
|
||||
|
||||
from ._callbacks import AgentResponseCallbackProtocol
|
||||
from ._entities import create_agent_entity
|
||||
from ._errors import IncomingRequestError
|
||||
from ._models import AgentSessionId, RunRequest
|
||||
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
|
||||
from ._state import AgentState
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions")
|
||||
|
||||
THREAD_ID_FIELD: str = "thread_id"
|
||||
RESPONSE_FORMAT_JSON: str = "json"
|
||||
RESPONSE_FORMAT_TEXT: str = "text"
|
||||
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
|
||||
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
|
||||
|
||||
|
||||
EntityHandler = Callable[[df.DurableEntityContext], None]
|
||||
HandlerT = TypeVar("HandlerT", bound=Callable[..., Any])
|
||||
|
||||
DEFAULT_MAX_POLL_RETRIES: int = 30
|
||||
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class DFAppBase:
|
||||
def __init__(self, http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION) -> None: ...
|
||||
|
||||
def function_name(self, name: str) -> Callable[[HandlerT], HandlerT]: ...
|
||||
|
||||
def route(self, route: str, methods: list[str]) -> Callable[[HandlerT], HandlerT]: ...
|
||||
|
||||
def durable_client_input(self, client_name: str) -> Callable[[HandlerT], HandlerT]: ...
|
||||
|
||||
def entity_trigger(self, context_name: str, entity_name: str) -> Callable[[EntityHandler], EntityHandler]: ...
|
||||
|
||||
def orchestration_trigger(self, context_name: str) -> Callable[[HandlerT], HandlerT]: ...
|
||||
|
||||
def activity_trigger(self, input_name: str) -> Callable[[HandlerT], HandlerT]: ...
|
||||
|
||||
else:
|
||||
DFAppBase = df.DFApp # type: ignore[assignment]
|
||||
|
||||
|
||||
class AgentFunctionApp(DFAppBase):
|
||||
"""Main application class for creating durable agent function apps using Durable Entities.
|
||||
|
||||
This class uses Durable Entities pattern for agent execution, providing:
|
||||
|
||||
- Stateful agent conversations
|
||||
- Conversation history management
|
||||
- Signal-based operation invocation
|
||||
- Better state management than orchestrations
|
||||
|
||||
Example:
|
||||
-------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
|
||||
# Create agents with unique names
|
||||
weather_agent = AzureOpenAIChatClient(...).create_agent(
|
||||
name="WeatherAgent",
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
math_agent = AzureOpenAIChatClient(...).create_agent(
|
||||
name="MathAgent",
|
||||
instructions="You are a helpful math assistant.",
|
||||
tools=[calculate],
|
||||
)
|
||||
|
||||
# Option 1: Pass list of agents during initialization
|
||||
app = AgentFunctionApp(agents=[weather_agent, math_agent])
|
||||
|
||||
# Option 2: Add agents after initialization
|
||||
app = AgentFunctionApp()
|
||||
app.add_agent(weather_agent)
|
||||
app.add_agent(math_agent)
|
||||
|
||||
|
||||
@app.orchestration_trigger(context_name="context")
|
||||
def my_orchestration(context):
|
||||
writer = app.get_agent(context, "WeatherAgent")
|
||||
thread = writer.get_new_thread()
|
||||
forecast_task = writer.run("What's the forecast?", thread=thread)
|
||||
forecast = yield forecast_task
|
||||
return forecast
|
||||
|
||||
This creates:
|
||||
|
||||
- HTTP trigger endpoint for each agent's requests (if enabled)
|
||||
- Durable entity for each agent's state management and execution
|
||||
- Full access to all Azure Functions capabilities
|
||||
|
||||
Attributes:
|
||||
agents: Dictionary of agent name to AgentProtocol instance
|
||||
enable_health_check: Whether health check endpoint is enabled
|
||||
enable_http_endpoints: Whether HTTP endpoints are created for agents
|
||||
max_poll_retries: Maximum polling attempts when waiting for responses
|
||||
poll_interval_seconds: Delay (seconds) between polling attempts
|
||||
"""
|
||||
|
||||
agents: dict[str, AgentProtocol]
|
||||
enable_health_check: bool
|
||||
enable_http_endpoints: bool
|
||||
agent_http_endpoint_flags: dict[str, bool]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agents: list[AgentProtocol] | None = None,
|
||||
http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION,
|
||||
enable_health_check: bool = True,
|
||||
enable_http_endpoints: bool = True,
|
||||
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
|
||||
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
default_callback: AgentResponseCallbackProtocol | None = None,
|
||||
):
|
||||
"""Initialize the AgentFunctionApp.
|
||||
|
||||
:param agents: List of agent instances to register.
|
||||
:param http_auth_level: HTTP authentication level (default: ``func.AuthLevel.FUNCTION``).
|
||||
:param enable_health_check: Enable the built-in health check endpoint (default: ``True``).
|
||||
:param enable_http_endpoints: Enable HTTP endpoints for agents (default: ``True``).
|
||||
:param max_poll_retries: Maximum polling attempts when waiting for a response.
|
||||
Defaults to ``DEFAULT_MAX_POLL_RETRIES``.
|
||||
:param poll_interval_seconds: Delay in seconds between polling attempts.
|
||||
Defaults to ``DEFAULT_POLL_INTERVAL_SECONDS``.
|
||||
:param default_callback: Optional callback invoked for agents without specific callbacks.
|
||||
|
||||
:note: If no agents are provided, they can be added later using :meth:`add_agent`.
|
||||
"""
|
||||
logger.debug("[AgentFunctionApp] Initializing with Durable Entities...")
|
||||
|
||||
# Initialize parent DFApp
|
||||
super().__init__(http_auth_level=http_auth_level)
|
||||
|
||||
# Initialize agents dictionary
|
||||
self.agents = {}
|
||||
self.agent_http_endpoint_flags = {}
|
||||
self.enable_health_check = enable_health_check
|
||||
self.enable_http_endpoints = enable_http_endpoints
|
||||
self.default_callback = default_callback
|
||||
|
||||
try:
|
||||
retries = int(max_poll_retries)
|
||||
except (TypeError, ValueError):
|
||||
retries = DEFAULT_MAX_POLL_RETRIES
|
||||
self.max_poll_retries = max(1, retries)
|
||||
|
||||
try:
|
||||
interval = float(poll_interval_seconds)
|
||||
except (TypeError, ValueError):
|
||||
interval = DEFAULT_POLL_INTERVAL_SECONDS
|
||||
self.poll_interval_seconds = interval if interval > 0 else DEFAULT_POLL_INTERVAL_SECONDS
|
||||
|
||||
if agents:
|
||||
# Register all provided agents
|
||||
logger.debug(f"[AgentFunctionApp] Registering {len(agents)} agent(s)")
|
||||
for agent_instance in agents:
|
||||
self.add_agent(agent_instance)
|
||||
|
||||
# Setup health check if enabled
|
||||
if self.enable_health_check:
|
||||
self._setup_health_route()
|
||||
|
||||
logger.debug("[AgentFunctionApp] Initialization complete")
|
||||
|
||||
def add_agent(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
enable_http_endpoint: bool | None = None,
|
||||
) -> None:
|
||||
"""Add an agent to the function app after initialization.
|
||||
|
||||
Args:
|
||||
agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol)
|
||||
The agent must have a 'name' attribute.
|
||||
callback: Optional callback invoked during agent execution
|
||||
enable_http_endpoint: Optional flag that overrides the app-level
|
||||
HTTP endpoint setting for this agent
|
||||
|
||||
Raises:
|
||||
ValueError: If the agent doesn't have a 'name' attribute or if an agent
|
||||
with the same name is already registered
|
||||
"""
|
||||
# Get agent name from the agent's name attribute
|
||||
name = getattr(agent, "name", None)
|
||||
if name is None:
|
||||
raise ValueError("Agent does not have a 'name' attribute. All agents must have a 'name' attribute.")
|
||||
|
||||
if name in self.agents:
|
||||
raise ValueError(f"Agent with name '{name}' is already registered. Each agent must have a unique name.")
|
||||
|
||||
effective_enable_http_endpoint = (
|
||||
self.enable_http_endpoints if enable_http_endpoint is None else self._coerce_to_bool(enable_http_endpoint)
|
||||
)
|
||||
|
||||
logger.debug(f"[AgentFunctionApp] Adding agent: {name}")
|
||||
logger.debug(f"[AgentFunctionApp] Route: /api/agents/{name}")
|
||||
logger.debug(
|
||||
"[AgentFunctionApp] HTTP endpoint %s for agent '%s'",
|
||||
"enabled" if effective_enable_http_endpoint else "disabled",
|
||||
name,
|
||||
)
|
||||
|
||||
self.agents[name] = agent
|
||||
self.agent_http_endpoint_flags[name] = effective_enable_http_endpoint
|
||||
|
||||
effective_callback = callback or self.default_callback
|
||||
|
||||
self._setup_agent_functions(
|
||||
agent,
|
||||
name,
|
||||
effective_callback,
|
||||
effective_enable_http_endpoint,
|
||||
)
|
||||
|
||||
logger.debug(f"[AgentFunctionApp] Agent '{name}' added successfully")
|
||||
|
||||
def get_agent(
|
||||
self,
|
||||
context: AgentOrchestrationContextType,
|
||||
agent_name: str,
|
||||
) -> DurableAIAgent:
|
||||
"""Return a DurableAIAgent proxy for a registered agent.
|
||||
|
||||
Args:
|
||||
context: Durable Functions orchestration context invoking the agent.
|
||||
agent_name: Name of the agent registered on this app.
|
||||
|
||||
Raises:
|
||||
ValueError: If the requested agent has not been registered.
|
||||
|
||||
Returns:
|
||||
DurableAIAgent wrapper bound to the orchestration context.
|
||||
"""
|
||||
normalized_name = str(agent_name)
|
||||
|
||||
if normalized_name not in self.agents:
|
||||
raise ValueError(f"Agent '{normalized_name}' is not registered with this app.")
|
||||
|
||||
return DurableAIAgent(context, normalized_name)
|
||||
|
||||
def _setup_agent_functions(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
agent_name: str,
|
||||
callback: AgentResponseCallbackProtocol | None,
|
||||
enable_http_endpoint: bool,
|
||||
) -> None:
|
||||
"""Set up the HTTP trigger and entity for a specific agent.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
agent_name: The name to use for routing and entity registration
|
||||
callback: Optional callback to receive response updates
|
||||
enable_http_endpoint: Whether the HTTP run route is enabled for
|
||||
this agent
|
||||
"""
|
||||
logger.debug(f"[AgentFunctionApp] Setting up functions for agent '{agent_name}'...")
|
||||
|
||||
if enable_http_endpoint:
|
||||
self._setup_http_run_route(agent_name)
|
||||
else:
|
||||
logger.debug(
|
||||
"[AgentFunctionApp] HTTP run route disabled for agent '%s'",
|
||||
agent_name,
|
||||
)
|
||||
self._setup_agent_entity(agent, agent_name, callback)
|
||||
|
||||
def _setup_http_run_route(self, agent_name: str) -> None:
|
||||
"""Register the POST route that triggers agent execution.
|
||||
|
||||
Args:
|
||||
agent_name: The agent name (used for both routing and entity identification)
|
||||
"""
|
||||
run_function_name = self._build_function_name(agent_name, "http")
|
||||
|
||||
function_name_decorator = self.function_name(run_function_name)
|
||||
route_decorator = self.route(route=f"agents/{agent_name}/run", methods=["POST"])
|
||||
durable_client_decorator = self.durable_client_input(client_name="client")
|
||||
|
||||
@function_name_decorator
|
||||
@route_decorator
|
||||
@durable_client_decorator
|
||||
async def http_start(req: func.HttpRequest, client: df.DurableOrchestrationClient) -> func.HttpResponse:
|
||||
"""HTTP trigger that calls a durable entity to execute the agent and returns the result.
|
||||
|
||||
Expected request body (RunRequest format):
|
||||
{
|
||||
"message": "user message to agent",
|
||||
"thread_id": "optional conversation identifier",
|
||||
"role": "user|system" (optional, default: "user"),
|
||||
"response_format": {...} (optional JSON schema for structured responses),
|
||||
"enable_tool_calls": true|false (optional, default: true)
|
||||
}
|
||||
"""
|
||||
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
|
||||
|
||||
response_format: str = RESPONSE_FORMAT_JSON
|
||||
thread_id: str | None = None
|
||||
|
||||
try:
|
||||
req_body, message, response_format = self._parse_incoming_request(req)
|
||||
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
|
||||
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Message: {message}")
|
||||
logger.debug(f"[HTTP Trigger] Thread ID: {thread_id}")
|
||||
logger.debug(f"[HTTP Trigger] wait_for_response: {wait_for_response}")
|
||||
|
||||
if not message:
|
||||
logger.warning("[HTTP Trigger] Request rejected: Missing message")
|
||||
return self._create_http_response(
|
||||
payload={"error": "Message is required"},
|
||||
status_code=400,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
session_id = self._create_session_id(agent_name, thread_id)
|
||||
correlation_id = self._generate_unique_id()
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Using session ID: {session_id}")
|
||||
logger.debug(f"[HTTP Trigger] Generated correlation ID: {correlation_id}")
|
||||
logger.debug("[HTTP Trigger] Calling entity to run agent...")
|
||||
|
||||
entity_instance_id = session_id.to_entity_id()
|
||||
run_request = self._build_request_data(
|
||||
req_body,
|
||||
message,
|
||||
thread_id,
|
||||
correlation_id,
|
||||
)
|
||||
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
|
||||
await client.signal_entity(entity_instance_id, "run_agent", run_request)
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Signal sent to entity {session_id}")
|
||||
|
||||
if wait_for_response:
|
||||
result = await self._get_response_from_entity(
|
||||
client=client,
|
||||
entity_instance_id=entity_instance_id,
|
||||
correlation_id=correlation_id,
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Result status: {result.get('status', 'unknown')}")
|
||||
return self._create_http_response(
|
||||
payload=result,
|
||||
status_code=200 if result.get("status") == "success" else 500,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
logger.debug("[HTTP Trigger] wait_for_response disabled; returning correlation ID")
|
||||
|
||||
accepted_response = self._build_accepted_response(
|
||||
message=message, thread_id=thread_id, correlation_id=correlation_id
|
||||
)
|
||||
|
||||
return self._create_http_response(
|
||||
payload=accepted_response,
|
||||
status_code=202,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
except IncomingRequestError as exc:
|
||||
logger.warning(f"[HTTP Trigger] Request rejected: {exc!s}")
|
||||
return self._create_http_response(
|
||||
payload={"error": str(exc)},
|
||||
status_code=exc.status_code,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.error(f"[HTTP Trigger] Invalid JSON: {exc!s}")
|
||||
return self._create_http_response(
|
||||
payload={"error": "Invalid JSON"},
|
||||
status_code=400,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[HTTP Trigger] Error: {exc!s}", exc_info=True)
|
||||
return self._create_http_response(
|
||||
payload={"error": str(exc)},
|
||||
status_code=500,
|
||||
response_format=response_format,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
_ = http_start
|
||||
|
||||
def _setup_agent_entity(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
agent_name: str,
|
||||
callback: AgentResponseCallbackProtocol | None,
|
||||
) -> None:
|
||||
"""Register the durable entity responsible for agent state.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
agent_name: The agent name (used for both entity identification and function naming)
|
||||
callback: Optional callback for response updates
|
||||
"""
|
||||
# Use the prefixed entity name for both registration and function naming
|
||||
entity_name_with_prefix = AgentSessionId.to_entity_name(agent_name)
|
||||
|
||||
def entity_function(context: df.DurableEntityContext) -> None:
|
||||
"""Durable entity that manages agent execution and conversation state.
|
||||
|
||||
Operations:
|
||||
- run_agent: Execute the agent with a message
|
||||
- reset: Clear conversation history
|
||||
"""
|
||||
entity_handler = create_agent_entity(agent, callback)
|
||||
entity_handler(context)
|
||||
|
||||
# Set function name for Azure Functions (used in function.json generation)
|
||||
# Use the prefixed entity name as the function name too.
|
||||
entity_function.__name__ = entity_name_with_prefix
|
||||
self.entity_trigger(context_name="context", entity_name=entity_name_with_prefix)(entity_function)
|
||||
|
||||
def _setup_health_route(self) -> None:
|
||||
"""Register the optional health check route."""
|
||||
health_route = self.route(route="health", methods=["GET"])
|
||||
|
||||
@health_route
|
||||
def health_check(req: func.HttpRequest) -> func.HttpResponse:
|
||||
"""Built-in health check endpoint."""
|
||||
agent_info = [
|
||||
{
|
||||
"name": name,
|
||||
"type": type(agent).__name__,
|
||||
"http_endpoint_enabled": self.agent_http_endpoint_flags.get(
|
||||
name,
|
||||
self.enable_http_endpoints,
|
||||
),
|
||||
}
|
||||
for name, agent in self.agents.items()
|
||||
]
|
||||
return func.HttpResponse(
|
||||
json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
_ = health_check
|
||||
|
||||
@staticmethod
|
||||
def _build_function_name(agent_name: str, prefix: str) -> str:
|
||||
"""Generate the sanitized function name in the form "{prefix}-{sanitized_agent_name}".
|
||||
|
||||
Example: agent_name="Weather Agent" and prefix="http" becomes "http-Weather_Agent".
|
||||
"""
|
||||
sanitized_agent = re.sub(r"[^0-9a-zA-Z_]", "_", agent_name or "agent").strip("_")
|
||||
|
||||
if not sanitized_agent:
|
||||
sanitized_agent = "agent"
|
||||
|
||||
if sanitized_agent[0].isdigit():
|
||||
sanitized_agent = f"agent_{sanitized_agent}"
|
||||
|
||||
return f"{prefix}-{sanitized_agent}"
|
||||
|
||||
async def _read_cached_state(
|
||||
self,
|
||||
client: df.DurableOrchestrationClient,
|
||||
entity_instance_id: df.EntityId,
|
||||
) -> AgentState | None:
|
||||
state_response = await client.read_entity_state(entity_instance_id)
|
||||
if not state_response or not state_response.entity_exists:
|
||||
return None
|
||||
|
||||
state_payload = state_response.entity_state
|
||||
if not isinstance(state_payload, dict):
|
||||
return None
|
||||
|
||||
typed_state_payload = cast(dict[str, Any], state_payload)
|
||||
|
||||
agent_state = AgentState()
|
||||
agent_state.restore_state(typed_state_payload)
|
||||
return agent_state
|
||||
|
||||
async def _get_response_from_entity(
|
||||
self,
|
||||
client: df.DurableOrchestrationClient,
|
||||
entity_instance_id: df.EntityId,
|
||||
correlation_id: str,
|
||||
message: str,
|
||||
thread_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Poll the entity state until a response is available or timeout occurs."""
|
||||
import asyncio
|
||||
|
||||
max_retries = self.max_poll_retries
|
||||
interval = self.poll_interval_seconds
|
||||
retry_count = 0
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Waiting for response with correlation ID: {correlation_id}")
|
||||
|
||||
while retry_count < max_retries:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
result = await self._poll_entity_for_response(
|
||||
client=client,
|
||||
entity_instance_id=entity_instance_id,
|
||||
correlation_id=correlation_id,
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
if result is not None:
|
||||
break
|
||||
|
||||
logger.debug(f"[HTTP Trigger] Response not available yet (retry {retry_count})")
|
||||
retry_count += 1
|
||||
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
logger.warning(
|
||||
f"[HTTP Trigger] Response with correlation ID {correlation_id} "
|
||||
f"not found in time (waited {max_retries * interval} seconds)"
|
||||
)
|
||||
return await self._build_timeout_result(message=message, thread_id=thread_id, correlation_id=correlation_id)
|
||||
|
||||
async def _poll_entity_for_response(
|
||||
self,
|
||||
client: df.DurableOrchestrationClient,
|
||||
entity_instance_id: df.EntityId,
|
||||
correlation_id: str,
|
||||
message: str,
|
||||
thread_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
result: dict[str, Any] | None = None
|
||||
try:
|
||||
state = await self._read_cached_state(client, entity_instance_id)
|
||||
|
||||
if state is None:
|
||||
return None
|
||||
|
||||
agent_response = state.try_get_agent_response(correlation_id)
|
||||
if agent_response:
|
||||
result = self._build_success_result(
|
||||
response_data=agent_response,
|
||||
message=message,
|
||||
thread_id=thread_id,
|
||||
correlation_id=correlation_id,
|
||||
state=state,
|
||||
)
|
||||
logger.debug(f"[HTTP Trigger] Found response for correlation ID: {correlation_id}")
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"[HTTP Trigger] Error reading entity state: {exc}")
|
||||
|
||||
return result
|
||||
|
||||
async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
||||
"""Create the timeout response."""
|
||||
return {
|
||||
"response": "Agent is still processing or timed out...",
|
||||
"message": message,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "timeout",
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
def _build_success_result(
|
||||
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: AgentState
|
||||
) -> dict[str, Any]:
|
||||
"""Build the success result returned to the HTTP caller."""
|
||||
return {
|
||||
"response": response_data.get("content"),
|
||||
"message": message,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "success",
|
||||
"message_count": response_data.get("message_count", state.message_count),
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
def _build_request_data(
|
||||
self, req_body: dict[str, Any], message: str, thread_id: str, correlation_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Create the durable entity request payload."""
|
||||
enable_tool_calls_value = req_body.get("enable_tool_calls")
|
||||
enable_tool_calls = True if enable_tool_calls_value is None else self._coerce_to_bool(enable_tool_calls_value)
|
||||
|
||||
return RunRequest(
|
||||
message=message,
|
||||
role=req_body.get("role"),
|
||||
response_format=req_body.get("response_format"),
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
thread_id=thread_id,
|
||||
correlation_id=correlation_id,
|
||||
).to_dict()
|
||||
|
||||
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
|
||||
"""Build the response returned when not waiting for completion."""
|
||||
return {
|
||||
"response": "Agent request accepted",
|
||||
"message": message,
|
||||
THREAD_ID_FIELD: thread_id,
|
||||
"status": "accepted",
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
def _create_http_response(
|
||||
self,
|
||||
payload: dict[str, Any] | str,
|
||||
status_code: int,
|
||||
response_format: str,
|
||||
thread_id: str | None,
|
||||
) -> func.HttpResponse:
|
||||
"""Create the HTTP response using helper serializers for clarity."""
|
||||
if response_format == RESPONSE_FORMAT_TEXT:
|
||||
return self._build_plain_text_response(payload=payload, status_code=status_code, thread_id=thread_id)
|
||||
|
||||
return self._build_json_response(payload=payload, status_code=status_code)
|
||||
|
||||
def _build_plain_text_response(
|
||||
self,
|
||||
payload: dict[str, Any] | str,
|
||||
status_code: int,
|
||||
thread_id: str | None,
|
||||
) -> func.HttpResponse:
|
||||
"""Return a plain-text response with optional thread identifier header."""
|
||||
body_text = payload if isinstance(payload, str) else self._convert_payload_to_text(payload)
|
||||
headers = {"x-ms-thread-id": thread_id} if thread_id is not None else None
|
||||
return func.HttpResponse(body_text, status_code=status_code, mimetype="text/plain", headers=headers)
|
||||
|
||||
def _build_json_response(self, payload: dict[str, Any] | str, status_code: int) -> func.HttpResponse:
|
||||
"""Return the JSON response, serializing dictionaries as needed."""
|
||||
body_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
return func.HttpResponse(body_json, status_code=status_code, mimetype="application/json")
|
||||
|
||||
def _convert_payload_to_text(self, payload: dict[str, Any]) -> str:
|
||||
"""Convert a structured payload into a human-readable text response."""
|
||||
for key in ("response", "error", "message"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return json.dumps(payload)
|
||||
|
||||
def _generate_unique_id(self) -> str:
|
||||
"""Generate a new unique identifier."""
|
||||
import uuid
|
||||
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def _create_session_id(self, func_name: str, thread_id: str | None) -> AgentSessionId:
|
||||
"""Create a session identifier using the provided thread id or a random value."""
|
||||
if thread_id:
|
||||
return AgentSessionId(name=func_name, key=thread_id)
|
||||
return AgentSessionId.with_random_key(name=func_name)
|
||||
|
||||
def _resolve_thread_id(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str:
|
||||
"""Retrieve the thread identifier from request body or query parameters."""
|
||||
params = req.params or {}
|
||||
|
||||
if THREAD_ID_FIELD in req_body:
|
||||
value = req_body.get(THREAD_ID_FIELD)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
|
||||
if THREAD_ID_FIELD in params:
|
||||
value = params.get(THREAD_ID_FIELD)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
|
||||
logger.debug("[HTTP Trigger] No thread identifier provided; using random thread id")
|
||||
return self._generate_unique_id()
|
||||
|
||||
def _parse_incoming_request(self, req: func.HttpRequest) -> tuple[dict[str, Any], str, str]:
|
||||
"""Parse the incoming run request supporting JSON and plain text bodies."""
|
||||
headers = self._extract_normalized_headers(req)
|
||||
|
||||
normalized_content_type = self._extract_content_type(headers)
|
||||
body_parser, body_format = self._select_body_parser(normalized_content_type)
|
||||
prefers_json = self._accepts_json_response(headers)
|
||||
response_format = self._select_response_format(body_format=body_format, prefers_json=prefers_json)
|
||||
|
||||
req_body, message = body_parser(req)
|
||||
return req_body, message, response_format
|
||||
|
||||
def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]:
|
||||
"""Create a lowercase header mapping from the incoming request."""
|
||||
headers: dict[str, str] = {}
|
||||
raw_headers = req.headers
|
||||
if isinstance(raw_headers, Mapping):
|
||||
header_mapping: Mapping[str, Any] = cast(Mapping[str, Any], raw_headers)
|
||||
for key, value in header_mapping.items():
|
||||
if value is not None:
|
||||
headers[str(key).lower()] = str(value)
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _extract_content_type(headers: dict[str, str]) -> str:
|
||||
"""Return the normalized content-type value (without parameters)."""
|
||||
content_type_header = headers.get("content-type", "")
|
||||
return content_type_header.split(";")[0].strip().lower() if content_type_header else ""
|
||||
|
||||
def _select_body_parser(
|
||||
self,
|
||||
normalized_content_type: str,
|
||||
) -> tuple[Callable[[func.HttpRequest], tuple[dict[str, Any], str]], str]:
|
||||
"""Choose the body parser and declared body format."""
|
||||
if normalized_content_type in {"application/json"} or normalized_content_type.endswith("+json"):
|
||||
return self._parse_json_body, RESPONSE_FORMAT_JSON
|
||||
return self._parse_text_body, RESPONSE_FORMAT_TEXT
|
||||
|
||||
@staticmethod
|
||||
def _accepts_json_response(headers: dict[str, str]) -> bool:
|
||||
"""Check whether the caller explicitly requests a JSON response."""
|
||||
accept_header = headers.get("accept")
|
||||
if not accept_header:
|
||||
return False
|
||||
|
||||
for value in accept_header.split(","):
|
||||
media_type = value.split(";")[0].strip().lower()
|
||||
if media_type == "application/json":
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _select_response_format(body_format: str, prefers_json: bool) -> str:
|
||||
"""Combine body format and accept preference to determine response format."""
|
||||
if body_format == RESPONSE_FORMAT_JSON or prefers_json:
|
||||
return RESPONSE_FORMAT_JSON
|
||||
return RESPONSE_FORMAT_TEXT
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]:
|
||||
req_body = req.get_json()
|
||||
if not isinstance(req_body, dict):
|
||||
raise IncomingRequestError("Invalid JSON payload. Expected an object.")
|
||||
|
||||
typed_req_body = cast(dict[str, Any], req_body)
|
||||
message_value = typed_req_body.get("message", "")
|
||||
message = message_value if isinstance(message_value, str) else str(message_value)
|
||||
return typed_req_body, message
|
||||
|
||||
@staticmethod
|
||||
def _parse_text_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]:
|
||||
body_bytes = req.get_body()
|
||||
text_body = body_bytes.decode("utf-8", errors="replace") if body_bytes else ""
|
||||
message = text_body.strip()
|
||||
|
||||
return {}, message
|
||||
|
||||
def _should_wait_for_response(self, req: func.HttpRequest, req_body: dict[str, Any]) -> bool:
|
||||
"""Determine whether the caller requested to wait for the response."""
|
||||
headers: dict[str, str] = self._extract_normalized_headers(req)
|
||||
header_value: str | None = headers.get(WAIT_FOR_RESPONSE_HEADER)
|
||||
|
||||
if header_value is not None:
|
||||
return self._coerce_to_bool(header_value)
|
||||
|
||||
params = req.params or {}
|
||||
if WAIT_FOR_RESPONSE_FIELD in params:
|
||||
return self._coerce_to_bool(params.get(WAIT_FOR_RESPONSE_FIELD))
|
||||
|
||||
if WAIT_FOR_RESPONSE_FIELD in req_body:
|
||||
return self._coerce_to_bool(req_body.get(WAIT_FOR_RESPONSE_FIELD))
|
||||
|
||||
return True
|
||||
|
||||
def _coerce_to_bool(self, value: Any) -> bool:
|
||||
"""Convert various representations into a boolean flag."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"true", "1", "yes", "y", "on"}
|
||||
return False
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Callback interfaces for Durable Agent executions.
|
||||
|
||||
This module enables callers of AgentFunctionApp to supply streaming and final-response callbacks that are
|
||||
invoked during durable entity execution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentCallbackContext:
|
||||
"""Context supplied to callback invocations."""
|
||||
|
||||
agent_name: str
|
||||
correlation_id: str
|
||||
thread_id: str | None = None
|
||||
request_message: str | None = None
|
||||
|
||||
|
||||
class AgentResponseCallbackProtocol(Protocol):
|
||||
"""Protocol describing the callbacks invoked during agent execution."""
|
||||
|
||||
async def on_streaming_response_update(
|
||||
self,
|
||||
update: AgentRunResponseUpdate,
|
||||
context: AgentCallbackContext,
|
||||
) -> None:
|
||||
"""Handle a streaming response update emitted by the agent."""
|
||||
|
||||
async def on_agent_response(
|
||||
self,
|
||||
response: AgentRunResponse,
|
||||
context: AgentCallbackContext,
|
||||
) -> None:
|
||||
"""Handle the final agent response."""
|
||||
@@ -0,0 +1,427 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Durable Entity for Agent Execution.
|
||||
|
||||
This module defines a durable entity that manages agent state and execution.
|
||||
Using entities instead of orchestrations provides better state management and
|
||||
allows for long-running agent conversations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, Role, get_logger
|
||||
|
||||
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
|
||||
from ._models import AgentResponse, RunRequest
|
||||
from ._state import AgentState
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.entities")
|
||||
|
||||
|
||||
class AgentEntity:
|
||||
"""Durable entity that manages agent execution and conversation state.
|
||||
|
||||
This entity:
|
||||
- Maintains conversation history
|
||||
- Executes agent with messages
|
||||
- Stores agent responses
|
||||
- Handles tool execution
|
||||
|
||||
Operations:
|
||||
- run_agent: Execute the agent with a message
|
||||
- reset: Clear conversation history
|
||||
|
||||
Attributes:
|
||||
agent: The AgentProtocol instance
|
||||
state: The AgentState managing conversation history
|
||||
"""
|
||||
|
||||
agent: AgentProtocol
|
||||
state: AgentState
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: AgentProtocol,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
):
|
||||
"""Initialize the agent entity.
|
||||
|
||||
Args:
|
||||
agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol)
|
||||
callback: Optional callback invoked during streaming updates and final responses
|
||||
"""
|
||||
self.agent = agent
|
||||
self.state = AgentState()
|
||||
self.callback = callback
|
||||
|
||||
logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}")
|
||||
|
||||
async def run_agent(
|
||||
self,
|
||||
context: df.DurableEntityContext,
|
||||
request: RunRequest | dict[str, Any] | str,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute the agent with a message directly in the entity.
|
||||
|
||||
Args:
|
||||
context: Entity context
|
||||
request: RunRequest object, dict, or string message (for backward compatibility)
|
||||
|
||||
Returns:
|
||||
Dict with status information and response (serialized AgentResponse)
|
||||
|
||||
Note:
|
||||
The agent returns an AgentRunResponse object which is stored in state.
|
||||
This method extracts the text/structured response and returns an AgentResponse dict.
|
||||
"""
|
||||
# Convert string or dict to RunRequest
|
||||
if isinstance(request, str):
|
||||
run_request = RunRequest(message=request, role=Role.USER)
|
||||
elif isinstance(request, dict):
|
||||
run_request = RunRequest.from_dict(request)
|
||||
else:
|
||||
run_request = request
|
||||
|
||||
message = run_request.message
|
||||
thread_id = run_request.thread_id
|
||||
correlation_id = run_request.correlation_id
|
||||
if not thread_id:
|
||||
raise ValueError("RunRequest must include a thread_id")
|
||||
if not correlation_id:
|
||||
raise ValueError("RunRequest must include a correlation_id")
|
||||
role = run_request.role or Role.USER
|
||||
response_format = run_request.response_format
|
||||
enable_tool_calls = run_request.enable_tool_calls
|
||||
|
||||
logger.debug(f"[AgentEntity.run_agent] Received message: {message}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Thread ID: {thread_id}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Correlation ID: {correlation_id}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Role: {role.value}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Enable tool calls: {enable_tool_calls}")
|
||||
logger.debug(f"[AgentEntity.run_agent] Response format: {'provided' if response_format else 'none'}")
|
||||
|
||||
# Store message in history with role
|
||||
self.state.add_user_message(message, role=role, correlation_id=correlation_id)
|
||||
|
||||
logger.debug("[AgentEntity.run_agent] Executing agent...")
|
||||
|
||||
try:
|
||||
logger.debug("[AgentEntity.run_agent] Starting agent invocation")
|
||||
|
||||
run_kwargs: dict[str, Any] = {"messages": self.state.get_chat_messages()}
|
||||
if not enable_tool_calls:
|
||||
run_kwargs["tools"] = None
|
||||
if response_format:
|
||||
run_kwargs["response_format"] = response_format
|
||||
|
||||
agent_run_response: AgentRunResponse = await self._invoke_agent(
|
||||
run_kwargs=run_kwargs,
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=message,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"[AgentEntity.run_agent] Agent invocation completed - response type: %s",
|
||||
type(agent_run_response).__name__,
|
||||
)
|
||||
|
||||
response_text = None
|
||||
structured_response = None
|
||||
|
||||
response_str: str | None = None
|
||||
try:
|
||||
if response_format:
|
||||
try:
|
||||
response_str = agent_run_response.text
|
||||
structured_response = json.loads(response_str)
|
||||
logger.debug("Parsed structured JSON response")
|
||||
except json.JSONDecodeError as decode_error:
|
||||
logger.warning(f"Failed to parse JSON response: {decode_error}")
|
||||
response_text = response_str
|
||||
else:
|
||||
raw_text = agent_run_response.text
|
||||
response_text = raw_text if raw_text else "No response"
|
||||
preview = response_text
|
||||
logger.debug(f"Response: {preview[:100]}..." if len(preview) > 100 else f"Response: {preview}")
|
||||
except Exception as extraction_error:
|
||||
logger.error(
|
||||
f"Error extracting response: {extraction_error}",
|
||||
exc_info=True,
|
||||
)
|
||||
response_text = "Error extracting response"
|
||||
|
||||
agent_response = AgentResponse(
|
||||
response=response_text,
|
||||
message=str(message),
|
||||
thread_id=str(thread_id),
|
||||
status="success",
|
||||
message_count=self.state.message_count,
|
||||
structured_response=structured_response,
|
||||
)
|
||||
result = agent_response.to_dict()
|
||||
|
||||
content = json.dumps(structured_response) if structured_response else (response_text or "")
|
||||
self.state.add_assistant_message(content, agent_run_response, correlation_id)
|
||||
logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
|
||||
error_traceback = traceback.format_exc()
|
||||
logger.error("[AgentEntity.run_agent] Agent execution failed")
|
||||
logger.error(f"Error: {exc!s}")
|
||||
logger.error(f"Error type: {type(exc).__name__}")
|
||||
logger.error(f"Full traceback:\n{error_traceback}")
|
||||
|
||||
error_response = AgentResponse(
|
||||
response=f"Error: {exc!s}",
|
||||
message=str(message),
|
||||
thread_id=str(thread_id),
|
||||
status="error",
|
||||
message_count=self.state.message_count,
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return error_response.to_dict()
|
||||
|
||||
async def _invoke_agent(
|
||||
self,
|
||||
run_kwargs: dict[str, Any],
|
||||
correlation_id: str,
|
||||
thread_id: str,
|
||||
request_message: str,
|
||||
) -> AgentRunResponse:
|
||||
"""Execute the agent, preferring streaming when available."""
|
||||
callback_context: AgentCallbackContext | None = None
|
||||
if self.callback is not None:
|
||||
callback_context = self._build_callback_context(
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=request_message,
|
||||
)
|
||||
|
||||
run_stream_callable = getattr(self.agent, "run_stream", None)
|
||||
if callable(run_stream_callable):
|
||||
try:
|
||||
stream_candidate = run_stream_callable(**run_kwargs)
|
||||
if inspect.isawaitable(stream_candidate):
|
||||
stream_candidate = await stream_candidate
|
||||
|
||||
return await self._consume_stream(
|
||||
stream=cast(AsyncIterable[AgentRunResponseUpdate], stream_candidate),
|
||||
callback_context=callback_context,
|
||||
)
|
||||
except TypeError as type_error:
|
||||
if "__aiter__" not in str(type_error):
|
||||
raise
|
||||
logger.debug(
|
||||
"run_stream returned a non-async result; falling back to run(): %s",
|
||||
type_error,
|
||||
)
|
||||
except Exception as stream_error:
|
||||
logger.warning(
|
||||
"run_stream failed; falling back to run(): %s",
|
||||
stream_error,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.debug("Agent does not expose run_stream; falling back to run().")
|
||||
|
||||
agent_run_response = await self._invoke_non_stream(run_kwargs)
|
||||
await self._notify_final_response(agent_run_response, callback_context)
|
||||
return agent_run_response
|
||||
|
||||
async def _consume_stream(
|
||||
self,
|
||||
stream: AsyncIterable[AgentRunResponseUpdate],
|
||||
callback_context: AgentCallbackContext | None = None,
|
||||
) -> AgentRunResponse:
|
||||
"""Consume streaming responses and build the final AgentRunResponse."""
|
||||
updates: list[AgentRunResponseUpdate] = []
|
||||
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
await self._notify_stream_update(update, callback_context)
|
||||
|
||||
if updates:
|
||||
response = AgentRunResponse.from_agent_run_response_updates(updates)
|
||||
else:
|
||||
logger.debug("[AgentEntity] No streaming updates received; creating empty response")
|
||||
response = AgentRunResponse(messages=[])
|
||||
|
||||
await self._notify_final_response(response, callback_context)
|
||||
return response
|
||||
|
||||
async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentRunResponse:
|
||||
"""Invoke the agent without streaming support."""
|
||||
run_callable = getattr(self.agent, "run", None)
|
||||
if run_callable is None or not callable(run_callable):
|
||||
raise AttributeError("Agent does not implement run() method")
|
||||
|
||||
result = run_callable(**run_kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
|
||||
if not isinstance(result, AgentRunResponse):
|
||||
raise TypeError(f"Agent run() must return an AgentRunResponse instance; received {type(result).__name__}")
|
||||
|
||||
return result
|
||||
|
||||
async def _notify_stream_update(
|
||||
self,
|
||||
update: AgentRunResponseUpdate,
|
||||
context: AgentCallbackContext | None,
|
||||
) -> None:
|
||||
"""Invoke the streaming callback if one is registered."""
|
||||
if self.callback is None or context is None:
|
||||
return
|
||||
|
||||
try:
|
||||
callback_result = self.callback.on_streaming_response_update(update, context)
|
||||
if inspect.isawaitable(callback_result):
|
||||
await callback_result
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[AgentEntity] Streaming callback raised an exception: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _notify_final_response(
|
||||
self,
|
||||
response: AgentRunResponse,
|
||||
context: AgentCallbackContext | None,
|
||||
) -> None:
|
||||
"""Invoke the final response callback if one is registered."""
|
||||
if self.callback is None or context is None:
|
||||
return
|
||||
|
||||
try:
|
||||
callback_result = self.callback.on_agent_response(response, context)
|
||||
if inspect.isawaitable(callback_result):
|
||||
await callback_result
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[AgentEntity] Response callback raised an exception: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _build_callback_context(
|
||||
self,
|
||||
correlation_id: str,
|
||||
thread_id: str,
|
||||
request_message: str,
|
||||
) -> AgentCallbackContext:
|
||||
"""Create the callback context provided to consumers."""
|
||||
agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__
|
||||
return AgentCallbackContext(
|
||||
agent_name=agent_name,
|
||||
correlation_id=correlation_id,
|
||||
thread_id=thread_id,
|
||||
request_message=request_message,
|
||||
)
|
||||
|
||||
def reset(self, context: df.DurableEntityContext) -> None:
|
||||
"""Reset the entity state (clear conversation history)."""
|
||||
logger.debug("[AgentEntity.reset] Resetting entity state")
|
||||
self.state.reset()
|
||||
logger.debug("[AgentEntity.reset] State reset complete")
|
||||
|
||||
|
||||
def create_agent_entity(
|
||||
agent: AgentProtocol,
|
||||
callback: AgentResponseCallbackProtocol | None = None,
|
||||
) -> Callable[[df.DurableEntityContext], None]:
|
||||
"""Factory function to create an agent entity class.
|
||||
|
||||
Args:
|
||||
agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol)
|
||||
callback: Optional callback invoked during streaming and final responses
|
||||
|
||||
Returns:
|
||||
Entity function configured with the agent
|
||||
"""
|
||||
|
||||
async def _entity_coroutine(context: df.DurableEntityContext) -> None:
|
||||
"""Async handler that executes the entity operations."""
|
||||
try:
|
||||
logger.debug("[entity_function] Entity triggered")
|
||||
logger.debug(f"[entity_function] Operation: {context.operation_name}")
|
||||
|
||||
current_state = context.get_state(lambda: None)
|
||||
logger.debug("Retrieved state: %s", str(current_state)[:100])
|
||||
entity = AgentEntity(agent, callback)
|
||||
|
||||
if current_state is not None:
|
||||
entity.state.restore_state(current_state)
|
||||
logger.debug(
|
||||
"[entity_function] Restored entity from state (message_count: %s)", entity.state.message_count
|
||||
)
|
||||
else:
|
||||
logger.debug("[entity_function] Created new entity instance")
|
||||
|
||||
operation = context.operation_name
|
||||
|
||||
if operation == "run_agent":
|
||||
input_data: Any = context.get_input()
|
||||
|
||||
request: str | dict[str, Any]
|
||||
if isinstance(input_data, dict) and "message" in input_data:
|
||||
request = cast(dict[str, Any], input_data)
|
||||
else:
|
||||
# Fall back to treating input as message string
|
||||
request = "" if input_data is None else str(cast(object, input_data))
|
||||
|
||||
result = await entity.run_agent(context, request)
|
||||
context.set_result(result)
|
||||
|
||||
elif operation == "reset":
|
||||
entity.reset(context)
|
||||
context.set_result({"status": "reset"})
|
||||
|
||||
else:
|
||||
logger.error("[entity_function] Unknown operation: %s", operation)
|
||||
context.set_result({"error": f"Unknown operation: {operation}"})
|
||||
|
||||
context.set_state(entity.state.to_dict())
|
||||
logger.debug(f"[entity_function] Operation {operation} completed successfully")
|
||||
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
|
||||
logger.error("[entity_function] Error in entity: %s", exc)
|
||||
logger.error(f"[entity_function] Traceback:\n{traceback.format_exc()}")
|
||||
context.set_result({"error": str(exc), "status": "error"})
|
||||
|
||||
def entity_function(context: df.DurableEntityContext) -> None:
|
||||
"""Synchronous wrapper invoked by the Durable Functions runtime."""
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
if loop.is_running():
|
||||
temp_loop = asyncio.new_event_loop()
|
||||
try:
|
||||
temp_loop.run_until_complete(_entity_coroutine(context))
|
||||
finally:
|
||||
temp_loop.close()
|
||||
else:
|
||||
loop.run_until_complete(_entity_coroutine(context))
|
||||
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("[entity_function] Unexpected error executing entity: %s", exc, exc_info=True)
|
||||
context.set_result({"error": str(exc), "status": "error"})
|
||||
|
||||
return entity_function
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Custom exception types for the durable agent framework."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class IncomingRequestError(ValueError):
|
||||
"""Raised when an incoming HTTP request cannot be parsed or validated."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
@@ -0,0 +1,395 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Data models for Durable Agent Framework.
|
||||
|
||||
This module defines the request and response models used by the framework.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import uuid
|
||||
from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
from agent_framework import AgentThread, Role
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type checking imports only
|
||||
from pydantic import BaseModel
|
||||
|
||||
_PydanticBaseModel: type[BaseModel] | None
|
||||
|
||||
try:
|
||||
from pydantic import BaseModel as _RuntimeBaseModel
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
_PydanticBaseModel = None
|
||||
else:
|
||||
_PydanticBaseModel = _RuntimeBaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSessionId:
|
||||
"""Represents an agent session ID, which is used to identify a long-running agent session.
|
||||
|
||||
Attributes:
|
||||
name: The name of the agent that owns the session (case-insensitive)
|
||||
key: The unique key of the agent session (case-sensitive)
|
||||
"""
|
||||
|
||||
name: str
|
||||
key: str
|
||||
|
||||
ENTITY_NAME_PREFIX: str = "dafx-"
|
||||
|
||||
@staticmethod
|
||||
def to_entity_name(name: str) -> str:
|
||||
"""Converts an agent name to an entity name by adding the DAFx prefix.
|
||||
|
||||
Args:
|
||||
name: The agent name
|
||||
|
||||
Returns:
|
||||
The entity name with the dafx- prefix
|
||||
"""
|
||||
return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}"
|
||||
|
||||
@staticmethod
|
||||
def with_random_key(name: str) -> AgentSessionId:
|
||||
"""Creates a new AgentSessionId with the specified name and a randomly generated key.
|
||||
|
||||
Args:
|
||||
name: The name of the agent that owns the session
|
||||
|
||||
Returns:
|
||||
A new AgentSessionId with the specified name and a random GUID key
|
||||
"""
|
||||
return AgentSessionId(name=name, key=uuid.uuid4().hex)
|
||||
|
||||
def to_entity_id(self) -> df.EntityId:
|
||||
"""Converts this AgentSessionId to a Durable Functions EntityId.
|
||||
|
||||
Returns:
|
||||
EntityId for use with Durable Functions APIs
|
||||
"""
|
||||
return df.EntityId(self.to_entity_name(self.name), self.key)
|
||||
|
||||
@staticmethod
|
||||
def from_entity_id(entity_id: df.EntityId) -> AgentSessionId:
|
||||
"""Creates an AgentSessionId from a Durable Functions EntityId.
|
||||
|
||||
Args:
|
||||
entity_id: The EntityId to convert
|
||||
|
||||
Returns:
|
||||
AgentSessionId instance
|
||||
|
||||
Raises:
|
||||
ValueError: If the entity ID does not have the expected prefix
|
||||
"""
|
||||
if not entity_id.name.startswith(AgentSessionId.ENTITY_NAME_PREFIX):
|
||||
raise ValueError(
|
||||
f"'{entity_id}' is not a valid agent session ID. "
|
||||
f"Expected entity name to start with '{AgentSessionId.ENTITY_NAME_PREFIX}'"
|
||||
)
|
||||
|
||||
agent_name = entity_id.name[len(AgentSessionId.ENTITY_NAME_PREFIX) :]
|
||||
return AgentSessionId(name=agent_name, key=entity_id.key)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns a string representation in the form @name@key."""
|
||||
return f"@{self.name}@{self.key}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Returns a detailed string representation."""
|
||||
return f"AgentSessionId(name='{self.name}', key='{self.key}')"
|
||||
|
||||
@staticmethod
|
||||
def parse(session_id_string: str) -> AgentSessionId:
|
||||
"""Parses a string representation of an agent session ID.
|
||||
|
||||
Args:
|
||||
session_id_string: A string in the form @name@key
|
||||
|
||||
Returns:
|
||||
AgentSessionId instance
|
||||
|
||||
Raises:
|
||||
ValueError: If the string format is invalid
|
||||
"""
|
||||
if not session_id_string.startswith("@"):
|
||||
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
|
||||
|
||||
parts = session_id_string[1:].split("@", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
|
||||
|
||||
return AgentSessionId(name=parts[0], key=parts[1])
|
||||
|
||||
|
||||
class DurableAgentThread(AgentThread):
|
||||
"""Durable agent thread that tracks the owning :class:`AgentSessionId`."""
|
||||
|
||||
_SERIALIZED_SESSION_ID_KEY = "durable_session_id"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_id: AgentSessionId | None = None,
|
||||
service_thread_id: str | None = None,
|
||||
message_store: Any = None,
|
||||
context_provider: Any = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
service_thread_id=service_thread_id,
|
||||
message_store=message_store,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
self._session_id: AgentSessionId | None = session_id
|
||||
|
||||
@property
|
||||
def session_id(self) -> AgentSessionId | None:
|
||||
"""Returns the durable agent session identifier for this thread."""
|
||||
return self._session_id
|
||||
|
||||
def attach_session(self, session_id: AgentSessionId) -> None:
|
||||
"""Associates the thread with the provided :class:`AgentSessionId`."""
|
||||
self._session_id = session_id
|
||||
|
||||
@classmethod
|
||||
def from_session_id(
|
||||
cls,
|
||||
session_id: AgentSessionId,
|
||||
*,
|
||||
service_thread_id: str | None = None,
|
||||
message_store: Any = None,
|
||||
context_provider: Any = None,
|
||||
) -> DurableAgentThread:
|
||||
"""Creates a durable thread pre-associated with the supplied session ID."""
|
||||
return cls(
|
||||
session_id=session_id,
|
||||
service_thread_id=service_thread_id,
|
||||
message_store=message_store,
|
||||
context_provider=context_provider,
|
||||
)
|
||||
|
||||
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Serializes thread state including the durable session identifier."""
|
||||
state = await super().serialize(**kwargs)
|
||||
if self._session_id is not None:
|
||||
state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
async def deserialize(
|
||||
cls,
|
||||
serialized_thread_state: MutableMapping[str, Any],
|
||||
*,
|
||||
message_store: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> DurableAgentThread:
|
||||
"""Restores a durable thread, rehydrating the stored session identifier."""
|
||||
state_payload = dict(serialized_thread_state)
|
||||
session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None)
|
||||
thread = await super().deserialize(
|
||||
state_payload,
|
||||
message_store=message_store,
|
||||
**kwargs,
|
||||
)
|
||||
if not isinstance(thread, DurableAgentThread):
|
||||
raise TypeError("Deserialized thread is not a DurableAgentThread instance")
|
||||
|
||||
if session_id_value is None:
|
||||
return thread
|
||||
|
||||
if not isinstance(session_id_value, str):
|
||||
raise ValueError("durable_session_id must be a string when present in serialized state")
|
||||
|
||||
thread.attach_session(AgentSessionId.parse(session_id_value))
|
||||
return thread
|
||||
|
||||
|
||||
def _serialize_response_format(response_format: type[BaseModel] | None) -> Any:
|
||||
"""Serialize response format for transport across durable function boundaries."""
|
||||
if response_format is None:
|
||||
return None
|
||||
|
||||
if _PydanticBaseModel is None:
|
||||
raise RuntimeError("pydantic is required to use structured response formats")
|
||||
|
||||
if not inspect.isclass(response_format) or not issubclass(response_format, _PydanticBaseModel):
|
||||
raise TypeError("response_format must be a Pydantic BaseModel type")
|
||||
|
||||
return {
|
||||
"__response_schema_type__": "pydantic_model",
|
||||
"module": response_format.__module__,
|
||||
"qualname": response_format.__qualname__,
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_response_format(response_format: Any) -> type[BaseModel] | None:
|
||||
"""Deserialize response format back into actionable type if possible."""
|
||||
if response_format is None:
|
||||
return None
|
||||
|
||||
if (
|
||||
_PydanticBaseModel is not None
|
||||
and inspect.isclass(response_format)
|
||||
and issubclass(response_format, _PydanticBaseModel)
|
||||
):
|
||||
return response_format
|
||||
|
||||
if not isinstance(response_format, dict):
|
||||
return None
|
||||
|
||||
response_dict = cast(dict[str, Any], response_format)
|
||||
|
||||
if response_dict.get("__response_schema_type__") != "pydantic_model":
|
||||
return None
|
||||
|
||||
module_name = response_dict.get("module")
|
||||
qualname = response_dict.get("qualname")
|
||||
if not module_name or not qualname:
|
||||
return None
|
||||
|
||||
try:
|
||||
module = import_module(module_name)
|
||||
except ImportError: # pragma: no cover - user provided module missing
|
||||
return None
|
||||
|
||||
attr: Any = module
|
||||
for part in qualname.split("."):
|
||||
try:
|
||||
attr = getattr(attr, part)
|
||||
except AttributeError: # pragma: no cover - invalid qualname
|
||||
return None
|
||||
|
||||
if _PydanticBaseModel is not None and inspect.isclass(attr) and issubclass(attr, _PydanticBaseModel):
|
||||
return attr
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRequest:
|
||||
"""Represents a request to run an agent with a specific message and configuration.
|
||||
|
||||
Attributes:
|
||||
message: The message to send to the agent
|
||||
role: The role of the message sender (user, system, or assistant)
|
||||
response_format: Optional Pydantic BaseModel type describing the structured response format
|
||||
enable_tool_calls: Whether to enable tool calls for this request
|
||||
thread_id: Optional thread ID for tracking
|
||||
correlation_id: Optional correlation ID for tracking the response to this specific request
|
||||
"""
|
||||
|
||||
message: str
|
||||
role: Role = Role.USER
|
||||
response_format: type[BaseModel] | None = None
|
||||
enable_tool_calls: bool = True
|
||||
thread_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
role: Role | str | None = Role.USER,
|
||||
response_format: type[BaseModel] | None = None,
|
||||
enable_tool_calls: bool = True,
|
||||
thread_id: str | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
self.message = message
|
||||
self.role = self.coerce_role(role)
|
||||
self.response_format = response_format
|
||||
self.enable_tool_calls = enable_tool_calls
|
||||
self.thread_id = thread_id
|
||||
self.correlation_id = correlation_id
|
||||
|
||||
@staticmethod
|
||||
def coerce_role(value: Role | str | None) -> Role:
|
||||
"""Normalize various role representations into a Role instance."""
|
||||
if isinstance(value, Role):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return Role.USER
|
||||
return Role(value=normalized.lower())
|
||||
return Role.USER
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = {
|
||||
"message": self.message,
|
||||
"enable_tool_calls": self.enable_tool_calls,
|
||||
"role": self.role.value,
|
||||
}
|
||||
if self.response_format:
|
||||
result["response_format"] = _serialize_response_format(self.response_format)
|
||||
if self.thread_id:
|
||||
result["thread_id"] = self.thread_id
|
||||
if self.correlation_id:
|
||||
result["correlation_id"] = self.correlation_id
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
|
||||
"""Create RunRequest from dictionary."""
|
||||
return cls(
|
||||
message=data.get("message", ""),
|
||||
role=cls.coerce_role(data.get("role")),
|
||||
response_format=_deserialize_response_format(data.get("response_format")),
|
||||
enable_tool_calls=data.get("enable_tool_calls", True),
|
||||
thread_id=data.get("thread_id"),
|
||||
correlation_id=data.get("correlation_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResponse:
|
||||
"""Response from agent execution.
|
||||
|
||||
Attributes:
|
||||
response: The agent's text response (or None for structured responses)
|
||||
message: The original message sent to the agent
|
||||
thread_id: The thread identifier
|
||||
status: Status of the execution (success, error, etc.)
|
||||
message_count: Number of messages in the conversation
|
||||
error: Error message if status is error
|
||||
error_type: Type of error if status is error
|
||||
structured_response: Structured response if response_format was provided
|
||||
"""
|
||||
|
||||
response: str | None
|
||||
message: str
|
||||
thread_id: str | None
|
||||
status: str
|
||||
message_count: int = 0
|
||||
error: str | None = None
|
||||
error_type: str | None = None
|
||||
structured_response: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result: dict[str, Any] = {
|
||||
"message": self.message,
|
||||
"thread_id": self.thread_id,
|
||||
"status": self.status,
|
||||
"message_count": self.message_count,
|
||||
}
|
||||
|
||||
# Add response or structured_response based on what's available
|
||||
if self.structured_response is not None:
|
||||
result["structured_response"] = self.structured_response
|
||||
elif self.response is not None:
|
||||
result["response"] = self.response
|
||||
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
if self.error_type:
|
||||
result["error_type"] = self.error_type
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,211 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Orchestration Support for Durable Agents.
|
||||
|
||||
This module provides support for using agents inside Durable Function orchestrations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
||||
|
||||
from agent_framework import AgentProtocol, AgentRunResponseUpdate, AgentThread, ChatMessage, get_logger
|
||||
|
||||
from ._models import AgentSessionId, DurableAgentThread, RunRequest
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.orchestration")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.durable_functions import DurableOrchestrationContext as _DurableOrchestrationContext
|
||||
|
||||
AgentOrchestrationContextType: TypeAlias = _DurableOrchestrationContext
|
||||
else:
|
||||
AgentOrchestrationContextType = Any
|
||||
|
||||
|
||||
class DurableAIAgent(AgentProtocol):
|
||||
"""A durable agent implementation that uses entity methods to interact with agent entities.
|
||||
|
||||
This class implements AgentProtocol and provides methods to work with Azure Durable Functions
|
||||
orchestrations, which use generators and yield instead of async/await.
|
||||
|
||||
Key methods:
|
||||
- get_new_thread(): Create a new conversation thread
|
||||
- run(): Execute the agent and return a Task for yielding in orchestrations
|
||||
|
||||
Note: The run() method is NOT async. It returns a Task directly that must be
|
||||
yielded in orchestrations to wait for the entity call to complete.
|
||||
|
||||
Example usage in orchestration:
|
||||
writer = app.get_agent(context, "WriterAgent")
|
||||
thread = writer.get_new_thread() # NOT yielded - returns immediately
|
||||
|
||||
response = yield writer.run( # Yielded - waits for entity call
|
||||
message="Write a haiku about coding",
|
||||
thread=thread
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, context: AgentOrchestrationContextType, agent_name: str):
|
||||
"""Initialize the DurableAIAgent.
|
||||
|
||||
Args:
|
||||
context: The orchestration context
|
||||
agent_name: Name of the agent (used to construct entity ID)
|
||||
"""
|
||||
self.context = context
|
||||
self.agent_name = agent_name
|
||||
self._id = str(uuid.uuid4())
|
||||
self._name = agent_name
|
||||
self._display_name = agent_name
|
||||
self._description = f"Durable agent proxy for {agent_name}"
|
||||
logger.debug(f"[DurableAIAgent] Initialized for agent: {agent_name}")
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Get the unique identifier for this agent."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Get the name of the agent."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Get the display name of the agent."""
|
||||
return self._display_name
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Get the description of the agent."""
|
||||
return self._description
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any: # TODO(msft-team): Add a wrapper to respond correctly with `AgentRunResponse`
|
||||
"""Execute the agent with messages and return a Task for orchestrations.
|
||||
|
||||
This method implements AgentProtocol and returns a Task that can be yielded
|
||||
in Durable Functions orchestrations.
|
||||
|
||||
Args:
|
||||
messages: The message(s) to send to the agent
|
||||
thread: Optional agent thread for conversation context
|
||||
**kwargs: Additional arguments (enable_tool_calls, response_format, etc.)
|
||||
|
||||
Returns:
|
||||
Task that will resolve to the agent response
|
||||
|
||||
Example:
|
||||
@app.orchestration_trigger(context_name="context")
|
||||
def my_orchestration(context):
|
||||
agent = app.get_agent(context, "MyAgent")
|
||||
thread = agent.get_new_thread()
|
||||
result = yield agent.run("Hello", thread=thread)
|
||||
"""
|
||||
message_str = self._normalize_messages(messages)
|
||||
|
||||
# Extract optional parameters from kwargs
|
||||
enable_tool_calls = kwargs.get("enable_tool_calls", True)
|
||||
response_format = kwargs.get("response_format")
|
||||
|
||||
# Get the session ID for the entity
|
||||
if isinstance(thread, DurableAgentThread) and thread.session_id is not None:
|
||||
session_id = thread.session_id
|
||||
else:
|
||||
# Create a unique session ID for each call when no thread is provided
|
||||
# This ensures each call gets its own conversation context
|
||||
session_key = str(self.context.new_uuid())
|
||||
session_id = AgentSessionId(name=self.agent_name, key=session_key)
|
||||
logger.warning(f"[DurableAIAgent] No thread provided, created unique session_id: {session_id}")
|
||||
|
||||
# Create entity ID from session ID
|
||||
entity_id = session_id.to_entity_id()
|
||||
|
||||
# Generate a deterministic correlation ID for this call
|
||||
# This is required by the entity and must be unique per call
|
||||
correlation_id = str(self.context.new_uuid())
|
||||
|
||||
# Prepare the request using RunRequest model
|
||||
run_request = RunRequest(
|
||||
message=message_str,
|
||||
enable_tool_calls=enable_tool_calls,
|
||||
correlation_id=correlation_id,
|
||||
thread_id=session_id.key,
|
||||
response_format=response_format,
|
||||
)
|
||||
|
||||
logger.debug(f"[DurableAIAgent] Calling entity {entity_id} with message: {message_str[:100]}...")
|
||||
|
||||
# Call the entity and return the Task directly
|
||||
# The orchestration will yield this Task
|
||||
return self.context.call_entity(entity_id, "run_agent", run_request.to_dict())
|
||||
|
||||
def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[AgentRunResponseUpdate]:
|
||||
"""Run the agent with streaming (not supported for durable agents).
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Streaming is not supported for durable agents.
|
||||
"""
|
||||
raise NotImplementedError("Streaming is not supported for durable agents in orchestrations.")
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
"""Create a new agent thread for this orchestration instance.
|
||||
|
||||
Each call creates a unique thread with its own conversation context.
|
||||
The session ID is deterministic (uses context.new_uuid()) to ensure
|
||||
orchestration replay works correctly.
|
||||
|
||||
Returns:
|
||||
A new AgentThread instance with a unique session ID
|
||||
"""
|
||||
# Generate a deterministic unique key for this thread
|
||||
# Using context.new_uuid() ensures the same GUID is generated during replay
|
||||
session_key = str(self.context.new_uuid())
|
||||
|
||||
# Create AgentSessionId with agent name and session key
|
||||
session_id = AgentSessionId(name=self.agent_name, key=session_key)
|
||||
|
||||
thread = DurableAgentThread.from_session_id(session_id, **kwargs)
|
||||
|
||||
logger.debug(f"[DurableAIAgent] Created new thread with session_id: {session_id}")
|
||||
return thread
|
||||
|
||||
def _messages_to_string(self, messages: list[ChatMessage]) -> str:
|
||||
"""Convert a list of ChatMessage objects to a single string.
|
||||
|
||||
Args:
|
||||
messages: List of ChatMessage objects
|
||||
|
||||
Returns:
|
||||
Concatenated string of message contents
|
||||
"""
|
||||
return "\n".join([msg.text or "" for msg in messages])
|
||||
|
||||
def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> str:
|
||||
"""Convert supported message inputs to a single string."""
|
||||
if messages is None:
|
||||
return ""
|
||||
if isinstance(messages, str):
|
||||
return messages
|
||||
if isinstance(messages, ChatMessage):
|
||||
return messages.text or ""
|
||||
if isinstance(messages, list):
|
||||
if not messages:
|
||||
return ""
|
||||
first_item = messages[0]
|
||||
if isinstance(first_item, str):
|
||||
return "\n".join(cast(list[str], messages))
|
||||
return self._messages_to_string(cast(list[ChatMessage], messages))
|
||||
return str(messages)
|
||||
@@ -0,0 +1,179 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent State Management.
|
||||
|
||||
This module defines the AgentState class for managing conversation state and
|
||||
serializing agent framework responses.
|
||||
"""
|
||||
|
||||
from collections.abc import MutableMapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import AgentRunResponse, ChatMessage, Role, get_logger
|
||||
|
||||
logger = get_logger("agent_framework.azurefunctions.state")
|
||||
|
||||
|
||||
class AgentState:
|
||||
"""Manages agent conversation state using agent_framework types (ChatMessage, AgentRunResponse).
|
||||
|
||||
This class handles:
|
||||
- Conversation history tracking using ChatMessage objects
|
||||
- Agent response storage using AgentRunResponse objects with correlation IDs
|
||||
- State persistence and restoration
|
||||
- Message counting
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty agent state."""
|
||||
self.conversation_history: list[ChatMessage] = []
|
||||
self.last_response: str | None = None
|
||||
self.message_count: int = 0
|
||||
|
||||
def _current_timestamp(self) -> str:
|
||||
"""Return an ISO 8601 UTC timestamp."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def add_user_message(
|
||||
self,
|
||||
content: str,
|
||||
role: Role = Role.USER,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
"""Add a user message to the conversation history as a ChatMessage object.
|
||||
|
||||
Args:
|
||||
content: The message content
|
||||
role: The message role (user, system, etc.)
|
||||
correlation_id: Optional correlation identifier associated with the user message
|
||||
"""
|
||||
self.message_count += 1
|
||||
timestamp = self._current_timestamp()
|
||||
additional_props: MutableMapping[str, Any] = {"timestamp": timestamp}
|
||||
if correlation_id is not None:
|
||||
additional_props["correlation_id"] = correlation_id
|
||||
chat_message = ChatMessage(role=role, text=content, additional_properties=additional_props)
|
||||
self.conversation_history.append(chat_message)
|
||||
logger.debug(f"Added {role} ChatMessage to history (message #{self.message_count})")
|
||||
|
||||
def add_assistant_message(
|
||||
self, content: str, agent_response: AgentRunResponse, correlation_id: str | None = None
|
||||
) -> None:
|
||||
"""Add an assistant message to the conversation history with full agent response.
|
||||
|
||||
Args:
|
||||
content: The text content of the response
|
||||
agent_response: The AgentRunResponse object from the agent framework
|
||||
correlation_id: Optional correlation ID for tracking this response
|
||||
"""
|
||||
self.last_response = content
|
||||
timestamp = self._current_timestamp()
|
||||
serialized_response = self.serialize_response(agent_response)
|
||||
|
||||
# Create a ChatMessage for the assistant response
|
||||
# The agent_response already contains messages, but we store it as a custom ChatMessage
|
||||
# with the agent_response stored in additional_properties for full metadata preservation
|
||||
additional_props: dict[str, Any] = {
|
||||
"agent_response": serialized_response,
|
||||
"correlation_id": correlation_id,
|
||||
"timestamp": timestamp,
|
||||
"message_count": self.message_count,
|
||||
}
|
||||
chat_message = ChatMessage(role="assistant", text=content, additional_properties=additional_props)
|
||||
|
||||
self.conversation_history.append(chat_message)
|
||||
|
||||
logger.debug(
|
||||
f"Added assistant ChatMessage to history with AgentRunResponse metadata (correlation_id: {correlation_id})"
|
||||
)
|
||||
|
||||
def get_chat_messages(self) -> list[ChatMessage]:
|
||||
"""Return a copy of the full conversation history."""
|
||||
return list(self.conversation_history)
|
||||
|
||||
def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None:
|
||||
"""Get an agent response by correlation ID.
|
||||
|
||||
Args:
|
||||
correlation_id: The correlation ID to look up
|
||||
|
||||
Returns:
|
||||
The agent response data if found, None otherwise
|
||||
"""
|
||||
for message in reversed(self.conversation_history):
|
||||
metadata = getattr(message, "additional_properties", {}) or {}
|
||||
if metadata.get("correlation_id") == correlation_id:
|
||||
return self._build_agent_response_payload(message, metadata)
|
||||
|
||||
return None
|
||||
|
||||
def serialize_response(self, response: AgentRunResponse) -> dict[str, Any]:
|
||||
"""Serialize an ``AgentRunResponse`` to a dictionary.
|
||||
|
||||
Args:
|
||||
response: The agent framework response object
|
||||
|
||||
Returns:
|
||||
Dictionary containing all response fields
|
||||
"""
|
||||
try:
|
||||
return response.to_dict()
|
||||
except Exception as exc: # pragma: no cover - defensive logging path
|
||||
logger.warning(f"Error serializing response: {exc}")
|
||||
return {"response": str(response), "serialization_error": str(exc)}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Get the current state as a dictionary for persistence.
|
||||
|
||||
Returns:
|
||||
Dictionary containing conversation_history (as serialized ChatMessages),
|
||||
last_response, and message_count
|
||||
"""
|
||||
return {
|
||||
"conversation_history": [msg.to_dict() for msg in self.conversation_history],
|
||||
"last_response": self.last_response,
|
||||
"message_count": self.message_count,
|
||||
}
|
||||
|
||||
def restore_state(self, state: dict[str, Any]) -> None:
|
||||
"""Restore state from a dictionary, reconstructing ChatMessage objects.
|
||||
|
||||
Args:
|
||||
state: Dictionary containing conversation_history, last_response, and message_count
|
||||
"""
|
||||
# Restore conversation history as ChatMessage objects
|
||||
history_data = state.get("conversation_history", [])
|
||||
restored_history: list[ChatMessage] = []
|
||||
for raw_message in history_data:
|
||||
if isinstance(raw_message, dict):
|
||||
restored_history.append(ChatMessage.from_dict(cast(dict[str, Any], raw_message)))
|
||||
else:
|
||||
restored_history.append(cast(ChatMessage, raw_message))
|
||||
|
||||
self.conversation_history = restored_history
|
||||
|
||||
self.last_response = state.get("last_response")
|
||||
self.message_count = state.get("message_count", 0)
|
||||
logger.debug("Restored state: %s ChatMessages in history", len(self.conversation_history))
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the state to empty."""
|
||||
self.conversation_history = []
|
||||
self.last_response = None
|
||||
self.message_count = 0
|
||||
logger.debug("State reset to empty")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the state."""
|
||||
return f"AgentState(messages={self.message_count}, history_length={len(self.conversation_history)})"
|
||||
|
||||
def _build_agent_response_payload(self, message: ChatMessage, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Construct the agent response payload returned to callers."""
|
||||
return {
|
||||
"content": message.text,
|
||||
"agent_response": metadata.get("agent_response"),
|
||||
"message_count": metadata.get("message_count", self.message_count),
|
||||
"timestamp": metadata.get("timestamp"),
|
||||
"correlation_id": metadata.get("correlation_id"),
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
[project]
|
||||
name = "agent-framework-azurefunctions"
|
||||
description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"azure-functions",
|
||||
"azure-functions-durable",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (require running function app)",
|
||||
"orchestration: marks tests that use orchestrations (require Azurite)",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_azurefunctions"]
|
||||
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_azurefunctions"
|
||||
test = "pytest --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,11 @@
|
||||
# Azure OpenAI Configuration
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name
|
||||
FUNCTIONS_WORKER_RUNTIME=python
|
||||
RUN_INTEGRATION_TESTS=true
|
||||
|
||||
# Azure Functions Configuration
|
||||
AzureWebJobsStorage=UseDevelopmentStorage=true
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=http://localhost:8080;Authentication=None
|
||||
|
||||
# Note: TASKHUB_NAME is not required for integration tests; it is auto-generated per test run.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Sample Integration Tests
|
||||
|
||||
Integration tests that validate the Durable Agent Framework samples by running them as Azure Functions.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create `.env` file
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your Azure credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Required variables:
|
||||
- `AZURE_OPENAI_ENDPOINT`
|
||||
- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`
|
||||
- `AZURE_OPENAI_API_KEY`
|
||||
- `AzureWebJobsStorage`
|
||||
- `DURABLE_TASK_SCHEDULER_CONNECTION_STRING`
|
||||
- `FUNCTIONS_WORKER_RUNTIME`
|
||||
|
||||
### 2. Start required services
|
||||
|
||||
**Azurite (for orchestration tests):**
|
||||
```bash
|
||||
docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite
|
||||
```
|
||||
|
||||
**Durable Task Scheduler:**
|
||||
```bash
|
||||
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
The tests automatically start and stop the Azure Functions app for each sample.
|
||||
|
||||
### Run all sample tests
|
||||
```bash
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests -v
|
||||
```
|
||||
|
||||
### Run specific sample
|
||||
```bash
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_01_single_agent.py -v
|
||||
```
|
||||
|
||||
### Run with verbose output
|
||||
```bash
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests -sv
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
Each test file uses pytest markers to automatically configure and start the function app:
|
||||
|
||||
```python
|
||||
pytestmark = [
|
||||
pytest.mark.sample("01_single_agent"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
]
|
||||
```
|
||||
|
||||
The `function_app_for_test` fixture:
|
||||
1. Loads environment variables from `.env`
|
||||
2. Validates required variables are present
|
||||
3. Starts the function app on a dynamically allocated port
|
||||
4. Waits for the app to be ready
|
||||
5. Runs your tests
|
||||
6. Tears down the function app
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
**Missing environment variables:**
|
||||
Ensure your `.env` file contains all required variables from `.env.example`.
|
||||
|
||||
**Tests timeout:**
|
||||
Check that Azure OpenAI credentials are valid and the service is accessible.
|
||||
@@ -0,0 +1 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Pytest configuration for Durable Agent Framework tests.
|
||||
|
||||
This module provides fixtures and configuration for pytest.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from .testutils import (
|
||||
FunctionAppStartupError,
|
||||
build_base_url,
|
||||
cleanup_function_app,
|
||||
find_available_port,
|
||||
get_sample_path_from_marker,
|
||||
load_and_validate_env,
|
||||
start_function_app,
|
||||
wait_for_function_app_ready,
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
"""Register custom markers."""
|
||||
config.addinivalue_line("markers", "orchestration: marks tests that use orchestrations (require Azurite)")
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"sample(path): specify the sample directory path for the test (e.g., @pytest.mark.sample('01_single_agent'))",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def function_app_running() -> bool:
|
||||
"""
|
||||
Check if the function app is running on localhost:7071.
|
||||
|
||||
This fixture can be used to skip tests if the function app is not available.
|
||||
"""
|
||||
try:
|
||||
response = requests.get("http://localhost:7071/api/health", timeout=2)
|
||||
return response.status_code == 200
|
||||
except requests.exceptions.RequestException:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def skip_if_no_function_app(function_app_running: bool) -> None:
|
||||
"""Skip test if function app is not running."""
|
||||
if not function_app_running:
|
||||
pytest.skip("Function app is not running on http://localhost:7071")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def function_app_for_test(request: pytest.FixtureRequest) -> Iterator[dict[str, int | str]]:
|
||||
"""
|
||||
Start the function app for the corresponding sample based on marker.
|
||||
|
||||
This fixture:
|
||||
1. Determines which sample to run from @pytest.mark.sample()
|
||||
2. Validates environment variables
|
||||
3. Starts the function app using 'func start'
|
||||
4. Waits for the app to be ready
|
||||
5. Tears down the app after tests complete
|
||||
|
||||
Usage:
|
||||
@pytest.mark.sample("01_single_agent")
|
||||
@pytest.mark.usefixtures("function_app_for_test")
|
||||
class TestSample01SingleAgent:
|
||||
...
|
||||
"""
|
||||
# Get sample path from marker
|
||||
sample_path, error_message = get_sample_path_from_marker(request)
|
||||
if error_message:
|
||||
pytest.fail(error_message)
|
||||
|
||||
assert sample_path is not None, "Sample path must be resolved before starting the function app"
|
||||
|
||||
# Load .env file if it exists and validate required env vars
|
||||
load_and_validate_env()
|
||||
|
||||
max_attempts = 3
|
||||
last_error: Exception | None = None
|
||||
func_process: subprocess.Popen[Any] | None = None
|
||||
base_url = ""
|
||||
port = 0
|
||||
|
||||
for _ in range(max_attempts):
|
||||
port = find_available_port()
|
||||
base_url = build_base_url(port)
|
||||
func_process = start_function_app(sample_path, port)
|
||||
|
||||
try:
|
||||
wait_for_function_app_ready(func_process, port)
|
||||
last_error = None
|
||||
break
|
||||
except FunctionAppStartupError as exc:
|
||||
last_error = exc
|
||||
cleanup_function_app(func_process)
|
||||
func_process = None
|
||||
|
||||
if func_process is None:
|
||||
error_message = f"Function app failed to start after {max_attempts} attempt(s)."
|
||||
if last_error is not None:
|
||||
error_message += f" Last error: {last_error}"
|
||||
pytest.fail(error_message)
|
||||
|
||||
try:
|
||||
yield {"base_url": base_url, "port": port}
|
||||
finally:
|
||||
if func_process is not None:
|
||||
cleanup_function_app(func_process)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def base_url(function_app_for_test: Mapping[str, int | str]) -> str:
|
||||
"""Expose the function app's base URL to tests."""
|
||||
return str(function_app_for_test["base_url"])
|
||||
@@ -0,0 +1,114 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Integration Tests for Single Agent Sample
|
||||
|
||||
Tests the single agent sample with various message formats and session management.
|
||||
|
||||
The function app is automatically started by the test fixture.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
|
||||
- Azurite or Azure Storage account configured
|
||||
|
||||
Usage:
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_01_single_agent.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.sample("01_single_agent"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
]
|
||||
|
||||
|
||||
class TestSampleSingleAgent:
|
||||
"""Tests for 01_single_agent sample."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_base_url(self, base_url: str) -> None:
|
||||
"""Provide agent-specific base URL for the tests."""
|
||||
self.base_url = f"{base_url}/api/agents/Joker"
|
||||
|
||||
def test_health_check(self, base_url: str) -> None:
|
||||
"""Test health check endpoint."""
|
||||
response = SampleTestHelper.get(f"{base_url}/api/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_simple_message_json(self) -> None:
|
||||
"""Test sending a simple message with JSON payload."""
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Tell me a short joke about cloud computing.", "thread_id": "test-simple-json"},
|
||||
)
|
||||
# Agent can return 200 (immediate) or 202 (async with wait_for_response=false)
|
||||
assert response.status_code in [200, 202]
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
# Synchronous response - check result directly
|
||||
assert data["status"] == "success"
|
||||
assert "response" in data
|
||||
assert data["message_count"] >= 1
|
||||
else:
|
||||
# Async response - check we got correlation info
|
||||
assert "correlation_id" in data or "thread_id" in data
|
||||
|
||||
def test_simple_message_plain_text(self) -> None:
|
||||
"""Test sending a message with plain text payload."""
|
||||
response = SampleTestHelper.post_text(f"{self.base_url}/run", "Tell me a short joke about networking.")
|
||||
assert response.status_code in [200, 202]
|
||||
|
||||
# Agent responded with plain text when the request body was text/plain.
|
||||
assert response.text.strip()
|
||||
assert response.headers.get("x-ms-thread-id") is not None
|
||||
|
||||
def test_thread_id_in_query(self) -> None:
|
||||
"""Test using thread_id in query parameter."""
|
||||
response = SampleTestHelper.post_text(
|
||||
f"{self.base_url}/run?thread_id=test-query-thread", "Tell me a short joke about weather in Texas."
|
||||
)
|
||||
assert response.status_code in [200, 202]
|
||||
|
||||
assert response.text.strip()
|
||||
assert response.headers.get("x-ms-thread-id") == "test-query-thread"
|
||||
|
||||
def test_conversation_continuity(self) -> None:
|
||||
"""Test conversation context is maintained across requests."""
|
||||
thread_id = "test-continuity"
|
||||
|
||||
# First message
|
||||
response1 = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Tell me a short joke about weather in Seattle.", "thread_id": thread_id},
|
||||
)
|
||||
assert response1.status_code in [200, 202]
|
||||
|
||||
if response1.status_code == 200:
|
||||
data1 = response1.json()
|
||||
assert data1["message_count"] == 1
|
||||
|
||||
# Second message in same session
|
||||
response2 = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run", {"message": "What about San Francisco?", "thread_id": thread_id}
|
||||
)
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
assert data2["message_count"] == 2
|
||||
else:
|
||||
# In async mode, we can't easily test message count
|
||||
# Just verify we can make multiple calls
|
||||
response2 = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run", {"message": "What about Texas?", "thread_id": thread_id}
|
||||
)
|
||||
assert response2.status_code == 202
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Integration Tests for Multi-Agent Sample
|
||||
|
||||
Tests the multi-agent sample with different agent endpoints.
|
||||
|
||||
The function app is automatically started by the test fixture.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
|
||||
- Azurite or Azure Storage account configured
|
||||
|
||||
Usage:
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_02_multi_agent.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.sample("02_multi_agent"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
]
|
||||
|
||||
|
||||
class TestSampleMultiAgent:
|
||||
"""Tests for 02_multi_agent sample."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_agent_urls(self, base_url: str) -> None:
|
||||
"""Configure base URLs for Weather and Math agents."""
|
||||
self.weather_base_url = f"{base_url}/api/agents/WeatherAgent"
|
||||
self.math_base_url = f"{base_url}/api/agents/MathAgent"
|
||||
|
||||
def test_weather_agent(self) -> None:
|
||||
"""Test WeatherAgent endpoint."""
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.weather_base_url}/run",
|
||||
{"message": "What is the weather in Seattle?"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
assert "response" in data
|
||||
|
||||
def test_math_agent(self) -> None:
|
||||
"""Test MathAgent endpoint."""
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.math_base_url}/run",
|
||||
{"message": "Calculate a 20% tip on a $50 bill", "wait_for_response": False},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "accepted"
|
||||
assert "correlation_id" in data
|
||||
assert "thread_id" in data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Integration Tests for Callbacks Sample
|
||||
|
||||
Tests the callbacks sample for event tracking and management.
|
||||
|
||||
The function app is automatically started by the test fixture.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
|
||||
- Azurite or Azure Storage account configured
|
||||
|
||||
Usage:
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_03_callbacks.py -v
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from .testutils import (
|
||||
TIMEOUT,
|
||||
SampleTestHelper,
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
)
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.sample("03_callbacks"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
]
|
||||
|
||||
|
||||
class TestSampleCallbacks:
|
||||
"""Tests for 03_callbacks sample."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_base_url(self, base_url: str) -> None:
|
||||
"""Provide the callback agent base URL for each test."""
|
||||
self.base_url = f"{base_url}/api/agents/CallbackAgent"
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_callback_events(base_url: str, thread_id: str) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
response = SampleTestHelper.get(f"{base_url}/callbacks/{thread_id}")
|
||||
if response.status_code == 200:
|
||||
events = response.json()
|
||||
return events
|
||||
|
||||
def test_agent_with_callbacks(self) -> None:
|
||||
"""Test agent execution with callback tracking."""
|
||||
thread_id = "test-callback"
|
||||
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Tell me about Python", "thread_id": thread_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
|
||||
events = self._wait_for_callback_events(self.base_url, thread_id)
|
||||
|
||||
assert events
|
||||
assert any(event.get("event_type") == "final" for event in events)
|
||||
|
||||
def test_get_callbacks(self) -> None:
|
||||
"""Test retrieving callback events."""
|
||||
thread_id = "test-callback-retrieve"
|
||||
|
||||
# Send a message first
|
||||
SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Hello", "thread_id": thread_id, "wait_for_response": False},
|
||||
)
|
||||
|
||||
# Get callbacks
|
||||
response = SampleTestHelper.get(f"{self.base_url}/callbacks/{thread_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_delete_callbacks(self) -> None:
|
||||
"""Test clearing callback events."""
|
||||
thread_id = "test-callback-delete"
|
||||
|
||||
# Send a message first
|
||||
SampleTestHelper.post_json(
|
||||
f"{self.base_url}/run",
|
||||
{"message": "Test", "thread_id": thread_id, "wait_for_response": False},
|
||||
)
|
||||
|
||||
# Delete callbacks
|
||||
response = requests.delete(f"{self.base_url}/callbacks/{thread_id}", timeout=TIMEOUT)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Integration Tests for Orchestration Chaining Sample
|
||||
|
||||
Tests the orchestration chaining sample for sequential agent execution.
|
||||
|
||||
The function app is automatically started by the test fixture.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
|
||||
- Azurite running for durable orchestrations (or Azure Storage account configured)
|
||||
|
||||
Usage:
|
||||
# Start Azurite (if not already running)
|
||||
azurite &
|
||||
|
||||
# Run tests
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_04_single_agent_orchestration_chaining.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.sample("04_single_agent_orchestration_chaining"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.orchestration
|
||||
class TestSampleOrchestrationChaining:
|
||||
"""Tests for 04_single_agent_orchestration_chaining sample."""
|
||||
|
||||
def test_orchestration_chaining(self, base_url: str) -> None:
|
||||
"""Test sequential agent calls in orchestration."""
|
||||
# Start orchestration
|
||||
response = SampleTestHelper.post_json(f"{base_url}/api/singleagent/run", {})
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "instanceId" in data
|
||||
assert "statusQueryGetUri" in data
|
||||
|
||||
# Wait for completion with output available
|
||||
status = SampleTestHelper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "output" in status
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Integration Tests for MultiAgent Concurrency Sample
|
||||
|
||||
Tests the multi-agent concurrency sample for parallel agent execution.
|
||||
|
||||
The function app is automatically started by the test fixture.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
|
||||
- Azurite running for durable orchestrations (or Azure Storage account configured)
|
||||
|
||||
Usage:
|
||||
# Start Azurite (if not already running)
|
||||
azurite &
|
||||
|
||||
# Run tests
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_05_multi_agent_orchestration_concurrency.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.orchestration,
|
||||
pytest.mark.sample("05_multi_agent_orchestration_concurrency"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
]
|
||||
|
||||
|
||||
class TestSampleMultiAgentConcurrency:
|
||||
"""Tests for 05_multi_agent_orchestration_concurrency sample."""
|
||||
|
||||
def test_concurrent_agents(self, base_url: str) -> None:
|
||||
"""Test multiple agents running concurrently."""
|
||||
# Start orchestration
|
||||
response = SampleTestHelper.post_text(f"{base_url}/api/multiagent/run", "What is temperature?")
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "instanceId" in data
|
||||
assert "statusQueryGetUri" in data
|
||||
|
||||
# Wait for completion
|
||||
status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"])
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
output = status["output"]
|
||||
assert "physicist" in output
|
||||
assert "chemist" in output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Integration Tests for MultiAgent Conditionals Sample
|
||||
|
||||
Tests the multi-agent conditionals sample for conditional orchestration logic.
|
||||
|
||||
The function app is automatically started by the test fixture.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
|
||||
- Azurite running for durable orchestrations (or Azure Storage account configured)
|
||||
|
||||
Usage:
|
||||
# Start Azurite (if not already running)
|
||||
azurite &
|
||||
|
||||
# Run tests
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_06_multi_agent_orchestration_conditionals.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.orchestration,
|
||||
pytest.mark.sample("06_multi_agent_orchestration_conditionals"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
]
|
||||
|
||||
|
||||
class TestSampleMultiAgentConditionals:
|
||||
"""Tests for 06_multi_agent_orchestration_conditionals sample."""
|
||||
|
||||
def test_legitimate_email(self, base_url: str) -> None:
|
||||
"""Test conditional logic with legitimate email."""
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{base_url}/api/spamdetection/run",
|
||||
{
|
||||
"email_id": "email-test-001",
|
||||
"email_content": "Hi John, I hope you are doing well. Can you send me the report?",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "instanceId" in data
|
||||
assert "statusQueryGetUri" in data
|
||||
|
||||
# Wait for completion
|
||||
status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"])
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "Email sent:" in status["output"]
|
||||
|
||||
def test_spam_email(self, base_url: str) -> None:
|
||||
"""Test conditional logic with spam email."""
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{base_url}/api/spamdetection/run",
|
||||
{"email_id": "email-test-002", "email_content": "URGENT! You have won $1,000,000! Click here now!"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "instanceId" in data
|
||||
|
||||
# Wait for completion
|
||||
status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"])
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "Email marked as spam:" in status["output"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Integration Tests for Human-in-the-Loop (HITL) Orchestration Sample
|
||||
|
||||
Tests the HITL orchestration sample for content generation with human approval workflow.
|
||||
|
||||
The function app is automatically started by the test fixture.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
|
||||
- Azurite running for durable orchestrations (or Azure Storage account configured)
|
||||
|
||||
Usage:
|
||||
# Start Azurite (if not already running)
|
||||
azurite &
|
||||
|
||||
# Run tests
|
||||
uv run pytest packages/azurefunctions/tests/integration_tests/test_07_single_agent_orchestration_hitl.py -v
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
|
||||
|
||||
# Module-level markers - applied to all tests in this file
|
||||
pytestmark = [
|
||||
pytest.mark.sample("07_single_agent_orchestration_hitl"),
|
||||
pytest.mark.usefixtures("function_app_for_test"),
|
||||
skip_if_azure_functions_integration_tests_disabled,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.orchestration
|
||||
class TestSampleHITLOrchestration:
|
||||
"""Tests for 07_single_agent_orchestration_hitl sample."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_hitl_base_url(self, base_url: str) -> None:
|
||||
"""Prepare the HITL API base URL for the module's tests."""
|
||||
self.hitl_base_url = f"{base_url}/api/hitl"
|
||||
|
||||
def test_hitl_orchestration_approval(self) -> None:
|
||||
"""Test HITL orchestration with human approval."""
|
||||
# Start orchestration
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.hitl_base_url}/run",
|
||||
{"topic": "artificial intelligence", "max_review_attempts": 3, "approval_timeout_hours": 1.0},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "instanceId" in data
|
||||
assert "statusQueryGetUri" in data
|
||||
assert data["topic"] == "artificial intelligence"
|
||||
instance_id = data["instanceId"]
|
||||
|
||||
# Wait a bit for the orchestration to generate initial content
|
||||
time.sleep(5)
|
||||
|
||||
# Check status to ensure it's waiting for approval
|
||||
status_response = SampleTestHelper.get(data["statusQueryGetUri"])
|
||||
assert status_response.status_code == 200
|
||||
status = status_response.json()
|
||||
assert status["runtimeStatus"] in ["Running", "Pending"]
|
||||
|
||||
# Send approval
|
||||
approval_response = SampleTestHelper.post_json(
|
||||
f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""}
|
||||
)
|
||||
assert approval_response.status_code == 200
|
||||
approval_data = approval_response.json()
|
||||
assert approval_data["approved"] is True
|
||||
|
||||
# Wait for orchestration to complete
|
||||
status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"])
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "output" in status
|
||||
assert "content" in status["output"]
|
||||
|
||||
def test_hitl_orchestration_rejection_with_feedback(self) -> None:
|
||||
"""Test HITL orchestration with rejection and subsequent approval."""
|
||||
# Start orchestration
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.hitl_base_url}/run",
|
||||
{"topic": "machine learning", "max_review_attempts": 3, "approval_timeout_hours": 1.0},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
instance_id = data["instanceId"]
|
||||
|
||||
# Wait for initial content generation
|
||||
time.sleep(5)
|
||||
|
||||
# Send rejection with feedback
|
||||
rejection_response = SampleTestHelper.post_json(
|
||||
f"{self.hitl_base_url}/approve/{instance_id}",
|
||||
{"approved": False, "feedback": "Please make it more concise and focus on practical applications."},
|
||||
)
|
||||
assert rejection_response.status_code == 200
|
||||
|
||||
# Wait for regeneration
|
||||
time.sleep(5)
|
||||
|
||||
# Check status - should still be running
|
||||
status_response = SampleTestHelper.get(data["statusQueryGetUri"])
|
||||
assert status_response.status_code == 200
|
||||
status = status_response.json()
|
||||
assert status["runtimeStatus"] in ["Running", "Pending"]
|
||||
|
||||
# Now approve the revised content
|
||||
approval_response = SampleTestHelper.post_json(
|
||||
f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""}
|
||||
)
|
||||
assert approval_response.status_code == 200
|
||||
|
||||
# Wait for completion
|
||||
status = SampleTestHelper.wait_for_orchestration(data["statusQueryGetUri"])
|
||||
assert status["runtimeStatus"] == "Completed"
|
||||
assert "output" in status
|
||||
|
||||
def test_hitl_orchestration_missing_topic(self) -> None:
|
||||
"""Test HITL orchestration with missing topic."""
|
||||
response = SampleTestHelper.post_json(f"{self.hitl_base_url}/run", {"max_review_attempts": 3})
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
|
||||
def test_hitl_get_status(self) -> None:
|
||||
"""Test getting orchestration status."""
|
||||
# Start orchestration
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.hitl_base_url}/run",
|
||||
{"topic": "quantum computing", "max_review_attempts": 2, "approval_timeout_hours": 1.0},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
instance_id = data["instanceId"]
|
||||
|
||||
# Get status
|
||||
status_response = SampleTestHelper.get(f"{self.hitl_base_url}/status/{instance_id}")
|
||||
assert status_response.status_code == 200
|
||||
status = status_response.json()
|
||||
assert "instanceId" in status
|
||||
assert "runtimeStatus" in status
|
||||
assert status["instanceId"] == instance_id
|
||||
|
||||
# Cleanup: approve to complete orchestration
|
||||
time.sleep(5)
|
||||
SampleTestHelper.post_json(f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""})
|
||||
|
||||
def test_hitl_approval_invalid_payload(self) -> None:
|
||||
"""Test sending approval with invalid payload."""
|
||||
# Start orchestration first
|
||||
response = SampleTestHelper.post_json(
|
||||
f"{self.hitl_base_url}/run",
|
||||
{"topic": "test topic", "max_review_attempts": 1, "approval_timeout_hours": 1.0},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
instance_id = data["instanceId"]
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
# Send approval without 'approved' field
|
||||
approval_response = SampleTestHelper.post_json(
|
||||
f"{self.hitl_base_url}/approve/{instance_id}", {"feedback": "Some feedback"}
|
||||
)
|
||||
assert approval_response.status_code == 400
|
||||
error_data = approval_response.json()
|
||||
assert "error" in error_data
|
||||
|
||||
# Cleanup
|
||||
SampleTestHelper.post_json(f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""})
|
||||
|
||||
def test_hitl_status_invalid_instance(self) -> None:
|
||||
"""Test getting status for non-existent instance."""
|
||||
response = SampleTestHelper.get(f"{self.hitl_base_url}/status/invalid-instance-id")
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,397 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Shared test helper utilities for sample integration tests.
|
||||
|
||||
This module provides common utilities for testing Azure Functions samples.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
# Configuration
|
||||
TIMEOUT = 30 # seconds
|
||||
ORCHESTRATION_TIMEOUT = 180 # seconds for orchestrations
|
||||
_DEFAULT_HOST = "localhost"
|
||||
|
||||
|
||||
class FunctionAppStartupError(RuntimeError):
|
||||
"""Raised when the Azure Functions host fails to start reliably."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _load_env_file_if_present() -> None:
|
||||
"""Load environment variables from the local .env file when available."""
|
||||
env_file = Path(__file__).parent / ".env"
|
||||
if not env_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(env_file)
|
||||
except ImportError:
|
||||
# python-dotenv not available; rely on existing environment
|
||||
pass
|
||||
|
||||
|
||||
def _should_skip_azure_functions_integration_tests() -> tuple[bool, str]:
|
||||
"""Determine whether Azure Functions integration tests should be skipped."""
|
||||
_load_env_file_if_present()
|
||||
|
||||
run_integration_tests = os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
|
||||
if not run_integration_tests:
|
||||
return (
|
||||
True,
|
||||
"Integration tests are disabled. Set RUN_INTEGRATION_TESTS=true to enable Azure Functions sample tests.",
|
||||
)
|
||||
|
||||
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()
|
||||
if not endpoint or endpoint == "https://your-resource.openai.azure.com/":
|
||||
return True, "No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests."
|
||||
|
||||
deployment_name = os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", "").strip()
|
||||
if not deployment_name or deployment_name == "your-deployment-name":
|
||||
return True, "No real AZURE_OPENAI_CHAT_DEPLOYMENT_NAME provided; skipping integration tests."
|
||||
|
||||
return False, "Integration tests enabled."
|
||||
|
||||
|
||||
_SKIP_AZURE_FUNCTIONS_INTEGRATION_TESTS, _AZURE_FUNCTIONS_SKIP_REASON = _should_skip_azure_functions_integration_tests()
|
||||
|
||||
skip_if_azure_functions_integration_tests_disabled = pytest.mark.skipif(
|
||||
_SKIP_AZURE_FUNCTIONS_INTEGRATION_TESTS,
|
||||
reason=_AZURE_FUNCTIONS_SKIP_REASON,
|
||||
)
|
||||
|
||||
|
||||
class SampleTestHelper:
|
||||
"""Helper class for testing samples."""
|
||||
|
||||
@staticmethod
|
||||
def post_json(url: str, data: dict[str, Any], timeout: int = TIMEOUT) -> requests.Response:
|
||||
"""POST JSON data to a URL."""
|
||||
return requests.post(url, json=data, headers={"Content-Type": "application/json"}, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def post_text(url: str, text: str, timeout: int = TIMEOUT) -> requests.Response:
|
||||
"""POST plain text to a URL."""
|
||||
return requests.post(url, data=text, headers={"Content-Type": "text/plain"}, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def get(url: str, timeout: int = TIMEOUT) -> requests.Response:
|
||||
"""GET request to a URL."""
|
||||
return requests.get(url, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def wait_for_orchestration(
|
||||
status_url: str, max_wait: int = ORCHESTRATION_TIMEOUT, poll_interval: int = 2
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Wait for an orchestration to complete.
|
||||
|
||||
Args:
|
||||
status_url: URL to poll for orchestration status
|
||||
max_wait: Maximum seconds to wait
|
||||
poll_interval: Seconds between polls
|
||||
|
||||
Returns:
|
||||
Final orchestration status
|
||||
|
||||
Raises:
|
||||
TimeoutError: If orchestration doesn't complete in time
|
||||
"""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < max_wait:
|
||||
response = requests.get(status_url, timeout=TIMEOUT)
|
||||
response.raise_for_status()
|
||||
status = response.json()
|
||||
|
||||
runtime_status = status.get("runtimeStatus", "")
|
||||
if runtime_status in ["Completed", "Failed", "Terminated"]:
|
||||
return status
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
raise TimeoutError(f"Orchestration did not complete within {max_wait} seconds")
|
||||
|
||||
@staticmethod
|
||||
def wait_for_orchestration_with_output(
|
||||
status_url: str, max_wait: int = ORCHESTRATION_TIMEOUT, poll_interval: int = 2
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Wait for an orchestration to complete and have output available.
|
||||
|
||||
This is a specialized version of wait_for_orchestration that also
|
||||
ensures the output field is present, handling timing race conditions.
|
||||
|
||||
Args:
|
||||
status_url: URL to poll for orchestration status
|
||||
max_wait: Maximum seconds to wait
|
||||
poll_interval: Seconds between polls
|
||||
|
||||
Returns:
|
||||
Final orchestration status with output
|
||||
|
||||
Raises:
|
||||
TimeoutError: If orchestration doesn't complete with output in time
|
||||
"""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < max_wait:
|
||||
response = requests.get(status_url, timeout=TIMEOUT)
|
||||
response.raise_for_status()
|
||||
status = response.json()
|
||||
|
||||
runtime_status = status.get("runtimeStatus", "")
|
||||
if runtime_status in ["Failed", "Terminated"]:
|
||||
return status
|
||||
if runtime_status == "Completed" and status.get("output"):
|
||||
return status
|
||||
# If completed but no output, continue polling for a bit more to
|
||||
# handle the race condition where output has not been persisted yet.
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
# Provide detailed error message based on final status
|
||||
final_response = requests.get(status_url, timeout=TIMEOUT)
|
||||
final_response.raise_for_status()
|
||||
final_status = final_response.json()
|
||||
final_runtime_status = final_status.get("runtimeStatus", "Unknown")
|
||||
|
||||
if final_runtime_status == "Completed":
|
||||
if "output" not in final_status:
|
||||
raise TimeoutError(
|
||||
"Orchestration completed but 'output' field is missing after "
|
||||
f"{max_wait} seconds. Final status: {final_status}"
|
||||
)
|
||||
if not final_status["output"]:
|
||||
raise TimeoutError(
|
||||
"Orchestration completed but output is empty after "
|
||||
f"{max_wait} seconds. Final status: {final_status}"
|
||||
)
|
||||
raise TimeoutError(
|
||||
"Orchestration completed with output but validation failed after "
|
||||
f"{max_wait} seconds. Final status: {final_status}"
|
||||
)
|
||||
raise TimeoutError(
|
||||
"Orchestration did not complete within "
|
||||
f"{max_wait} seconds. Final status: {final_runtime_status}, "
|
||||
f"Full status: {final_status}"
|
||||
)
|
||||
|
||||
|
||||
# Function App Lifecycle Management Helpers
|
||||
|
||||
|
||||
def _resolve_repo_root() -> Path:
|
||||
"""Resolve the repository root, preferring GITHUB_WORKSPACE when available."""
|
||||
workspace = os.getenv("GITHUB_WORKSPACE")
|
||||
if workspace:
|
||||
candidate = Path(workspace).expanduser()
|
||||
if not (candidate / "samples").exists() and (candidate / "python" / "samples").exists():
|
||||
return (candidate / "python").resolve()
|
||||
return candidate.resolve()
|
||||
|
||||
# If `GITHUB_WORKSPACE` is not set,
|
||||
# go up from testutils.py -> integration_tests -> tests -> azurefunctions -> packages -> python
|
||||
return Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def get_sample_path_from_marker(request) -> tuple[Path | None, str | None]:
|
||||
"""
|
||||
Get sample path from @pytest.mark.sample() marker.
|
||||
|
||||
Returns a tuple of (sample_path, error_message).
|
||||
If successful, error_message is None.
|
||||
If failed, sample_path is None and error_message contains the reason.
|
||||
"""
|
||||
marker = request.node.get_closest_marker("sample")
|
||||
|
||||
if not marker:
|
||||
return (
|
||||
None,
|
||||
(
|
||||
"No @pytest.mark.sample() marker found on test. Add pytestmark with "
|
||||
"@pytest.mark.sample('sample_name') to the test module."
|
||||
),
|
||||
)
|
||||
|
||||
if not marker.args:
|
||||
return (
|
||||
None,
|
||||
"@pytest.mark.sample() marker found but no sample name provided. Use @pytest.mark.sample('sample_name').",
|
||||
)
|
||||
|
||||
sample_name = marker.args[0]
|
||||
repo_root = _resolve_repo_root()
|
||||
sample_path = repo_root / "samples" / "getting_started" / "azure_functions" / sample_name
|
||||
|
||||
if not sample_path.exists():
|
||||
return None, f"Sample directory does not exist: {sample_path}"
|
||||
|
||||
return sample_path, None
|
||||
|
||||
|
||||
def find_available_port(host: str = _DEFAULT_HOST) -> int:
|
||||
"""Find an available TCP port on the given host."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((host, 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def build_base_url(port: int, host: str = _DEFAULT_HOST) -> str:
|
||||
"""Construct a base URL for the Azure Functions host."""
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
|
||||
def is_port_in_use(port: int, host: str = _DEFAULT_HOST) -> bool:
|
||||
"""
|
||||
Check if a port is already in use.
|
||||
|
||||
Returns True if the port is in use, False otherwise.
|
||||
"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
return sock.connect_ex((host, port)) == 0
|
||||
|
||||
|
||||
def load_and_validate_env() -> None:
|
||||
"""
|
||||
Load .env file from current directory if it exists,
|
||||
then validate that required environment variables are present.
|
||||
|
||||
Raises pytest.fail if required environment variables are missing.
|
||||
"""
|
||||
_load_env_file_if_present()
|
||||
|
||||
# Required environment variables for Azure Functions samples
|
||||
# These match the variables defined in .env.example
|
||||
required_env_vars = [
|
||||
"AZURE_OPENAI_ENDPOINT",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",
|
||||
"AzureWebJobsStorage",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
|
||||
"FUNCTIONS_WORKER_RUNTIME",
|
||||
]
|
||||
|
||||
# Check if required env vars are set
|
||||
missing_vars = [var for var in required_env_vars if not os.environ.get(var)]
|
||||
|
||||
if missing_vars:
|
||||
pytest.fail(
|
||||
f"Missing required environment variables: {', '.join(missing_vars)}. "
|
||||
"Please create a .env file in tests/integration_tests/ based on .env.example or "
|
||||
"set these variables in your environment."
|
||||
)
|
||||
|
||||
|
||||
def start_function_app(sample_path: Path, port: int) -> subprocess.Popen:
|
||||
"""
|
||||
Start a function app in the specified sample directory.
|
||||
|
||||
Returns the subprocess.Popen object for the running process.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
# Use a unique TASKHUB_NAME for each test run to ensure test isolation.
|
||||
# This prevents conflicts between parallel or repeated test runs, as Durable Functions
|
||||
# use the task hub name to separate orchestration state.
|
||||
env["TASKHUB_NAME"] = f"test{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# On Windows, use CREATE_NEW_PROCESS_GROUP to allow proper termination
|
||||
# shell=True only on Windows to handle PATH resolution
|
||||
if sys.platform == "win32":
|
||||
return subprocess.Popen(
|
||||
["func", "start", "--port", str(port)],
|
||||
cwd=str(sample_path),
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
shell=True,
|
||||
env=env,
|
||||
)
|
||||
# On Unix, don't use shell=True to avoid shell wrapper issues
|
||||
return subprocess.Popen(["func", "start", "--port", str(port)], cwd=str(sample_path), env=env)
|
||||
|
||||
|
||||
def wait_for_function_app_ready(func_process: subprocess.Popen, port: int, max_wait: int = 60) -> None:
|
||||
"""Block until the Azure Functions host responds healthy or fail fast."""
|
||||
start_time = time.time()
|
||||
health_url = f"{build_base_url(port)}/api/health"
|
||||
last_error: Exception | None = None
|
||||
|
||||
while time.time() - start_time < max_wait:
|
||||
# If the process exited early, capture any previously seen error and fail fast.
|
||||
if func_process.poll() is not None:
|
||||
raise FunctionAppStartupError(
|
||||
f"Function app process exited with code {func_process.returncode} before becoming healthy"
|
||||
) from last_error
|
||||
|
||||
if is_port_in_use(port):
|
||||
try:
|
||||
response = requests.get(health_url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
last_error = RuntimeError(f"Health check returned {response.status_code}")
|
||||
except requests.RequestException as exc:
|
||||
last_error = exc
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
raise FunctionAppStartupError(
|
||||
f"Function app did not become healthy on port {port} within {max_wait} seconds"
|
||||
) from last_error
|
||||
|
||||
|
||||
def cleanup_function_app(func_process: subprocess.Popen) -> None:
|
||||
"""
|
||||
Clean up the function app process and all its children.
|
||||
|
||||
Uses psutil if available for more thorough cleanup, falls back to basic termination.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
if func_process.poll() is None: # Process still running
|
||||
# Get parent process
|
||||
parent = psutil.Process(func_process.pid)
|
||||
|
||||
# Get all child processes recursively
|
||||
children = parent.children(recursive=True)
|
||||
|
||||
# Kill children first
|
||||
for child in children:
|
||||
with suppress(psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
child.kill()
|
||||
|
||||
# Kill parent
|
||||
with suppress(psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
parent.kill()
|
||||
|
||||
# Wait for all to terminate
|
||||
_gone, alive = psutil.wait_procs(children + [parent], timeout=3)
|
||||
|
||||
# Force kill any remaining
|
||||
for proc in alive:
|
||||
with suppress(psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
proc.kill()
|
||||
except ImportError:
|
||||
# Fallback if psutil not available
|
||||
try:
|
||||
if func_process.poll() is None:
|
||||
func_process.kill()
|
||||
func_process.wait()
|
||||
except Exception:
|
||||
# Ignore all exceptions during fallback cleanup; best effort to terminate process.
|
||||
pass
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
|
||||
# Give the port time to be released
|
||||
time.sleep(2)
|
||||
@@ -0,0 +1,738 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for AgentFunctionApp."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, TypeVar
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
import pytest
|
||||
from agent_framework import AgentRunResponse, ChatMessage
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER
|
||||
from agent_framework_azurefunctions._entities import AgentEntity, AgentState, create_agent_entity
|
||||
|
||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
||||
|
||||
|
||||
class TestAgentFunctionAppInit:
|
||||
"""Test suite for AgentFunctionApp initialization."""
|
||||
|
||||
def test_init_with_defaults(self) -> None:
|
||||
"""Test initialization with default parameters."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
|
||||
assert len(app.agents) == 1
|
||||
assert "TestAgent" in app.agents
|
||||
assert app.enable_health_check is True
|
||||
|
||||
def test_init_with_custom_auth_level(self) -> None:
|
||||
"""Test initialization with custom auth level."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent], http_auth_level=func.AuthLevel.FUNCTION)
|
||||
|
||||
# App should be created successfully
|
||||
assert "TestAgent" in app.agents
|
||||
|
||||
def test_init_with_health_check_disabled(self) -> None:
|
||||
"""Test initialization with health check disabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent], enable_health_check=False)
|
||||
|
||||
assert app.enable_health_check is False
|
||||
|
||||
def test_init_with_http_endpoints_disabled(self) -> None:
|
||||
"""Test initialization with HTTP endpoints disabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False)
|
||||
|
||||
assert app.enable_http_endpoints is False
|
||||
|
||||
def test_init_stores_agent_reference(self) -> None:
|
||||
"""Test that agent reference is stored correctly."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
|
||||
assert app.agents["TestAgent"].name == "TestAgent"
|
||||
|
||||
def test_add_agent_uses_specific_callback(self) -> None:
|
||||
"""Verify that a per-agent callback overrides the default."""
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "CallbackAgent"
|
||||
specific_callback = Mock()
|
||||
|
||||
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
||||
app = AgentFunctionApp(default_callback=Mock())
|
||||
app.add_agent(mock_agent, callback=specific_callback)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
|
||||
assert passed_callback is specific_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
def test_default_callback_applied_when_no_specific(self) -> None:
|
||||
"""Ensure the default callback is supplied when add_agent lacks override."""
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "DefaultAgent"
|
||||
default_callback = Mock()
|
||||
|
||||
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
||||
app = AgentFunctionApp(default_callback=default_callback)
|
||||
app.add_agent(mock_agent)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
|
||||
assert passed_callback is default_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
def test_init_with_agents_uses_default_callback(self) -> None:
|
||||
"""Agents provided in __init__ should receive the default callback."""
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "InitAgent"
|
||||
default_callback = Mock()
|
||||
|
||||
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
||||
AgentFunctionApp(agents=[mock_agent], default_callback=default_callback)
|
||||
|
||||
setup_mock.assert_called_once()
|
||||
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
|
||||
assert passed_callback is default_callback
|
||||
assert enable_http_endpoint is True
|
||||
|
||||
|
||||
class TestAgentFunctionAppSetup:
|
||||
"""Test suite for AgentFunctionApp setup and configuration."""
|
||||
|
||||
def test_app_is_dfapp_instance(self) -> None:
|
||||
"""Test that AgentFunctionApp is a DFApp instance."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
|
||||
assert isinstance(app, df.DFApp)
|
||||
|
||||
def test_setup_creates_http_trigger(self) -> None:
|
||||
"""Test that setup creates an HTTP trigger."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with (
|
||||
patch.object(AgentFunctionApp, "route", new=passthrough_decorator),
|
||||
patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator),
|
||||
patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator),
|
||||
):
|
||||
app = AgentFunctionApp(agents=[mock_agent])
|
||||
|
||||
# Verify agent is registered
|
||||
assert "TestAgent" in app.agents
|
||||
|
||||
def test_http_function_name_uses_prefix_format(self) -> None:
|
||||
"""Ensure function names follow the prefix-agent naming convention."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "Agent 42"
|
||||
|
||||
captured_names: list[str] = []
|
||||
|
||||
def capture_function_name(
|
||||
self: AgentFunctionApp, name: str, *args: Any, **kwargs: Any
|
||||
) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
captured_names.append(name)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with (
|
||||
patch.object(AgentFunctionApp, "function_name", new=capture_function_name),
|
||||
patch.object(AgentFunctionApp, "route", new=passthrough_decorator),
|
||||
patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator),
|
||||
patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator),
|
||||
):
|
||||
AgentFunctionApp(agents=[mock_agent])
|
||||
|
||||
assert captured_names == ["http-Agent_42"]
|
||||
|
||||
def test_setup_skips_http_trigger_when_disabled(self) -> None:
|
||||
"""Test that HTTP trigger is not created when disabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
captured_routes: list[str | None] = []
|
||||
|
||||
def capture_route(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
route_key = kwargs.get("route") if kwargs else None
|
||||
captured_routes.append(route_key)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with (
|
||||
patch.object(AgentFunctionApp, "function_name", new=passthrough_decorator),
|
||||
patch.object(AgentFunctionApp, "route", new=capture_route),
|
||||
patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator),
|
||||
patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator),
|
||||
):
|
||||
app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False)
|
||||
|
||||
# Verify agent is registered
|
||||
assert "TestAgent" in app.agents
|
||||
|
||||
# Verify that no HTTP run route was created
|
||||
run_route = f"agents/{mock_agent.name}/run"
|
||||
assert run_route not in captured_routes
|
||||
|
||||
def test_agent_override_enables_http_route_when_app_disabled(self) -> None:
|
||||
"""Agent-level override should enable HTTP route even when app disables it."""
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "OverrideAgent"
|
||||
|
||||
with (
|
||||
patch.object(AgentFunctionApp, "_setup_http_run_route") as http_route_mock,
|
||||
patch.object(AgentFunctionApp, "_setup_agent_entity") as agent_entity_mock,
|
||||
):
|
||||
app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=False)
|
||||
app.add_agent(mock_agent, enable_http_endpoint=True)
|
||||
|
||||
http_route_mock.assert_called_once_with("OverrideAgent")
|
||||
agent_entity_mock.assert_called_once_with(mock_agent, "OverrideAgent", ANY)
|
||||
assert app.agent_http_endpoint_flags["OverrideAgent"] is True
|
||||
|
||||
def test_agent_override_disables_http_route_when_app_enabled(self) -> None:
|
||||
"""Agent-level override should disable HTTP route even when app enables it."""
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "DisabledOverride"
|
||||
|
||||
with (
|
||||
patch.object(AgentFunctionApp, "_setup_http_run_route") as http_route_mock,
|
||||
patch.object(AgentFunctionApp, "_setup_agent_entity") as agent_entity_mock,
|
||||
):
|
||||
app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=True)
|
||||
app.add_agent(mock_agent, enable_http_endpoint=False)
|
||||
|
||||
http_route_mock.assert_not_called()
|
||||
agent_entity_mock.assert_called_once_with(mock_agent, "DisabledOverride", ANY)
|
||||
assert app.agent_http_endpoint_flags["DisabledOverride"] is False
|
||||
|
||||
def test_multiple_apps_independent(self) -> None:
|
||||
"""Test that multiple AgentFunctionApp instances are independent."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "Agent1"
|
||||
agent2 = Mock()
|
||||
agent2.name = "Agent2"
|
||||
|
||||
app1 = AgentFunctionApp(agents=[agent1])
|
||||
app2 = AgentFunctionApp(agents=[agent2])
|
||||
|
||||
assert app1.agents["Agent1"].name == "Agent1"
|
||||
assert app2.agents["Agent2"].name == "Agent2"
|
||||
assert "Agent1" in app1.agents
|
||||
assert "Agent2" in app2.agents
|
||||
|
||||
|
||||
class TestWaitForResponseAndCorrelationId:
|
||||
"""Tests for wait_for_response flag and correlation ID handling."""
|
||||
|
||||
def _create_app(self) -> AgentFunctionApp:
|
||||
mock_agent = Mock()
|
||||
mock_agent.__class__.__name__ = "MockAgent"
|
||||
mock_agent.name = "MockAgent"
|
||||
return AgentFunctionApp(agents=[mock_agent], enable_health_check=False)
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> Mock:
|
||||
request = Mock()
|
||||
request.headers = headers or {}
|
||||
request.params = params or {}
|
||||
return request
|
||||
|
||||
def test_wait_for_response_header_true(self) -> None:
|
||||
"""Test that the wait-for-response header is honored."""
|
||||
app = self._create_app()
|
||||
request = self._make_request(headers={WAIT_FOR_RESPONSE_HEADER: "true"})
|
||||
|
||||
assert app._should_wait_for_response(request, {}) is True
|
||||
|
||||
def test_wait_for_response_body_snake_case(self) -> None:
|
||||
"""Test that payload controls wait_for_response."""
|
||||
app = self._create_app()
|
||||
request = self._make_request()
|
||||
|
||||
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "true"}) is True
|
||||
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "false"}) is False
|
||||
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "0"}) is False
|
||||
|
||||
def test_wait_for_response_query_parameter(self) -> None:
|
||||
"""Test that query parameter controls wait_for_response."""
|
||||
app = self._create_app()
|
||||
request = self._make_request(params={WAIT_FOR_RESPONSE_FIELD: "true"})
|
||||
|
||||
assert app._should_wait_for_response(request, {}) is True
|
||||
|
||||
def test_wait_for_response_query_precedence(self) -> None:
|
||||
"""Test that query parameter overrides body value."""
|
||||
app = self._create_app()
|
||||
request = self._make_request(params={WAIT_FOR_RESPONSE_FIELD: "false"})
|
||||
|
||||
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "true"}) is False
|
||||
|
||||
|
||||
class TestAgentEntityOperations:
|
||||
"""Test suite for entity operations."""
|
||||
|
||||
async def test_entity_run_agent_operation(self) -> None:
|
||||
"""Test that entity can run agent operation."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(
|
||||
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Test response")])
|
||||
)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Test message", "thread_id": "test-conv-123", "correlation_id": "corr-app-entity-1"},
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["response"] == "Test response"
|
||||
assert result["message"] == "Test message"
|
||||
assert result["thread_id"] == "test-conv-123"
|
||||
assert entity.state.message_count == 1
|
||||
|
||||
async def test_entity_stores_conversation_history(self) -> None:
|
||||
"""Test that the entity stores conversation history."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(
|
||||
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response 1")])
|
||||
)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Send first message
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-2"}
|
||||
)
|
||||
|
||||
history = entity.state.conversation_history
|
||||
assert len(history) == 2 # User + assistant
|
||||
|
||||
user_msg = history[0]
|
||||
user_role = getattr(user_msg.role, "value", user_msg.role)
|
||||
assert user_role == "user"
|
||||
assert user_msg.text == "Message 1"
|
||||
|
||||
assistant_msg = history[1]
|
||||
assistant_role = getattr(assistant_msg.role, "value", assistant_msg.role)
|
||||
assert assistant_role == "assistant"
|
||||
assert assistant_msg.text == "Response 1"
|
||||
|
||||
async def test_entity_increments_message_count(self) -> None:
|
||||
"""Test that the entity increments the message count."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(
|
||||
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")])
|
||||
)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
assert entity.state.message_count == 0
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3a"}
|
||||
)
|
||||
assert entity.state.message_count == 1
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3b"}
|
||||
)
|
||||
assert entity.state.message_count == 2
|
||||
|
||||
def test_entity_reset(self) -> None:
|
||||
"""Test that entity reset clears state."""
|
||||
mock_agent = Mock()
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
# Set some state
|
||||
entity.state.message_count = 10
|
||||
entity.state.last_response = "Some response"
|
||||
entity.state.conversation_history = [
|
||||
ChatMessage(role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"})
|
||||
]
|
||||
|
||||
# Reset
|
||||
mock_context = Mock()
|
||||
entity.reset(mock_context)
|
||||
|
||||
assert entity.state.message_count == 0
|
||||
assert entity.state.last_response is None
|
||||
assert len(entity.state.conversation_history) == 0
|
||||
|
||||
|
||||
class TestAgentEntityFactory:
|
||||
"""Test suite for the entity factory function."""
|
||||
|
||||
def test_create_agent_entity_returns_function(self) -> None:
|
||||
"""Test that create_agent_entity returns a function."""
|
||||
mock_agent = Mock()
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
assert callable(entity_function)
|
||||
|
||||
def test_entity_function_handles_run_agent_operation(self) -> None:
|
||||
"""Test that the entity function handles the run_agent operation."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(
|
||||
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")])
|
||||
)
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-123",
|
||||
"correlation_id": "corr-app-factory-1",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute entity function
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify result was set
|
||||
assert mock_context.set_result.called
|
||||
assert mock_context.set_state.called
|
||||
|
||||
def test_entity_function_handles_reset_operation(self) -> None:
|
||||
"""Test that the entity function handles the reset operation."""
|
||||
mock_agent = Mock()
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "reset"
|
||||
mock_context.get_state.return_value = {
|
||||
"message_count": 5,
|
||||
"conversation_history": [{"role": "user", "content": "test"}],
|
||||
"last_response": "Test",
|
||||
}
|
||||
|
||||
# Execute entity function
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify result was set
|
||||
assert mock_context.set_result.called
|
||||
result_call = mock_context.set_result.call_args[0][0]
|
||||
assert result_call["status"] == "reset"
|
||||
|
||||
def test_entity_function_handles_unknown_operation(self) -> None:
|
||||
"""Test that the entity function handles an unknown operation."""
|
||||
mock_agent = Mock()
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context with unknown operation
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "unknown_operation"
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute entity function
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify error result was set
|
||||
assert mock_context.set_result.called
|
||||
result_call = mock_context.set_result.call_args[0][0]
|
||||
assert "error" in result_call
|
||||
assert "unknown_operation" in result_call["error"]
|
||||
|
||||
def test_entity_function_restores_state(self) -> None:
|
||||
"""Test that the entity function restores state from the context."""
|
||||
mock_agent = Mock()
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context with existing state
|
||||
existing_state = {
|
||||
"message_count": 3,
|
||||
"conversation_history": [{"role": "user", "content": "msg1"}, {"role": "assistant", "content": "resp1"}],
|
||||
"last_response": "resp1",
|
||||
}
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "reset"
|
||||
mock_context.get_state.return_value = existing_state
|
||||
|
||||
with patch.object(AgentState, "restore_state") as restore_state_mock:
|
||||
entity_function(mock_context)
|
||||
|
||||
restore_state_mock.assert_called_once_with(existing_state)
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test suite for error handling."""
|
||||
|
||||
async def test_entity_handles_agent_error(self) -> None:
|
||||
"""Test that the entity handles agent execution errors."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Agent error"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlation_id": "corr-app-error-1"}
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "error" in result
|
||||
assert "Agent error" in result["error"]
|
||||
assert result["error_type"] == "Exception"
|
||||
|
||||
def test_entity_function_handles_exception(self) -> None:
|
||||
"""Test that the entity function handles exceptions gracefully."""
|
||||
mock_agent = Mock()
|
||||
# Force an exception by making get_input fail
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Test error"))
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.side_effect = Exception("Input error")
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute entity function - should not raise
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify error result was set
|
||||
assert mock_context.set_result.called
|
||||
result_call = mock_context.set_result.call_args[0][0]
|
||||
assert "error" in result_call
|
||||
|
||||
|
||||
class TestIncomingRequestParsing:
|
||||
"""Tests for parsing run requests with JSON and plain text bodies."""
|
||||
|
||||
def _create_app(self) -> AgentFunctionApp:
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "ParserAgent"
|
||||
return AgentFunctionApp(agents=[mock_agent], enable_health_check=False)
|
||||
|
||||
def test_parse_plain_text_body(self) -> None:
|
||||
"""Test parsing a plain-text request body."""
|
||||
app = self._create_app()
|
||||
|
||||
request = Mock()
|
||||
request.headers = {}
|
||||
request.params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b"Plain text message"
|
||||
|
||||
req_body, message, response_format = app._parse_incoming_request(request)
|
||||
|
||||
assert req_body == {}
|
||||
assert message == "Plain text message"
|
||||
|
||||
assert response_format == "text"
|
||||
|
||||
def test_parse_plain_text_trims_whitespace(self) -> None:
|
||||
"""Plain-text parser returns an empty string when the body contains only whitespace."""
|
||||
app = self._create_app()
|
||||
|
||||
request = Mock()
|
||||
request.headers = {}
|
||||
request.params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b" "
|
||||
|
||||
req_body, message, response_format = app._parse_incoming_request(request)
|
||||
|
||||
assert req_body == {}
|
||||
assert message == ""
|
||||
assert response_format == "text"
|
||||
|
||||
def test_accept_header_prefers_json(self) -> None:
|
||||
"""Test that the Accept header can force JSON responses for plain-text bodies."""
|
||||
app = self._create_app()
|
||||
|
||||
request = Mock()
|
||||
request.headers = {"accept": "application/json"}
|
||||
request.params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b"Plain text message"
|
||||
|
||||
_, message, response_format = app._parse_incoming_request(request)
|
||||
|
||||
assert message == "Plain text message"
|
||||
assert response_format == "json"
|
||||
|
||||
def test_extract_thread_id_from_query_params(self) -> None:
|
||||
"""Test thread identifier extraction from query parameters."""
|
||||
app = self._create_app()
|
||||
|
||||
request = Mock()
|
||||
request.params = {"thread_id": "query-thread"}
|
||||
req_body = {}
|
||||
|
||||
thread_id = app._resolve_thread_id(request, req_body)
|
||||
|
||||
assert thread_id == "query-thread"
|
||||
|
||||
|
||||
class TestHttpRunRoute:
|
||||
"""Tests for the HTTP run route behavior."""
|
||||
|
||||
@staticmethod
|
||||
def _get_run_handler(agent: Mock) -> Callable[[func.HttpRequest, Any], Awaitable[func.HttpResponse]]:
|
||||
captured_handlers: dict[str | None, Callable[..., Awaitable[func.HttpResponse]]] = {}
|
||||
|
||||
def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def capture_route(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
|
||||
def decorator(func: TFunc) -> TFunc:
|
||||
route_key = kwargs.get("route") if kwargs else None
|
||||
captured_handlers[route_key] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with (
|
||||
patch.object(AgentFunctionApp, "function_name", new=capture_decorator),
|
||||
patch.object(AgentFunctionApp, "route", new=capture_route),
|
||||
patch.object(AgentFunctionApp, "durable_client_input", new=capture_decorator),
|
||||
patch.object(AgentFunctionApp, "entity_trigger", new=capture_decorator),
|
||||
):
|
||||
AgentFunctionApp(agents=[agent], enable_health_check=False)
|
||||
|
||||
run_route = f"agents/{agent.name}/run"
|
||||
return captured_handlers[run_route]
|
||||
|
||||
async def test_http_run_accepts_plain_text(self) -> None:
|
||||
"""Test that the HTTP handler accepts plain-text requests."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "HttpAgent"
|
||||
|
||||
handler = self._get_run_handler(mock_agent)
|
||||
|
||||
request = Mock()
|
||||
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false"}
|
||||
request.params = {}
|
||||
request.route_params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b"Plain text via HTTP"
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
response = await handler(request, client)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.mimetype == "text/plain"
|
||||
assert response.headers.get("x-ms-thread-id") is not None
|
||||
assert response.get_body().decode("utf-8") == "Agent request accepted"
|
||||
|
||||
signal_args = client.signal_entity.call_args[0]
|
||||
run_request = signal_args[2]
|
||||
|
||||
assert run_request["message"] == "Plain text via HTTP"
|
||||
assert run_request["role"] == "user"
|
||||
assert "thread_id" in run_request
|
||||
|
||||
async def test_http_run_accept_header_returns_json(self) -> None:
|
||||
"""Test that Accept header requesting JSON results in JSON response."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "HttpAgentJson"
|
||||
|
||||
handler = self._get_run_handler(mock_agent)
|
||||
|
||||
request = Mock()
|
||||
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false", "Accept": "application/json"}
|
||||
request.params = {}
|
||||
request.route_params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b"Plain text via HTTP"
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
response = await handler(request, client)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.mimetype == "application/json"
|
||||
assert response.headers.get("x-ms-thread-id") is None
|
||||
body = response.get_body().decode("utf-8")
|
||||
assert '"status": "accepted"' in body
|
||||
|
||||
async def test_http_run_rejects_empty_message(self) -> None:
|
||||
"""Test that the HTTP handler rejects empty messages with a 400 response."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "HttpAgentEmpty"
|
||||
|
||||
handler = self._get_run_handler(mock_agent)
|
||||
|
||||
request = Mock()
|
||||
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false"}
|
||||
request.params = {}
|
||||
request.route_params = {}
|
||||
request.get_json.side_effect = ValueError("Invalid JSON")
|
||||
request.get_body.return_value = b" "
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
response = await handler(request, client)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.mimetype == "text/plain"
|
||||
assert response.headers.get("x-ms-thread-id") is not None
|
||||
assert response.get_body().decode("utf-8") == "Message is required"
|
||||
client.signal_entity.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,904 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for AgentEntity and entity operations.
|
||||
|
||||
Run with: pytest tests/test_entities.py -v
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from datetime import datetime
|
||||
from typing import Any, TypeVar
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
|
||||
from agent_framework_azurefunctions._models import RunRequest
|
||||
from agent_framework_azurefunctions._state import AgentState
|
||||
|
||||
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def _role_value(chat_message: ChatMessage) -> str:
|
||||
"""Helper to extract the string role from a ChatMessage."""
|
||||
role = getattr(chat_message, "role", None)
|
||||
role_value = getattr(role, "value", role)
|
||||
if role_value is None:
|
||||
return ""
|
||||
return str(role_value)
|
||||
|
||||
|
||||
def _agent_response(text: str | None) -> AgentRunResponse:
|
||||
"""Create an AgentRunResponse with a single assistant message."""
|
||||
message = (
|
||||
ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", contents=[])
|
||||
)
|
||||
return AgentRunResponse(messages=[message])
|
||||
|
||||
|
||||
class RecordingCallback:
|
||||
"""Callback implementation capturing streaming and final responses for assertions."""
|
||||
|
||||
def __init__(self):
|
||||
self.stream_mock = AsyncMock()
|
||||
self.response_mock = AsyncMock()
|
||||
|
||||
async def on_streaming_response_update(
|
||||
self,
|
||||
update: AgentRunResponseUpdate,
|
||||
context: Any,
|
||||
) -> None:
|
||||
await self.stream_mock(update, context)
|
||||
|
||||
async def on_agent_response(self, response: AgentRunResponse, context: Any) -> None:
|
||||
await self.response_mock(response, context)
|
||||
|
||||
|
||||
class EntityStructuredResponse(BaseModel):
|
||||
answer: float
|
||||
|
||||
|
||||
class TestAgentEntityInit:
|
||||
"""Test suite for AgentEntity initialization."""
|
||||
|
||||
def test_init_creates_entity(self) -> None:
|
||||
"""Test that AgentEntity initializes correctly."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
assert entity.agent == mock_agent
|
||||
assert entity.state.conversation_history == []
|
||||
assert entity.state.last_response is None
|
||||
assert entity.state.message_count == 0
|
||||
|
||||
def test_init_stores_agent_reference(self) -> None:
|
||||
"""Test that the agent reference is stored correctly."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "TestAgent"
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
assert entity.agent.name == "TestAgent"
|
||||
|
||||
def test_init_with_different_agent_types(self) -> None:
|
||||
"""Test initialization with different agent types."""
|
||||
agent1 = Mock()
|
||||
agent1.__class__.__name__ = "AzureOpenAIAgent"
|
||||
|
||||
agent2 = Mock()
|
||||
agent2.__class__.__name__ = "CustomAgent"
|
||||
|
||||
entity1 = AgentEntity(agent1)
|
||||
entity2 = AgentEntity(agent2)
|
||||
|
||||
assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent"
|
||||
assert entity2.agent.__class__.__name__ == "CustomAgent"
|
||||
|
||||
|
||||
class TestAgentEntityRunAgent:
|
||||
"""Test suite for the run_agent operation."""
|
||||
|
||||
async def test_run_agent_executes_agent(self) -> None:
|
||||
"""Test that run_agent executes the agent."""
|
||||
mock_agent = Mock()
|
||||
mock_response = _agent_response("Test response")
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-1"}
|
||||
)
|
||||
|
||||
# Verify agent.run was called
|
||||
mock_agent.run.assert_called_once()
|
||||
_, kwargs = mock_agent.run.call_args
|
||||
sent_messages = kwargs.get("messages")
|
||||
assert isinstance(sent_messages, list)
|
||||
assert len(sent_messages) == 1
|
||||
sent_message = sent_messages[0]
|
||||
assert isinstance(sent_message, ChatMessage)
|
||||
assert sent_message.text == "Test message"
|
||||
assert _role_value(sent_message) == "user"
|
||||
|
||||
# Verify result
|
||||
assert result["status"] == "success"
|
||||
assert result["response"] == "Test response"
|
||||
assert result["message"] == "Test message"
|
||||
assert result["thread_id"] == "conv-123"
|
||||
|
||||
async def test_run_agent_streaming_callbacks_invoked(self) -> None:
|
||||
"""Ensure streaming updates trigger callbacks and run() is not used."""
|
||||
|
||||
updates = [
|
||||
AgentRunResponseUpdate(text="Hello"),
|
||||
AgentRunResponseUpdate(text=" world"),
|
||||
]
|
||||
|
||||
async def update_generator() -> AsyncIterator[AgentRunResponseUpdate]:
|
||||
for update in updates:
|
||||
yield update
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "StreamingAgent"
|
||||
mock_agent.run_stream = Mock(return_value=update_generator())
|
||||
mock_agent.run = AsyncMock(side_effect=AssertionError("run() should not be called when streaming succeeds"))
|
||||
|
||||
callback = RecordingCallback()
|
||||
entity = AgentEntity(mock_agent, callback=callback)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context,
|
||||
{
|
||||
"message": "Tell me something",
|
||||
"thread_id": "session-1",
|
||||
"correlation_id": "corr-stream-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert "Hello" in result.get("response", "")
|
||||
assert callback.stream_mock.await_count == len(updates)
|
||||
assert callback.response_mock.await_count == 1
|
||||
mock_agent.run.assert_not_called()
|
||||
|
||||
# Validate callback arguments
|
||||
stream_calls = callback.stream_mock.await_args_list
|
||||
for expected_update, recorded_call in zip(updates, stream_calls, strict=True):
|
||||
assert recorded_call.args[0] is expected_update
|
||||
context = recorded_call.args[1]
|
||||
assert context.agent_name == "StreamingAgent"
|
||||
assert context.correlation_id == "corr-stream-1"
|
||||
assert context.thread_id == "session-1"
|
||||
assert context.request_message == "Tell me something"
|
||||
|
||||
final_call = callback.response_mock.await_args
|
||||
assert final_call is not None
|
||||
final_response, final_context = final_call.args
|
||||
assert final_context.agent_name == "StreamingAgent"
|
||||
assert final_context.correlation_id == "corr-stream-1"
|
||||
assert final_context.thread_id == "session-1"
|
||||
assert final_context.request_message == "Tell me something"
|
||||
assert getattr(final_response, "text", "").strip()
|
||||
|
||||
async def test_run_agent_final_callback_without_streaming(self) -> None:
|
||||
"""Ensure the final callback fires even when streaming is unavailable."""
|
||||
|
||||
mock_agent = Mock()
|
||||
mock_agent.name = "NonStreamingAgent"
|
||||
mock_agent.run_stream = None
|
||||
agent_response = _agent_response("Final response")
|
||||
mock_agent.run = AsyncMock(return_value=agent_response)
|
||||
|
||||
callback = RecordingCallback()
|
||||
entity = AgentEntity(mock_agent, callback=callback)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context,
|
||||
{
|
||||
"message": "Hi",
|
||||
"thread_id": "session-2",
|
||||
"correlation_id": "corr-final-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result.get("response") == "Final response"
|
||||
assert callback.stream_mock.await_count == 0
|
||||
assert callback.response_mock.await_count == 1
|
||||
|
||||
final_call = callback.response_mock.await_args
|
||||
assert final_call is not None
|
||||
assert final_call.args[0] is agent_response
|
||||
final_context = final_call.args[1]
|
||||
assert final_context.agent_name == "NonStreamingAgent"
|
||||
assert final_context.correlation_id == "corr-final-1"
|
||||
assert final_context.thread_id == "session-2"
|
||||
assert final_context.request_message == "Hi"
|
||||
|
||||
async def test_run_agent_updates_conversation_history(self) -> None:
|
||||
"""Test that run_agent updates the conversation history."""
|
||||
mock_agent = Mock()
|
||||
mock_response = _agent_response("Agent response")
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "User message", "thread_id": "conv-1", "correlation_id": "corr-entity-2"}
|
||||
)
|
||||
|
||||
# Should have 2 entries: user message + assistant response
|
||||
history = entity.state.conversation_history
|
||||
|
||||
assert len(history) == 2
|
||||
|
||||
user_msg = history[0]
|
||||
assert _role_value(user_msg) == "user"
|
||||
assert user_msg.text == "User message"
|
||||
|
||||
assistant_msg = history[1]
|
||||
assert _role_value(assistant_msg) == "assistant"
|
||||
assert assistant_msg.text == "Agent response"
|
||||
|
||||
async def test_run_agent_increments_message_count(self) -> None:
|
||||
"""Test that run_agent increments the message count."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
assert entity.state.message_count == 0
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-3a"}
|
||||
)
|
||||
assert entity.state.message_count == 1
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-3b"}
|
||||
)
|
||||
assert entity.state.message_count == 2
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-3c"}
|
||||
)
|
||||
assert entity.state.message_count == 3
|
||||
|
||||
async def test_run_agent_stores_last_response(self) -> None:
|
||||
"""Test that run_agent stores the last response."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-4a"}
|
||||
)
|
||||
assert entity.state.last_response == "Response 1"
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-4b"}
|
||||
)
|
||||
assert entity.state.last_response == "Response 2"
|
||||
|
||||
async def test_run_agent_with_none_thread_id(self) -> None:
|
||||
"""Test run_agent with a None thread identifier."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="thread_id"):
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message", "thread_id": None, "correlation_id": "corr-entity-5"}
|
||||
)
|
||||
|
||||
async def test_run_agent_handles_response_without_text_attribute(self) -> None:
|
||||
"""Test that run_agent handles responses without a text attribute."""
|
||||
mock_agent = Mock()
|
||||
|
||||
class NoTextResponse(AgentRunResponse):
|
||||
@property
|
||||
def text(self) -> str: # type: ignore[override]
|
||||
raise AttributeError("text attribute missing")
|
||||
|
||||
mock_response = NoTextResponse(messages=[ChatMessage(role="assistant", text="ignored")])
|
||||
mock_agent.run = AsyncMock(return_value=mock_response)
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-6"}
|
||||
)
|
||||
|
||||
# Should handle gracefully
|
||||
assert result["status"] == "success"
|
||||
assert result["response"] == "Error extracting response"
|
||||
|
||||
async def test_run_agent_handles_none_response_text(self) -> None:
|
||||
"""Test that run_agent handles responses with None text."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response(None))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-7"}
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["response"] == "No response"
|
||||
|
||||
async def test_run_agent_multiple_conversations(self) -> None:
|
||||
"""Test that run_agent maintains history across multiple messages."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Send multiple messages
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-8a"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-8b"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-8c"}
|
||||
)
|
||||
|
||||
history = entity.state.conversation_history
|
||||
assert len(history) == 6
|
||||
assert entity.state.message_count == 3
|
||||
|
||||
|
||||
class TestAgentEntityReset:
|
||||
"""Test suite for the reset operation."""
|
||||
|
||||
def test_reset_clears_conversation_history(self) -> None:
|
||||
"""Test that reset clears the conversation history."""
|
||||
mock_agent = Mock()
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
# Add some history
|
||||
entity.state.conversation_history = [
|
||||
ChatMessage(role="user", text="msg1"),
|
||||
ChatMessage(role="assistant", text="resp1"),
|
||||
]
|
||||
|
||||
mock_context = Mock()
|
||||
entity.reset(mock_context)
|
||||
|
||||
assert entity.state.conversation_history == []
|
||||
|
||||
def test_reset_clears_last_response(self) -> None:
|
||||
"""Test that reset clears the last response."""
|
||||
mock_agent = Mock()
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
entity.state.last_response = "Some response"
|
||||
|
||||
mock_context = Mock()
|
||||
entity.reset(mock_context)
|
||||
|
||||
assert entity.state.last_response is None
|
||||
|
||||
def test_reset_clears_message_count(self) -> None:
|
||||
"""Test that reset clears the message count."""
|
||||
mock_agent = Mock()
|
||||
entity = AgentEntity(mock_agent)
|
||||
|
||||
entity.state.message_count = 10
|
||||
|
||||
mock_context = Mock()
|
||||
entity.reset(mock_context)
|
||||
|
||||
assert entity.state.message_count == 0
|
||||
|
||||
async def test_reset_after_conversation(self) -> None:
|
||||
"""Test reset after a full conversation."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Have a conversation
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-10a"}
|
||||
)
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-10b"}
|
||||
)
|
||||
|
||||
# Verify state before reset
|
||||
assert entity.state.message_count == 2
|
||||
assert len(entity.state.conversation_history) == 4
|
||||
|
||||
# Reset
|
||||
entity.reset(mock_context)
|
||||
|
||||
# Verify state after reset
|
||||
assert entity.state.message_count == 0
|
||||
assert len(entity.state.conversation_history) == 0
|
||||
assert entity.state.last_response is None
|
||||
|
||||
|
||||
class TestCreateAgentEntity:
|
||||
"""Test suite for the create_agent_entity factory function."""
|
||||
|
||||
def test_create_agent_entity_returns_callable(self) -> None:
|
||||
"""Test that create_agent_entity returns a callable."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
assert callable(entity_function)
|
||||
|
||||
def test_entity_function_handles_run_agent(self) -> None:
|
||||
"""Test that the entity function handles the run_agent operation."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-123",
|
||||
"correlation_id": "corr-entity-factory",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify result and state were set
|
||||
assert mock_context.set_result.called
|
||||
assert mock_context.set_state.called
|
||||
|
||||
def test_entity_function_handles_reset(self) -> None:
|
||||
"""Test that the entity function handles the reset operation."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
# Mock context with existing state
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "reset"
|
||||
mock_context.get_state.return_value = {
|
||||
"message_count": 5,
|
||||
"conversation_history": [
|
||||
ChatMessage(
|
||||
role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"}
|
||||
).to_dict()
|
||||
],
|
||||
"last_response": "Test",
|
||||
}
|
||||
|
||||
# Execute
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify reset result
|
||||
assert mock_context.set_result.called
|
||||
result = mock_context.set_result.call_args[0][0]
|
||||
assert result["status"] == "reset"
|
||||
|
||||
# Verify state was cleared
|
||||
assert mock_context.set_state.called
|
||||
state = mock_context.set_state.call_args[0][0]
|
||||
assert state["message_count"] == 0
|
||||
assert state["conversation_history"] == []
|
||||
assert state["last_response"] is None
|
||||
|
||||
def test_entity_function_handles_unknown_operation(self) -> None:
|
||||
"""Test that the entity function handles unknown operations."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "invalid_operation"
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify error result
|
||||
assert mock_context.set_result.called
|
||||
result = mock_context.set_result.call_args[0][0]
|
||||
assert "error" in result
|
||||
assert "invalid_operation" in result["error"].lower()
|
||||
|
||||
def test_entity_function_creates_new_entity_on_first_call(self) -> None:
|
||||
"""Test that the entity function creates a new entity when no state exists."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.__class__.__name__ = "Agent"
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "reset"
|
||||
mock_context.get_state.return_value = None # No existing state
|
||||
|
||||
# Execute
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify new entity state was created
|
||||
assert mock_context.set_result.called
|
||||
result = mock_context.set_result.call_args[0][0]
|
||||
assert result["status"] == "reset"
|
||||
assert mock_context.set_state.called
|
||||
state = mock_context.set_state.call_args[0][0]
|
||||
assert state["message_count"] == 0
|
||||
assert state["conversation_history"] == []
|
||||
|
||||
def test_entity_function_restores_existing_state(self) -> None:
|
||||
"""Test that the entity function restores existing state."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
existing_state = {
|
||||
"message_count": 5,
|
||||
"conversation_history": [
|
||||
ChatMessage(
|
||||
role="user", text="msg1", additional_properties={"timestamp": "2024-01-01T00:00:00Z"}
|
||||
).to_dict(),
|
||||
ChatMessage(
|
||||
role="assistant", text="resp1", additional_properties={"timestamp": "2024-01-01T00:05:00Z"}
|
||||
).to_dict(),
|
||||
],
|
||||
"last_response": "resp1",
|
||||
}
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "reset"
|
||||
mock_context.get_state.return_value = existing_state
|
||||
|
||||
with patch.object(AgentState, "restore_state") as restore_state_mock:
|
||||
entity_function(mock_context)
|
||||
|
||||
restore_state_mock.assert_called_once_with(existing_state)
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test suite for error handling in entities."""
|
||||
|
||||
async def test_run_agent_handles_agent_exception(self) -> None:
|
||||
"""Test that run_agent handles agent exceptions."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Agent failed"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-1"}
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "error" in result
|
||||
assert "Agent failed" in result["error"]
|
||||
assert result["error_type"] == "Exception"
|
||||
|
||||
async def test_run_agent_handles_value_error(self) -> None:
|
||||
"""Test that run_agent handles ValueError instances."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=ValueError("Invalid input"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-2"}
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["error_type"] == "ValueError"
|
||||
assert "Invalid input" in result["error"]
|
||||
|
||||
async def test_run_agent_handles_timeout_error(self) -> None:
|
||||
"""Test that run_agent handles TimeoutError instances."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=TimeoutError("Request timeout"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-3"}
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["error_type"] == "TimeoutError"
|
||||
|
||||
def test_entity_function_handles_exception_in_operation(self) -> None:
|
||||
"""Test that the entity function handles exceptions gracefully."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.side_effect = Exception("Input error")
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
# Execute - should not raise
|
||||
entity_function(mock_context)
|
||||
|
||||
# Verify error was set
|
||||
assert mock_context.set_result.called
|
||||
result = mock_context.set_result.call_args[0][0]
|
||||
assert "error" in result
|
||||
|
||||
async def test_run_agent_preserves_message_on_error(self) -> None:
|
||||
"""Test that run_agent preserves message information on error."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(side_effect=Exception("Error"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
result = await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-error-4"},
|
||||
)
|
||||
|
||||
# Even on error, message info should be preserved
|
||||
assert result["message"] == "Test message"
|
||||
assert result["thread_id"] == "conv-123"
|
||||
assert result["status"] == "error"
|
||||
|
||||
|
||||
class TestConversationHistory:
|
||||
"""Test suite for conversation history tracking."""
|
||||
|
||||
async def test_conversation_history_has_timestamps(self) -> None:
|
||||
"""Test that conversation history entries include timestamps."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-history-1"}
|
||||
)
|
||||
|
||||
# Check both user and assistant messages have timestamps
|
||||
for entry in entity.state.conversation_history:
|
||||
timestamp = entry.additional_properties.get("timestamp")
|
||||
assert timestamp is not None
|
||||
# Verify timestamp is in ISO format
|
||||
datetime.fromisoformat(timestamp)
|
||||
|
||||
async def test_conversation_history_ordering(self) -> None:
|
||||
"""Test that conversation history maintains the correct order."""
|
||||
mock_agent = Mock()
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Send multiple messages with different responses
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2a"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2b"},
|
||||
)
|
||||
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2c"},
|
||||
)
|
||||
|
||||
# Verify order
|
||||
history = entity.state.conversation_history
|
||||
assert history[0].text == "Message 1"
|
||||
assert history[1].text == "Response 1"
|
||||
assert history[2].text == "Message 2"
|
||||
assert history[3].text == "Response 2"
|
||||
assert history[4].text == "Message 3"
|
||||
assert history[5].text == "Response 3"
|
||||
|
||||
async def test_conversation_history_role_alternation(self) -> None:
|
||||
"""Test that conversation history alternates between user and assistant roles."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3a"},
|
||||
)
|
||||
await entity.run_agent(
|
||||
mock_context,
|
||||
{"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3b"},
|
||||
)
|
||||
|
||||
# Check role alternation
|
||||
history = entity.state.conversation_history
|
||||
assert _role_value(history[0]) == "user"
|
||||
assert _role_value(history[1]) == "assistant"
|
||||
assert _role_value(history[2]) == "user"
|
||||
assert _role_value(history[3]) == "assistant"
|
||||
|
||||
|
||||
class TestRunRequestSupport:
|
||||
"""Test suite for RunRequest support in entities."""
|
||||
|
||||
async def test_run_agent_with_run_request_object(self) -> None:
|
||||
"""Test run_agent with a RunRequest object."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
request = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="conv-123",
|
||||
role=Role.USER,
|
||||
enable_tool_calls=True,
|
||||
correlation_id="corr-runreq-1",
|
||||
)
|
||||
|
||||
result = await entity.run_agent(mock_context, request)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["response"] == "Response"
|
||||
assert result["message"] == "Test message"
|
||||
assert result["thread_id"] == "conv-123"
|
||||
|
||||
async def test_run_agent_with_dict_request(self) -> None:
|
||||
"""Test run_agent with a dictionary request."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
request_dict = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-456",
|
||||
"role": "system",
|
||||
"enable_tool_calls": False,
|
||||
"correlation_id": "corr-runreq-2",
|
||||
}
|
||||
|
||||
result = await entity.run_agent(mock_context, request_dict)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["message"] == "Test message"
|
||||
assert result["thread_id"] == "conv-456"
|
||||
|
||||
async def test_run_agent_with_string_raises_without_correlation(self) -> None:
|
||||
"""Test that run_agent rejects legacy string input without correlation ID."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await entity.run_agent(mock_context, "Simple message")
|
||||
|
||||
async def test_run_agent_stores_role_in_history(self) -> None:
|
||||
"""Test that run_agent stores the role in conversation history."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
# Send as system role
|
||||
request = RunRequest(
|
||||
message="System message",
|
||||
thread_id="conv-runreq-3",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-runreq-3",
|
||||
)
|
||||
|
||||
await entity.run_agent(mock_context, request)
|
||||
|
||||
# Check that system role was stored
|
||||
history = entity.state.conversation_history
|
||||
assert _role_value(history[0]) == "system"
|
||||
assert history[0].text == "System message"
|
||||
|
||||
async def test_run_agent_with_response_format(self) -> None:
|
||||
"""Test run_agent with a JSON response format."""
|
||||
mock_agent = Mock()
|
||||
# Return JSON response
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response('{"answer": 42}'))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
request = RunRequest(
|
||||
message="What is the answer?",
|
||||
thread_id="conv-runreq-4",
|
||||
response_format=EntityStructuredResponse,
|
||||
correlation_id="corr-runreq-4",
|
||||
)
|
||||
|
||||
result = await entity.run_agent(mock_context, request)
|
||||
|
||||
assert result["status"] == "success"
|
||||
# Should have structured_response
|
||||
if "structured_response" in result:
|
||||
assert result["structured_response"]["answer"] == 42
|
||||
|
||||
async def test_run_agent_disable_tool_calls(self) -> None:
|
||||
"""Test run_agent with tool calls disabled."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity = AgentEntity(mock_agent)
|
||||
mock_context = Mock()
|
||||
|
||||
request = RunRequest(
|
||||
message="Test", thread_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5"
|
||||
)
|
||||
|
||||
result = await entity.run_agent(mock_context, request)
|
||||
|
||||
assert result["status"] == "success"
|
||||
# Agent should have been called (tool disabling is framework-dependent)
|
||||
mock_agent.run.assert_called_once()
|
||||
|
||||
async def test_entity_function_with_run_request_dict(self) -> None:
|
||||
"""Test that the entity function handles the RunRequest dict format."""
|
||||
mock_agent = Mock()
|
||||
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
|
||||
|
||||
entity_function = create_agent_entity(mock_agent)
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.operation_name = "run_agent"
|
||||
mock_context.get_input.return_value = {
|
||||
"message": "Test message",
|
||||
"thread_id": "conv-789",
|
||||
"role": "user",
|
||||
"enable_tool_calls": True,
|
||||
"correlation_id": "corr-runreq-6",
|
||||
}
|
||||
mock_context.get_state.return_value = None
|
||||
|
||||
await asyncio.to_thread(entity_function, mock_context)
|
||||
|
||||
# Verify result was set
|
||||
assert mock_context.set_result.called
|
||||
result = mock_context.set_result.call_args[0][0]
|
||||
assert result["status"] == "success"
|
||||
assert result["message"] == "Test message"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,470 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for data models (AgentSessionId, RunRequest, AgentResponse)."""
|
||||
|
||||
import azure.durable_functions as df
|
||||
import pytest
|
||||
from agent_framework import Role
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_azurefunctions._models import AgentResponse, AgentSessionId, RunRequest
|
||||
|
||||
|
||||
class ModuleStructuredResponse(BaseModel):
|
||||
value: int
|
||||
|
||||
|
||||
class TestAgentSessionId:
|
||||
"""Test suite for AgentSessionId."""
|
||||
|
||||
def test_init_creates_session_id(self) -> None:
|
||||
"""Test that AgentSessionId initializes correctly."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key-123")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_with_random_key_generates_guid(self) -> None:
|
||||
"""Test that with_random_key generates a GUID."""
|
||||
session_id = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert len(session_id.key) == 32 # UUID hex is 32 chars
|
||||
# Verify it's a valid hex string
|
||||
int(session_id.key, 16)
|
||||
|
||||
def test_with_random_key_unique_keys(self) -> None:
|
||||
"""Test that with_random_key generates unique keys."""
|
||||
session_id1 = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
session_id2 = AgentSessionId.with_random_key(name="AgentEntity")
|
||||
|
||||
assert session_id1.key != session_id2.key
|
||||
|
||||
def test_to_entity_id_conversion(self) -> None:
|
||||
"""Test conversion to EntityId."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
entity_id = session_id.to_entity_id()
|
||||
|
||||
assert isinstance(entity_id, df.EntityId)
|
||||
assert entity_id.name == "dafx-AgentEntity"
|
||||
assert entity_id.key == "test-key"
|
||||
|
||||
def test_from_entity_id_conversion(self) -> None:
|
||||
"""Test creation from EntityId."""
|
||||
entity_id = df.EntityId(name="dafx-AgentEntity", key="test-key")
|
||||
session_id = AgentSessionId.from_entity_id(entity_id)
|
||||
|
||||
assert isinstance(session_id, AgentSessionId)
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "test-key"
|
||||
|
||||
def test_round_trip_entity_id_conversion(self) -> None:
|
||||
"""Test round-trip conversion to and from EntityId."""
|
||||
original = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
entity_id = original.to_entity_id()
|
||||
restored = AgentSessionId.from_entity_id(entity_id)
|
||||
|
||||
assert restored.name == original.name
|
||||
assert restored.key == original.key
|
||||
|
||||
def test_str_representation(self) -> None:
|
||||
"""Test string representation."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key-123")
|
||||
str_repr = str(session_id)
|
||||
|
||||
assert str_repr == "@AgentEntity@test-key-123"
|
||||
|
||||
def test_repr_representation(self) -> None:
|
||||
"""Test repr representation."""
|
||||
session_id = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
repr_str = repr(session_id)
|
||||
|
||||
assert "AgentSessionId" in repr_str
|
||||
assert "AgentEntity" in repr_str
|
||||
assert "test-key" in repr_str
|
||||
|
||||
def test_parse_valid_session_id(self) -> None:
|
||||
"""Test parsing valid session ID string."""
|
||||
session_id = AgentSessionId.parse("@AgentEntity@test-key-123")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "test-key-123"
|
||||
|
||||
def test_parse_invalid_format_no_prefix(self) -> None:
|
||||
"""Test parsing invalid format without @ prefix."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.parse("AgentEntity@test-key")
|
||||
|
||||
assert "Invalid agent session ID format" in str(exc_info.value)
|
||||
|
||||
def test_parse_invalid_format_single_part(self) -> None:
|
||||
"""Test parsing invalid format with single part."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.parse("@AgentEntity")
|
||||
|
||||
assert "Invalid agent session ID format" in str(exc_info.value)
|
||||
|
||||
def test_parse_with_multiple_at_signs_in_key(self) -> None:
|
||||
"""Test parsing with @ signs in the key."""
|
||||
session_id = AgentSessionId.parse("@AgentEntity@key-with@symbols")
|
||||
|
||||
assert session_id.name == "AgentEntity"
|
||||
assert session_id.key == "key-with@symbols"
|
||||
|
||||
def test_parse_round_trip(self) -> None:
|
||||
"""Test round-trip parse and string conversion."""
|
||||
original = AgentSessionId(name="AgentEntity", key="test-key")
|
||||
str_repr = str(original)
|
||||
parsed = AgentSessionId.parse(str_repr)
|
||||
|
||||
assert parsed.name == original.name
|
||||
assert parsed.key == original.key
|
||||
|
||||
def test_to_entity_name_adds_prefix(self) -> None:
|
||||
"""Test that to_entity_name adds the dafx- prefix."""
|
||||
entity_name = AgentSessionId.to_entity_name("TestAgent")
|
||||
assert entity_name == "dafx-TestAgent"
|
||||
|
||||
def test_from_entity_id_strips_prefix(self) -> None:
|
||||
"""Test that from_entity_id strips the dafx- prefix."""
|
||||
entity_id = df.EntityId(name="dafx-TestAgent", key="key123")
|
||||
session_id = AgentSessionId.from_entity_id(entity_id)
|
||||
|
||||
assert session_id.name == "TestAgent"
|
||||
assert session_id.key == "key123"
|
||||
|
||||
def test_from_entity_id_raises_without_prefix(self) -> None:
|
||||
"""Test that from_entity_id raises ValueError when entity name lacks the prefix."""
|
||||
entity_id = df.EntityId(name="TestAgent", key="key123")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AgentSessionId.from_entity_id(entity_id)
|
||||
|
||||
assert "not a valid agent session ID" in str(exc_info.value)
|
||||
assert "dafx-" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestRunRequest:
|
||||
"""Test suite for RunRequest."""
|
||||
|
||||
def test_init_with_defaults(self) -> None:
|
||||
"""Test RunRequest initialization with defaults."""
|
||||
request = RunRequest(message="Hello", thread_id="thread-default")
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.USER
|
||||
assert request.response_format is None
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.thread_id == "thread-default"
|
||||
|
||||
def test_init_with_all_fields(self) -> None:
|
||||
"""Test RunRequest initialization with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is schema
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.thread_id == "thread-123"
|
||||
|
||||
def test_init_coerces_string_role(self) -> None:
|
||||
"""Ensure string role values are coerced into Role instances."""
|
||||
request = RunRequest(message="Hello", thread_id="thread-str-role", role="system") # type: ignore[arg-type]
|
||||
|
||||
assert request.role == Role.SYSTEM
|
||||
|
||||
def test_to_dict_with_defaults(self) -> None:
|
||||
"""Test to_dict with default values."""
|
||||
request = RunRequest(message="Test message", thread_id="thread-to-dict")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test message"
|
||||
assert data["enable_tool_calls"] is True
|
||||
assert data["role"] == "user"
|
||||
assert "response_format" not in data or data["response_format"] is None
|
||||
assert data["thread_id"] == "thread-to-dict"
|
||||
|
||||
def test_to_dict_with_all_fields(self) -> None:
|
||||
"""Test to_dict with all fields."""
|
||||
schema = ModuleStructuredResponse
|
||||
request = RunRequest(
|
||||
message="Hello",
|
||||
thread_id="thread-456",
|
||||
role=Role.ASSISTANT,
|
||||
response_format=schema,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Hello"
|
||||
assert data["role"] == "assistant"
|
||||
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert data["response_format"]["module"] == schema.__module__
|
||||
assert data["response_format"]["qualname"] == schema.__qualname__
|
||||
assert data["enable_tool_calls"] is False
|
||||
assert data["thread_id"] == "thread-456"
|
||||
|
||||
def test_from_dict_with_defaults(self) -> None:
|
||||
"""Test from_dict with minimal data."""
|
||||
data = {"message": "Hello", "thread_id": "thread-from-dict"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Hello"
|
||||
assert request.role == Role.USER
|
||||
assert request.enable_tool_calls is True
|
||||
assert request.thread_id == "thread-from-dict"
|
||||
|
||||
def test_from_dict_with_all_fields(self) -> None:
|
||||
"""Test from_dict with all fields."""
|
||||
data = {
|
||||
"message": "Test",
|
||||
"role": "system",
|
||||
"response_format": {
|
||||
"__response_schema_type__": "pydantic_model",
|
||||
"module": ModuleStructuredResponse.__module__,
|
||||
"qualname": ModuleStructuredResponse.__qualname__,
|
||||
},
|
||||
"enable_tool_calls": False,
|
||||
"thread_id": "thread-789",
|
||||
}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.role == Role.SYSTEM
|
||||
assert request.response_format is ModuleStructuredResponse
|
||||
assert request.enable_tool_calls is False
|
||||
assert request.thread_id == "thread-789"
|
||||
|
||||
def test_from_dict_with_unknown_role_preserves_value(self) -> None:
|
||||
"""Test from_dict keeps custom roles intact."""
|
||||
data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.role.value == "reviewer"
|
||||
assert request.role != Role.USER
|
||||
|
||||
def test_from_dict_empty_message(self) -> None:
|
||||
"""Test from_dict with empty message."""
|
||||
data = {"thread_id": "thread-empty"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == ""
|
||||
assert request.role == Role.USER
|
||||
assert request.thread_id == "thread-empty"
|
||||
|
||||
def test_round_trip_dict_conversion(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
response_format=ModuleStructuredResponse,
|
||||
enable_tool_calls=False,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
assert restored.enable_tool_calls == original.enable_tool_calls
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
def test_round_trip_with_pydantic_response_format(self) -> None:
|
||||
"""Ensure Pydantic response formats serialize and deserialize properly."""
|
||||
original = RunRequest(
|
||||
message="Structured",
|
||||
thread_id="thread-pydantic",
|
||||
response_format=ModuleStructuredResponse,
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
|
||||
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert data["response_format"]["module"] == ModuleStructuredResponse.__module__
|
||||
assert data["response_format"]["qualname"] == ModuleStructuredResponse.__qualname__
|
||||
|
||||
restored = RunRequest.from_dict(data)
|
||||
assert restored.response_format is ModuleStructuredResponse
|
||||
|
||||
def test_init_with_correlation_id(self) -> None:
|
||||
"""Test RunRequest initialization with correlation_id."""
|
||||
request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123")
|
||||
|
||||
assert request.message == "Test message"
|
||||
assert request.correlation_id == "corr-123"
|
||||
|
||||
def test_to_dict_with_correlation_id(self) -> None:
|
||||
"""Test to_dict includes correlation_id."""
|
||||
request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456")
|
||||
data = request.to_dict()
|
||||
|
||||
assert data["message"] == "Test"
|
||||
assert data["correlation_id"] == "corr-456"
|
||||
|
||||
def test_from_dict_with_correlation_id(self) -> None:
|
||||
"""Test from_dict with correlation_id."""
|
||||
data = {"message": "Test", "correlation_id": "corr-789", "thread_id": "thread-corr-from-dict"}
|
||||
request = RunRequest.from_dict(data)
|
||||
|
||||
assert request.message == "Test"
|
||||
assert request.correlation_id == "corr-789"
|
||||
assert request.thread_id == "thread-corr-from-dict"
|
||||
|
||||
def test_round_trip_with_correlation_id(self) -> None:
|
||||
"""Test round-trip to_dict and from_dict with correlation_id."""
|
||||
original = RunRequest(
|
||||
message="Test message",
|
||||
thread_id="thread-123",
|
||||
role=Role.SYSTEM,
|
||||
correlation_id="corr-123",
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = RunRequest.from_dict(data)
|
||||
|
||||
assert restored.message == original.message
|
||||
assert restored.role == original.role
|
||||
assert restored.correlation_id == original.correlation_id
|
||||
assert restored.thread_id == original.thread_id
|
||||
|
||||
|
||||
class TestAgentResponse:
|
||||
"""Test suite for AgentResponse."""
|
||||
|
||||
def test_init_with_required_fields(self) -> None:
|
||||
"""Test AgentResponse initialization with required fields."""
|
||||
response = AgentResponse(
|
||||
response="Test response", message="Test message", thread_id="thread-123", status="success"
|
||||
)
|
||||
|
||||
assert response.response == "Test response"
|
||||
assert response.message == "Test message"
|
||||
assert response.thread_id == "thread-123"
|
||||
assert response.status == "success"
|
||||
assert response.message_count == 0
|
||||
assert response.error is None
|
||||
assert response.error_type is None
|
||||
assert response.structured_response is None
|
||||
|
||||
def test_init_with_all_fields(self) -> None:
|
||||
"""Test AgentResponse initialization with all fields."""
|
||||
structured = {"answer": "42"}
|
||||
response = AgentResponse(
|
||||
response=None,
|
||||
message="What is the answer?",
|
||||
thread_id="thread-456",
|
||||
status="success",
|
||||
message_count=5,
|
||||
error=None,
|
||||
error_type=None,
|
||||
structured_response=structured,
|
||||
)
|
||||
|
||||
assert response.response is None
|
||||
assert response.structured_response == structured
|
||||
assert response.message_count == 5
|
||||
|
||||
def test_to_dict_with_text_response(self) -> None:
|
||||
"""Test to_dict with text response."""
|
||||
response = AgentResponse(
|
||||
response="Text response", message="Message", thread_id="thread-1", status="success", message_count=3
|
||||
)
|
||||
data = response.to_dict()
|
||||
|
||||
assert data["response"] == "Text response"
|
||||
assert data["message"] == "Message"
|
||||
assert data["thread_id"] == "thread-1"
|
||||
assert data["status"] == "success"
|
||||
assert data["message_count"] == 3
|
||||
assert "structured_response" not in data
|
||||
assert "error" not in data
|
||||
assert "error_type" not in data
|
||||
|
||||
def test_to_dict_with_structured_response(self) -> None:
|
||||
"""Test to_dict with structured response."""
|
||||
structured = {"answer": 42, "confidence": 0.95}
|
||||
response = AgentResponse(
|
||||
response=None,
|
||||
message="Question",
|
||||
thread_id="thread-2",
|
||||
status="success",
|
||||
structured_response=structured,
|
||||
)
|
||||
data = response.to_dict()
|
||||
|
||||
assert data["structured_response"] == structured
|
||||
assert "response" not in data
|
||||
|
||||
def test_to_dict_with_error(self) -> None:
|
||||
"""Test to_dict with error."""
|
||||
response = AgentResponse(
|
||||
response=None,
|
||||
message="Failed message",
|
||||
thread_id="thread-3",
|
||||
status="error",
|
||||
error="Something went wrong",
|
||||
error_type="ValueError",
|
||||
)
|
||||
data = response.to_dict()
|
||||
|
||||
assert data["status"] == "error"
|
||||
assert data["error"] == "Something went wrong"
|
||||
assert data["error_type"] == "ValueError"
|
||||
|
||||
def test_to_dict_prefers_structured_over_text(self) -> None:
|
||||
"""Test to_dict prefers structured_response over response."""
|
||||
structured = {"result": "structured"}
|
||||
response = AgentResponse(
|
||||
response="Text response",
|
||||
message="Message",
|
||||
thread_id="thread-4",
|
||||
status="success",
|
||||
structured_response=structured,
|
||||
)
|
||||
data = response.to_dict()
|
||||
|
||||
assert "structured_response" in data
|
||||
assert data["structured_response"] == structured
|
||||
# Text response should not be included when structured is present
|
||||
assert "response" not in data
|
||||
|
||||
|
||||
class TestModelIntegration:
|
||||
"""Test suite for integration between models."""
|
||||
|
||||
def test_run_request_with_session_id(self) -> None:
|
||||
"""Test using RunRequest with AgentSessionId."""
|
||||
session_id = AgentSessionId.with_random_key("AgentEntity")
|
||||
request = RunRequest(message="Test message", thread_id=str(session_id))
|
||||
|
||||
assert request.thread_id is not None
|
||||
assert request.thread_id == str(session_id)
|
||||
assert request.thread_id.startswith("@AgentEntity@")
|
||||
|
||||
def test_response_from_run_request(self) -> None:
|
||||
"""Test creating AgentResponse from RunRequest."""
|
||||
request = RunRequest(message="What is 2+2?", thread_id="thread-123", role=Role.USER)
|
||||
|
||||
response = AgentResponse(
|
||||
response="4",
|
||||
message=request.message,
|
||||
thread_id=request.thread_id,
|
||||
status="success",
|
||||
message_count=1,
|
||||
)
|
||||
|
||||
assert response.message == request.message
|
||||
assert response.thread_id == request.thread_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,150 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for multi-agent support in AgentFunctionApp."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
|
||||
|
||||
class TestMultiAgentInit:
|
||||
"""Test suite for multi-agent initialization."""
|
||||
|
||||
def test_init_with_agents_list(self) -> None:
|
||||
"""Test initialization with list of agents."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "Agent1"
|
||||
agent2 = Mock()
|
||||
agent2.name = "Agent2"
|
||||
|
||||
app = AgentFunctionApp(agents=[agent1, agent2])
|
||||
|
||||
assert len(app.agents) == 2
|
||||
assert "Agent1" in app.agents
|
||||
assert "Agent2" in app.agents
|
||||
assert app.agents["Agent1"] == agent1
|
||||
assert app.agents["Agent2"] == agent2
|
||||
|
||||
def test_init_with_empty_agents_list(self) -> None:
|
||||
"""Test initialization with empty list of agents."""
|
||||
app = AgentFunctionApp(agents=[])
|
||||
|
||||
assert len(app.agents) == 0
|
||||
|
||||
def test_init_with_no_agents(self) -> None:
|
||||
"""Test initialization without any agents."""
|
||||
app = AgentFunctionApp()
|
||||
|
||||
assert len(app.agents) == 0
|
||||
|
||||
def test_init_with_duplicate_agent_names(self) -> None:
|
||||
"""Test initialization with agents having the same name raises error."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "TestAgent"
|
||||
agent2 = Mock()
|
||||
agent2.name = "TestAgent"
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
AgentFunctionApp(agents=[agent1, agent2])
|
||||
|
||||
def test_init_with_agent_without_name(self) -> None:
|
||||
"""Test initialization with agent missing name attribute raises error."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "Agent1"
|
||||
agent2 = Mock(spec=[]) # Mock without name attribute
|
||||
|
||||
with pytest.raises(ValueError, match="does not have a 'name' attribute"):
|
||||
AgentFunctionApp(agents=[agent1, agent2])
|
||||
|
||||
|
||||
class TestAddAgentMethod:
|
||||
"""Test suite for add_agent() method."""
|
||||
|
||||
def test_add_agent_to_empty_app(self) -> None:
|
||||
"""Test adding agent to app initialized without agents."""
|
||||
app = AgentFunctionApp()
|
||||
|
||||
agent = Mock()
|
||||
agent.name = "NewAgent"
|
||||
|
||||
app.add_agent(agent)
|
||||
|
||||
assert len(app.agents) == 1
|
||||
assert "NewAgent" in app.agents
|
||||
assert app.agents["NewAgent"] == agent
|
||||
|
||||
def test_add_multiple_agents(self) -> None:
|
||||
"""Test adding multiple agents sequentially."""
|
||||
app = AgentFunctionApp()
|
||||
|
||||
agent1 = Mock()
|
||||
agent1.name = "Agent1"
|
||||
agent2 = Mock()
|
||||
agent2.name = "Agent2"
|
||||
|
||||
app.add_agent(agent1)
|
||||
app.add_agent(agent2)
|
||||
|
||||
assert len(app.agents) == 2
|
||||
assert "Agent1" in app.agents
|
||||
assert "Agent2" in app.agents
|
||||
|
||||
def test_add_agent_with_duplicate_name_raises_error(self) -> None:
|
||||
"""Test that adding agent with duplicate name raises ValueError."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "MyAgent"
|
||||
agent2 = Mock()
|
||||
agent2.name = "MyAgent"
|
||||
|
||||
app = AgentFunctionApp(agents=[agent1])
|
||||
|
||||
# Try to add another agent with the same name
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
app.add_agent(agent2)
|
||||
|
||||
def test_add_agent_to_app_with_existing_agents(self) -> None:
|
||||
"""Test adding agent to app that already has agents."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "Agent1"
|
||||
agent2 = Mock()
|
||||
agent2.name = "Agent2"
|
||||
|
||||
app = AgentFunctionApp(agents=[agent1])
|
||||
app.add_agent(agent2)
|
||||
|
||||
assert len(app.agents) == 2
|
||||
assert "Agent1" in app.agents
|
||||
assert "Agent2" in app.agents
|
||||
|
||||
def test_add_agent_without_name_raises_error(self) -> None:
|
||||
"""Test that adding agent without name attribute raises error."""
|
||||
app = AgentFunctionApp()
|
||||
|
||||
agent = Mock(spec=[]) # Mock without name attribute
|
||||
|
||||
with pytest.raises(ValueError, match="does not have a 'name' attribute"):
|
||||
app.add_agent(agent)
|
||||
|
||||
|
||||
class TestHealthCheckWithMultipleAgents:
|
||||
"""Test suite for health check with multiple agents."""
|
||||
|
||||
def test_health_check_returns_all_agents(self) -> None:
|
||||
"""Test that health check returns information about all agents."""
|
||||
agent1 = Mock()
|
||||
agent1.name = "Agent1"
|
||||
agent2 = Mock()
|
||||
agent2.name = "Agent2"
|
||||
|
||||
app = AgentFunctionApp(agents=[agent1, agent2])
|
||||
|
||||
# Note: We can't easily test the actual health check endpoint without running the app
|
||||
# But we can verify the agents dictionary is properly populated
|
||||
assert len(app.agents) == 2
|
||||
assert app.enable_health_check is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,442 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for orchestration support (DurableAIAgent)."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentThread
|
||||
|
||||
from agent_framework_azurefunctions import AgentFunctionApp, DurableAIAgent
|
||||
from agent_framework_azurefunctions._models import AgentSessionId, DurableAgentThread
|
||||
|
||||
|
||||
def _app_with_registered_agents(*agent_names: str) -> AgentFunctionApp:
|
||||
app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=False)
|
||||
for name in agent_names:
|
||||
agent = Mock()
|
||||
agent.name = name
|
||||
app.add_agent(agent)
|
||||
return app
|
||||
|
||||
|
||||
class TestDurableAIAgent:
|
||||
"""Test suite for DurableAIAgent wrapper."""
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Test DurableAIAgent initialization."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-123"
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
assert agent.context == mock_context
|
||||
assert agent.agent_name == "TestAgent"
|
||||
|
||||
def test_implements_agent_protocol(self) -> None:
|
||||
"""Test that DurableAIAgent implements AgentProtocol."""
|
||||
from agent_framework import AgentProtocol
|
||||
|
||||
mock_context = Mock()
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
# Check that agent satisfies AgentProtocol
|
||||
assert isinstance(agent, AgentProtocol)
|
||||
|
||||
def test_has_agent_protocol_properties(self) -> None:
|
||||
"""Test that DurableAIAgent has AgentProtocol properties."""
|
||||
mock_context = Mock()
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
# AgentProtocol properties
|
||||
assert hasattr(agent, "id")
|
||||
assert hasattr(agent, "name")
|
||||
assert hasattr(agent, "description")
|
||||
assert hasattr(agent, "display_name")
|
||||
|
||||
# Verify values
|
||||
assert agent.name == "TestAgent"
|
||||
assert agent.description == "Durable agent proxy for TestAgent"
|
||||
assert agent.display_name == "TestAgent"
|
||||
assert agent.id is not None # Auto-generated UUID
|
||||
|
||||
def test_get_new_thread(self) -> None:
|
||||
"""Test creating a new agent thread."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-456"
|
||||
mock_context.new_uuid = Mock(return_value="test-guid-456")
|
||||
|
||||
agent = DurableAIAgent(mock_context, "WriterAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
assert thread.session_id is not None
|
||||
session_id = thread.session_id
|
||||
assert isinstance(session_id, AgentSessionId)
|
||||
assert session_id.name == "WriterAgent"
|
||||
assert session_id.key == "test-guid-456"
|
||||
mock_context.new_uuid.assert_called_once()
|
||||
|
||||
def test_get_new_thread_deterministic(self) -> None:
|
||||
"""Test that get_new_thread creates deterministic session IDs."""
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-789"
|
||||
mock_context.new_uuid = Mock(side_effect=["session-guid-1", "session-guid-2"])
|
||||
|
||||
agent = DurableAIAgent(mock_context, "EditorAgent")
|
||||
|
||||
# Create multiple threads - they should have unique session IDs
|
||||
thread1 = agent.get_new_thread()
|
||||
thread2 = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread1, DurableAgentThread)
|
||||
assert isinstance(thread2, DurableAgentThread)
|
||||
|
||||
session_id1 = thread1.session_id
|
||||
session_id2 = thread2.session_id
|
||||
assert session_id1 is not None and session_id2 is not None
|
||||
assert isinstance(session_id1, AgentSessionId)
|
||||
assert isinstance(session_id2, AgentSessionId)
|
||||
assert session_id1.name == "EditorAgent"
|
||||
assert session_id2.name == "EditorAgent"
|
||||
assert session_id1.key == "session-guid-1"
|
||||
assert session_id2.key == "session-guid-2"
|
||||
assert mock_context.new_uuid.call_count == 2
|
||||
|
||||
def test_run_creates_entity_call(self) -> None:
|
||||
"""Test that run() creates proper entity call and returns a Task."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-001"
|
||||
mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"])
|
||||
|
||||
# Mock call_entity to return a Task-like object
|
||||
mock_task = Mock()
|
||||
mock_task._is_scheduled = False # Task attribute that orchestration checks
|
||||
|
||||
mock_context.call_entity = Mock(return_value=mock_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
# Create thread
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Call run() - it should return the Task directly
|
||||
task = agent.run(messages="Test message", thread=thread, enable_tool_calls=True)
|
||||
|
||||
# Verify run() returns the Task from call_entity
|
||||
assert task == mock_task
|
||||
|
||||
# Verify call_entity was called with correct parameters
|
||||
assert mock_context.call_entity.called
|
||||
call_args = mock_context.call_entity.call_args
|
||||
entity_id, operation, request = call_args[0]
|
||||
|
||||
assert operation == "run_agent"
|
||||
assert request["message"] == "Test message"
|
||||
assert request["enable_tool_calls"] is True
|
||||
assert "correlation_id" in request
|
||||
assert request["correlation_id"] == "correlation-guid"
|
||||
assert "thread_id" in request
|
||||
assert request["thread_id"] == "thread-guid"
|
||||
|
||||
def test_run_without_thread(self) -> None:
|
||||
"""Test that run() works without explicit thread (creates unique session key)."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-002"
|
||||
# Two calls to new_uuid: one for session_key, one for correlation_id
|
||||
mock_context.new_uuid = Mock(side_effect=["auto-generated-guid", "correlation-guid"])
|
||||
|
||||
mock_task = Mock()
|
||||
mock_task._is_scheduled = False
|
||||
mock_context.call_entity = Mock(return_value=mock_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
# Call without thread
|
||||
task = agent.run(messages="Test message")
|
||||
|
||||
assert task == mock_task
|
||||
|
||||
# Verify the entity ID uses the auto-generated GUID with dafx- prefix
|
||||
call_args = mock_context.call_entity.call_args
|
||||
entity_id = call_args[0][0]
|
||||
assert entity_id.name == "dafx-TestAgent"
|
||||
assert entity_id.key == "auto-generated-guid"
|
||||
# Should be called twice: once for session_key, once for correlation_id
|
||||
assert mock_context.new_uuid.call_count == 2
|
||||
|
||||
def test_run_with_response_format(self) -> None:
|
||||
"""Test that run() passes response format correctly."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-003"
|
||||
|
||||
mock_task = Mock()
|
||||
mock_task._is_scheduled = False
|
||||
mock_context.call_entity = Mock(return_value=mock_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SampleSchema(BaseModel):
|
||||
key: str
|
||||
|
||||
# Create thread and call
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
task = agent.run(messages="Test message", thread=thread, response_format=SampleSchema)
|
||||
|
||||
assert task == mock_task
|
||||
|
||||
# Verify schema was passed in the call_entity arguments
|
||||
call_args = mock_context.call_entity.call_args
|
||||
input_data = call_args[0][2] # Third argument is input_data
|
||||
assert "response_format" in input_data
|
||||
assert input_data["response_format"]["__response_schema_type__"] == "pydantic_model"
|
||||
assert input_data["response_format"]["module"] == SampleSchema.__module__
|
||||
assert input_data["response_format"]["qualname"] == SampleSchema.__qualname__
|
||||
|
||||
def test_messages_to_string(self) -> None:
|
||||
"""Test converting ChatMessage list to string."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
mock_context = Mock()
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="user", text="Hello"),
|
||||
ChatMessage(role="assistant", text="Hi there"),
|
||||
ChatMessage(role="user", text="How are you?"),
|
||||
]
|
||||
|
||||
result = agent._messages_to_string(messages)
|
||||
|
||||
assert result == "Hello\nHi there\nHow are you?"
|
||||
|
||||
def test_run_with_chat_message(self) -> None:
|
||||
"""Test that run() handles ChatMessage input."""
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"])
|
||||
mock_task = Mock()
|
||||
mock_context.call_entity = Mock(return_value=mock_task)
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Call with ChatMessage
|
||||
msg = ChatMessage(role="user", text="Hello")
|
||||
task = agent.run(messages=msg, thread=thread)
|
||||
|
||||
assert task == mock_task
|
||||
|
||||
# Verify message was converted to string
|
||||
call_args = mock_context.call_entity.call_args
|
||||
request = call_args[0][2]
|
||||
assert request["message"] == "Hello"
|
||||
|
||||
def test_run_stream_raises_not_implemented(self) -> None:
|
||||
"""Test that run_stream() method raises NotImplementedError."""
|
||||
mock_context = Mock()
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
|
||||
with pytest.raises(NotImplementedError) as exc_info:
|
||||
agent.run_stream("Test message")
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "Streaming is not supported" in error_msg
|
||||
|
||||
def test_entity_id_format(self) -> None:
|
||||
"""Test that EntityId is created with correct format (name, key)."""
|
||||
from azure.durable_functions import EntityId
|
||||
|
||||
mock_context = Mock()
|
||||
mock_context.new_uuid = Mock(return_value="test-guid-789")
|
||||
mock_context.call_entity = Mock(return_value=Mock())
|
||||
|
||||
agent = DurableAIAgent(mock_context, "WriterAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Call run() to trigger entity ID creation
|
||||
agent.run("Test", thread=thread)
|
||||
|
||||
# Verify call_entity was called with correct EntityId
|
||||
call_args = mock_context.call_entity.call_args
|
||||
entity_id = call_args[0][0]
|
||||
|
||||
# EntityId should be EntityId(name="dafx-WriterAgent", key="test-guid-789")
|
||||
# Which formats as "@dafx-writeragent@test-guid-789"
|
||||
assert isinstance(entity_id, EntityId)
|
||||
assert entity_id.name == "dafx-WriterAgent"
|
||||
assert entity_id.key == "test-guid-789"
|
||||
assert str(entity_id) == "@dafx-writeragent@test-guid-789"
|
||||
|
||||
|
||||
class TestAgentFunctionAppGetAgent:
|
||||
"""Test suite for AgentFunctionApp.get_agent."""
|
||||
|
||||
def test_get_agent_method(self) -> None:
|
||||
"""Test get_agent method creates DurableAIAgent for registered agent."""
|
||||
app = _app_with_registered_agents("MyAgent")
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-100"
|
||||
|
||||
agent = app.get_agent(mock_context, "MyAgent")
|
||||
|
||||
assert isinstance(agent, DurableAIAgent)
|
||||
assert agent.agent_name == "MyAgent"
|
||||
assert agent.context == mock_context
|
||||
|
||||
def test_get_agent_raises_for_unregistered_agent(self) -> None:
|
||||
"""Test get_agent raises ValueError when agent is not registered."""
|
||||
app = _app_with_registered_agents("KnownAgent")
|
||||
|
||||
with pytest.raises(ValueError, match=r"Agent 'MissingAgent' is not registered with this app\."):
|
||||
app.get_agent(Mock(), "MissingAgent")
|
||||
|
||||
|
||||
class TestOrchestrationIntegration:
|
||||
"""Integration tests for orchestration scenarios."""
|
||||
|
||||
def test_sequential_agent_calls_simulation(self) -> None:
|
||||
"""Simulate sequential agent calls in an orchestration."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-orchestration-001"
|
||||
# new_uuid will be called 3 times:
|
||||
# 1. thread creation
|
||||
# 2. correlation_id for first call
|
||||
# 3. correlation_id for second call
|
||||
mock_context.new_uuid = Mock(side_effect=["deterministic-guid-001", "corr-1", "corr-2"])
|
||||
|
||||
# Track entity calls
|
||||
entity_calls: list[dict[str, Any]] = []
|
||||
|
||||
def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> Mock:
|
||||
entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data})
|
||||
|
||||
# Return a mock Task
|
||||
mock_task = Mock()
|
||||
mock_task._is_scheduled = False
|
||||
return mock_task
|
||||
|
||||
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
|
||||
|
||||
app = _app_with_registered_agents("WriterAgent")
|
||||
agent = app.get_agent(mock_context, "WriterAgent")
|
||||
|
||||
# Create thread
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# First call - returns Task
|
||||
task1 = agent.run("Write something", thread=thread)
|
||||
assert hasattr(task1, "_is_scheduled")
|
||||
|
||||
# Second call - returns Task
|
||||
task2 = agent.run("Improve: something", thread=thread)
|
||||
assert hasattr(task2, "_is_scheduled")
|
||||
|
||||
# Verify both calls used the same entity (same session key)
|
||||
assert len(entity_calls) == 2
|
||||
assert entity_calls[0]["entity_id"] == entity_calls[1]["entity_id"]
|
||||
# EntityId format is @dafx-writeragent@deterministic-guid-001
|
||||
assert entity_calls[0]["entity_id"] == "@dafx-writeragent@deterministic-guid-001"
|
||||
# new_uuid called 3 times: thread + 2 correlation IDs
|
||||
assert mock_context.new_uuid.call_count == 3
|
||||
|
||||
def test_multiple_agents_in_orchestration(self) -> None:
|
||||
"""Test using multiple different agents in one orchestration."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-orchestration-002"
|
||||
# Mock new_uuid to return different GUIDs for each call
|
||||
# Order: writer thread, editor thread, writer correlation, editor correlation
|
||||
mock_context.new_uuid = Mock(side_effect=["writer-guid-001", "editor-guid-002", "writer-corr", "editor-corr"])
|
||||
|
||||
entity_calls: list[str] = []
|
||||
|
||||
def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> Mock:
|
||||
entity_calls.append(str(entity_id))
|
||||
mock_task = Mock()
|
||||
mock_task._is_scheduled = False
|
||||
return mock_task
|
||||
|
||||
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
|
||||
|
||||
app = _app_with_registered_agents("WriterAgent", "EditorAgent")
|
||||
writer = app.get_agent(mock_context, "WriterAgent")
|
||||
editor = app.get_agent(mock_context, "EditorAgent")
|
||||
|
||||
writer_thread = writer.get_new_thread()
|
||||
editor_thread = editor.get_new_thread()
|
||||
|
||||
# Call both agents - returns Tasks
|
||||
writer_task = writer.run("Write", thread=writer_thread)
|
||||
editor_task = editor.run("Edit", thread=editor_thread)
|
||||
|
||||
assert hasattr(writer_task, "_is_scheduled")
|
||||
assert hasattr(editor_task, "_is_scheduled")
|
||||
|
||||
# Verify different entity IDs were used
|
||||
assert len(entity_calls) == 2
|
||||
# EntityId format is @dafx-agentname@guid (lowercased agent name with dafx- prefix)
|
||||
assert entity_calls[0] == "@dafx-writeragent@writer-guid-001"
|
||||
assert entity_calls[1] == "@dafx-editoragent@editor-guid-002"
|
||||
|
||||
|
||||
class TestAgentThreadSerialization:
|
||||
"""Test that AgentThread can be serialized for orchestration state."""
|
||||
|
||||
async def test_agent_thread_serialize(self) -> None:
|
||||
"""Test that AgentThread can be serialized."""
|
||||
thread = AgentThread()
|
||||
|
||||
# Serialize
|
||||
serialized = await thread.serialize()
|
||||
|
||||
assert isinstance(serialized, dict)
|
||||
assert "service_thread_id" in serialized
|
||||
|
||||
async def test_agent_thread_deserialize(self) -> None:
|
||||
"""Test that AgentThread can be deserialized."""
|
||||
thread = AgentThread()
|
||||
serialized = await thread.serialize()
|
||||
|
||||
# Deserialize
|
||||
restored = await AgentThread.deserialize(serialized)
|
||||
|
||||
assert isinstance(restored, AgentThread)
|
||||
assert restored.service_thread_id == thread.service_thread_id
|
||||
|
||||
async def test_durable_agent_thread_serialization(self) -> None:
|
||||
"""Test that DurableAgentThread persists session metadata during serialization."""
|
||||
mock_context = Mock()
|
||||
mock_context.instance_id = "test-instance-999"
|
||||
mock_context.new_uuid = Mock(return_value="test-guid-999")
|
||||
|
||||
agent = DurableAIAgent(mock_context, "TestAgent")
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread, DurableAgentThread)
|
||||
# Verify custom attribute and property exist
|
||||
assert thread.session_id is not None
|
||||
session_id = thread.session_id
|
||||
assert isinstance(session_id, AgentSessionId)
|
||||
assert session_id.name == "TestAgent"
|
||||
assert session_id.key == "test-guid-999"
|
||||
|
||||
# Standard serialization should still work
|
||||
serialized = await thread.serialize()
|
||||
assert isinstance(serialized, dict)
|
||||
assert serialized.get("durable_session_id") == str(session_id)
|
||||
|
||||
# After deserialization, we'd need to restore the custom attribute
|
||||
# This would be handled by the orchestration framework
|
||||
restored = await DurableAgentThread.deserialize(serialized)
|
||||
assert isinstance(restored, DurableAgentThread)
|
||||
assert restored.session_id == session_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -0,0 +1,110 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for AgentState correlation ID tracking."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentRunResponse
|
||||
|
||||
from agent_framework_azurefunctions._state import AgentState
|
||||
|
||||
|
||||
class TestAgentStateCorrelationId:
|
||||
"""Test suite for AgentState correlation ID tracking."""
|
||||
|
||||
def _create_mock_response(self, text: str = "Response") -> Mock:
|
||||
"""Create a mock AgentRunResponse with the provided text."""
|
||||
mock_response = Mock(spec=AgentRunResponse)
|
||||
mock_response.to_dict.return_value = {"text": text, "messages": []}
|
||||
return mock_response
|
||||
|
||||
def test_add_assistant_message_with_correlation_id(self) -> None:
|
||||
state = AgentState()
|
||||
state.add_user_message("Hello", correlation_id="corr-123-request")
|
||||
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
|
||||
message_metadata = state.conversation_history[-1].additional_properties or {}
|
||||
assert message_metadata.get("correlation_id") == "corr-123"
|
||||
|
||||
response_data = state.try_get_agent_response("corr-123")
|
||||
assert response_data is not None
|
||||
assert response_data["content"] == "Response"
|
||||
assert response_data["agent_response"] == {"text": "Response", "messages": []}
|
||||
|
||||
def test_try_get_agent_response_returns_response(self) -> None:
|
||||
state = AgentState()
|
||||
state.add_user_message("Hello", correlation_id="corr-200-request")
|
||||
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-456")
|
||||
|
||||
response_data = state.try_get_agent_response("corr-456")
|
||||
|
||||
assert response_data is not None
|
||||
assert response_data["content"] == "Response"
|
||||
|
||||
def test_try_get_agent_response_returns_none_for_missing_id(self) -> None:
|
||||
state = AgentState()
|
||||
state.add_user_message("Hello", correlation_id="corr-300-request")
|
||||
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
|
||||
|
||||
assert state.try_get_agent_response("non-existent") is None
|
||||
|
||||
def test_multiple_responses_tracked_separately(self) -> None:
|
||||
state = AgentState()
|
||||
|
||||
for index in range(3):
|
||||
state.add_user_message(f"Message {index}", correlation_id=f"corr-{index}-request")
|
||||
state.add_assistant_message(
|
||||
f"Response {index}",
|
||||
self._create_mock_response(text=f"Response {index}"),
|
||||
correlation_id=f"corr-{index}",
|
||||
)
|
||||
|
||||
for index in range(3):
|
||||
payload = state.try_get_agent_response(f"corr-{index}")
|
||||
assert payload is not None
|
||||
assert payload["content"] == f"Response {index}"
|
||||
|
||||
def test_add_assistant_message_without_correlation_id(self) -> None:
|
||||
state = AgentState()
|
||||
state.add_user_message("Hello", correlation_id="corr-400-request")
|
||||
state.add_assistant_message("Response", self._create_mock_response())
|
||||
|
||||
assert state.try_get_agent_response("missing") is None
|
||||
assert state.last_response == "Response"
|
||||
|
||||
def test_to_dict_does_not_duplicate_agent_responses(self) -> None:
|
||||
state = AgentState()
|
||||
state.add_user_message("Hello", correlation_id="corr-500-request")
|
||||
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
|
||||
|
||||
state_snapshot = state.to_dict()
|
||||
|
||||
assert "agent_responses" not in state_snapshot
|
||||
metadata = state_snapshot["conversation_history"][-1]["additional_properties"]
|
||||
assert metadata["correlation_id"] == "corr-123"
|
||||
|
||||
def test_restore_state_preserves_agent_response_lookup(self) -> None:
|
||||
state = AgentState()
|
||||
state.add_user_message("Hello", correlation_id="corr-600-request")
|
||||
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
|
||||
|
||||
restored_state = AgentState()
|
||||
restored_state.restore_state(state.to_dict())
|
||||
|
||||
payload = restored_state.try_get_agent_response("corr-123")
|
||||
assert payload is not None
|
||||
assert payload["content"] == "Response"
|
||||
|
||||
def test_reset_clears_conversation_history(self) -> None:
|
||||
state = AgentState()
|
||||
state.add_user_message("Hello", correlation_id="corr-700-request")
|
||||
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
|
||||
|
||||
state.reset()
|
||||
|
||||
assert len(state.conversation_history) == 0
|
||||
assert state.try_get_agent_response("corr-123") is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -5,6 +5,9 @@ import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AgentCallbackContext": ("agent_framework_azurefunctions", "azurefunctions"),
|
||||
"AgentFunctionApp": ("agent_framework_azurefunctions", "azurefunctions"),
|
||||
"AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "azurefunctions"),
|
||||
"AzureAIAgentClient": ("agent_framework_azure_ai", "azure-ai"),
|
||||
"AzureAIClient": ("agent_framework_azure_ai", "azure-ai"),
|
||||
"AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "core"),
|
||||
@@ -12,6 +15,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AzureAISettings": ("agent_framework_azure_ai", "azure-ai"),
|
||||
"AzureOpenAISettings": ("agent_framework.azure._shared", "core"),
|
||||
"AzureOpenAIResponsesClient": ("agent_framework.azure._responses_client", "core"),
|
||||
"DurableAIAgent": ("agent_framework_azurefunctions", "azurefunctions"),
|
||||
"get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "core"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_azure_ai import AzureAIAgentClient, AzureAIClient, AzureAISettings
|
||||
from agent_framework_azurefunctions import (
|
||||
AgentCallbackContext,
|
||||
AgentFunctionApp,
|
||||
AgentResponseCallbackProtocol,
|
||||
DurableAIAgent,
|
||||
)
|
||||
|
||||
from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient
|
||||
from agent_framework.azure._chat_client import AzureOpenAIChatClient
|
||||
@@ -9,6 +15,9 @@ from agent_framework.azure._responses_client import AzureOpenAIResponsesClient
|
||||
from agent_framework.azure._shared import AzureOpenAISettings
|
||||
|
||||
__all__ = [
|
||||
"AgentCallbackContext",
|
||||
"AgentFunctionApp",
|
||||
"AgentResponseCallbackProtocol",
|
||||
"AzureAIAgentClient",
|
||||
"AzureAIClient",
|
||||
"AzureAISettings",
|
||||
@@ -16,5 +25,6 @@ __all__ = [
|
||||
"AzureOpenAIChatClient",
|
||||
"AzureOpenAIResponsesClient",
|
||||
"AzureOpenAISettings",
|
||||
"DurableAIAgent",
|
||||
"get_entra_auth_token",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user