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:
co-authored by
Laveesh Rohra
Chris Gillum
Copilot
Anirudh Garg
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",
|
||||
]
|
||||
|
||||
@@ -28,6 +28,7 @@ dependencies = [
|
||||
"agent-framework-ag-ui",
|
||||
"agent-framework-anthropic",
|
||||
"agent-framework-azure-ai",
|
||||
"agent-framework-azurefunctions",
|
||||
"agent-framework-chatkit",
|
||||
"agent-framework-copilotstudio",
|
||||
"agent-framework-devui",
|
||||
@@ -93,6 +94,7 @@ agent-framework-core = { workspace = true }
|
||||
agent-framework-a2a = { workspace = true }
|
||||
agent-framework-ag-ui = { workspace = true }
|
||||
agent-framework-azure-ai = { workspace = true }
|
||||
agent-framework-azurefunctions = { workspace = true }
|
||||
agent-framework-chatkit = { workspace = true }
|
||||
agent-framework-copilotstudio = { workspace = true }
|
||||
agent-framework-lab = { workspace = true }
|
||||
@@ -246,6 +248,7 @@ pytest --import-mode=importlib
|
||||
--cov=agent_framework_a2a
|
||||
--cov=agent_framework_ag_ui
|
||||
--cov=agent_framework_azure_ai
|
||||
--cov=agent_framework_azurefunctions
|
||||
--cov=agent_framework_chatkit
|
||||
--cov=agent_framework_copilotstudio
|
||||
--cov=agent_framework_mem0
|
||||
@@ -254,6 +257,7 @@ pytest --import-mode=importlib
|
||||
--cov-report=term-missing:skip-covered
|
||||
--ignore-glob=packages/lab/**
|
||||
--ignore-glob=packages/devui/**
|
||||
-rs
|
||||
-n logical --dist loadfile --dist worksteal
|
||||
packages/**/tests
|
||||
"""
|
||||
@@ -263,6 +267,7 @@ cmd = """
|
||||
pytest --import-mode=importlib
|
||||
--ignore-glob=packages/lab/**
|
||||
--ignore-glob=packages/devui/**
|
||||
-rs
|
||||
-n logical --dist loadfile --dist worksteal
|
||||
packages/**/tests
|
||||
"""
|
||||
|
||||
@@ -193,6 +193,18 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen
|
||||
| [`getting_started/multimodal_input/azure_responses_multimodal.py`](./getting_started/multimodal_input/azure_responses_multimodal.py) | Azure OpenAI Responses with multimodal (image) input example |
|
||||
| [`getting_started/multimodal_input/openai_chat_multimodal.py`](./getting_started/multimodal_input/openai_chat_multimodal.py) | OpenAI Chat with multimodal (image) input example |
|
||||
|
||||
## Azure Functions
|
||||
|
||||
| Sample | Description |
|
||||
|--------|-------------|
|
||||
| [`getting_started/azure_functions/01_single_agent/`](./getting_started/azure_functions/01_single_agent/) | Host a single agent in Azure Functions with Durable Extension HTTP endpoints and per-session state. |
|
||||
| [`getting_started/azure_functions/02_multi_agent/`](./getting_started/azure_functions/02_multi_agent/) | Register multiple agents in one function app with dedicated run routes and a health check endpoint. |
|
||||
| [`getting_started/azure_functions/03_callbacks/`](./getting_started/azure_functions/03_callbacks/) | Capture streaming response telemetry via Durable Extension callbacks exposed through HTTP APIs. |
|
||||
| [`getting_started/azure_functions/04_single_agent_orchestration_chaining/`](./getting_started/azure_functions/04_single_agent_orchestration_chaining/) | Chain sequential agent executions inside a durable orchestration while preserving the shared thread context. |
|
||||
| [`getting_started/azure_functions/05_multi_agent_orchestration_concurrency/`](./getting_started/azure_functions/05_multi_agent_orchestration_concurrency/) | Run two agents concurrently within a durable orchestration and combine their domain-specific outputs. |
|
||||
| [`getting_started/azure_functions/06_multi_agent_orchestration_conditionals/`](./getting_started/azure_functions/06_multi_agent_orchestration_conditionals/) | Route orchestration logic based on structured agent responses for spam detection and reply drafting. |
|
||||
| [`getting_started/azure_functions/07_single_agent_orchestration_hitl/`](./getting_started/azure_functions/07_single_agent_orchestration_hitl/) | Implement a human-in-the-loop approval loop that iterates on agent output inside a durable orchestration. |
|
||||
|
||||
## Observability
|
||||
|
||||
| File | Description |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Single Agent Sample (Python)
|
||||
|
||||
This sample demonstrates how to use the Durable Extension for Agent Framework to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Defining a simple agent with the Microsoft Agent Framework and wiring it into
|
||||
an Azure Functions app via the Durable Extension for Agent Framework.
|
||||
- Calling the agent through generated HTTP endpoints (`/api/agents/Joker/run`).
|
||||
- Managing conversation state with thread identifiers, so multiple clients can
|
||||
interact with the agent concurrently without sharing context.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Follow the common setup steps in `../README.md` to install tooling, configure Azure OpenAI credentials, and install the Python dependencies for this sample.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
Send a prompt to the Joker agent:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a short joke about cloud computing."
|
||||
```
|
||||
|
||||
The agent responds with a JSON payload that includes the generated joke.
|
||||
|
||||
> **Note:** To return immediately with an HTTP 202 response instead of waiting for the agent output, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body. The default behavior waits for the response.
|
||||
|
||||
## Expected Output
|
||||
|
||||
When you send a POST request with plain-text input, the Functions host responds with an HTTP 202 and queues the request for the durable agent entity. A typical response body looks like the following:
|
||||
Expected HTTP 202 payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "Tell me a short joke about cloud computing.",
|
||||
"thread_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
### Joker Agent Sample Interactions
|
||||
@baseUrl = http://localhost:7071
|
||||
@agentName = Joker
|
||||
@agentRoute = {{baseUrl}}/api/agents/{{agentName}}
|
||||
@healthRoute = {{baseUrl}}/api/health
|
||||
|
||||
### Health Check
|
||||
GET {{healthRoute}}
|
||||
|
||||
### Ask for a joke (JSON payload)
|
||||
POST {{agentRoute}}/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Add a security element to it.",
|
||||
"thread_id": "thread-001"
|
||||
}
|
||||
|
||||
### Ask for a joke (plain text payload)
|
||||
POST {{agentRoute}}/run
|
||||
|
||||
Give me a programming joke about race conditions.
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Host a single Azure OpenAI-powered agent inside Azure Functions.
|
||||
|
||||
Components used in this sample:
|
||||
- AzureOpenAIChatClient to call the Azure OpenAI chat deployment.
|
||||
- AgentFunctionApp to expose HTTP endpoints via the Durable Functions extension.
|
||||
|
||||
Prerequisites: set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` (plus `AZURE_OPENAI_API_KEY` or Azure CLI authentication) before starting the Functions host."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
# 1. Instantiate the agent with the chosen deployment and instructions.
|
||||
def _create_agent() -> Any:
|
||||
"""Create the Joker agent."""
|
||||
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
name="Joker",
|
||||
instructions="You are good at telling jokes.",
|
||||
)
|
||||
|
||||
|
||||
# 2. Register the agent with AgentFunctionApp so Azure Functions exposes the required triggers.
|
||||
app = AgentFunctionApp(agents=[_create_agent()], enable_health_check=True, max_poll_retries=50)
|
||||
|
||||
"""
|
||||
Expected output when invoking `POST /api/agents/Joker/run` with plain-text input:
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
{
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "Tell me a short joke about cloud computing.",
|
||||
"conversation_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>",
|
||||
"AZURE_OPENAI_API_KEY": "<AZURE_OPENAI_API_KEY>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
agent-framework-azurefunctions
|
||||
azure-identity
|
||||
@@ -0,0 +1,104 @@
|
||||
# Multi-Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Extension for Agent Framework to create an Azure Functions app that hosts multiple AI agents and provides direct HTTP API access for interactive conversations with each agent.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define multiple AI agents with unique names and instructions.
|
||||
- Registering multiple agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via thread IDs) for isolated interactions per agent.
|
||||
- Two different methods for registering agents: list-based initialization and incremental addition.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Complete the common environment preparation steps described in `../README.md`, including installing Azure Functions Core Tools, starting Azurite, configuring Azure OpenAI settings, and installing this sample's requirements.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending HTTP requests to the different agent endpoints.
|
||||
|
||||
You can use the `demo.http` file to send messages to the agents, or a command line tool like `curl` as shown below:
|
||||
|
||||
> **Note:** Each endpoint waits for the agent response by default. To receive an immediate HTTP 202 instead, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body.
|
||||
|
||||
### Test the Weather Agent
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
Weather agent request:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/WeatherAgent/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "What is the weather in Seattle?"}'
|
||||
```
|
||||
|
||||
Expected HTTP 202 payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "What is the weather in Seattle?",
|
||||
"thread_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
```
|
||||
|
||||
Math agent request:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/MathAgent/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Calculate a 20% tip on a $50 bill"}'
|
||||
```
|
||||
|
||||
Expected HTTP 202 payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "Calculate a 20% tip on a $50 bill",
|
||||
"thread_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
```
|
||||
|
||||
Health check (optional):
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"agents": [
|
||||
{"name": "WeatherAgent", "type": "ChatAgent"},
|
||||
{"name": "MathAgent", "type": "ChatAgent"}
|
||||
],
|
||||
"agent_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Code Structure
|
||||
|
||||
The sample demonstrates two ways to register multiple agents:
|
||||
|
||||
### Option 1: Pass list of agents during initialization
|
||||
```python
|
||||
app = AgentFunctionApp(agents=[weather_agent, math_agent])
|
||||
```
|
||||
|
||||
### Option 2: Add agents incrementally (commented in sample)
|
||||
```python
|
||||
app = AgentFunctionApp()
|
||||
app.add_agent(weather_agent)
|
||||
app.add_agent(math_agent)
|
||||
```
|
||||
|
||||
Each agent automatically gets:
|
||||
- `POST /api/agents/{agent_name}/run` - Send messages to the agent
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
### DAFx Multi-Agent Function App - HTTP Samples
|
||||
### Use with the VS Code REST Client extension or any HTTP client
|
||||
###
|
||||
### API Structure:
|
||||
### - POST /api/agents/{agentName}/run -> Send a message to an agent
|
||||
### - GET /api/health -> Health check and agent metadata
|
||||
|
||||
### Variables
|
||||
@baseUrl = http://localhost:7071
|
||||
@weatherAgentName = WeatherAgent
|
||||
@mathAgentName = MathAgent
|
||||
@weatherAgentRoute = {{baseUrl}}/api/agents/{{weatherAgentName}}
|
||||
@mathAgentRoute = {{baseUrl}}/api/agents/{{mathAgentName}}
|
||||
@healthRoute = {{baseUrl}}/api/health
|
||||
|
||||
### Health Check
|
||||
# Confirms the Azure Functions app is running and both agents are registered
|
||||
# Expected response:
|
||||
# {
|
||||
# "status": "healthy",
|
||||
# "agents": [
|
||||
# {"name": "WeatherAgent", "type": "AzureOpenAIAssistantsAgent"},
|
||||
# {"name": "MathAgent", "type": "AzureOpenAIAssistantsAgent"}
|
||||
# ],
|
||||
# "agent_count": 2
|
||||
# }
|
||||
GET {{healthRoute}}
|
||||
|
||||
###
|
||||
|
||||
### Weather Agent - Current Conditions
|
||||
# Tests the Weather agent's tool-assisted response path
|
||||
# Expected response: { "response": "The weather in Seattle...", "status": "success" }
|
||||
POST {{weatherAgentRoute}}/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "What is the weather in Seattle?",
|
||||
"thread_id": "weather-user-001"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
|
||||
### Math Agent - Tip Calculation
|
||||
# Exercises the Math agent with a calculation request
|
||||
# Expected response: { "response": "A 20% tip on a $50 bill is $10...", "status": "success" }
|
||||
POST {{mathAgentRoute}}/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Calculate a 20% tip on a $50 bill",
|
||||
"thread_id": "math-user-001"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Host multiple Azure OpenAI agents inside a single Azure Functions app.
|
||||
|
||||
Components used in this sample:
|
||||
- AzureOpenAIChatClient to create agents bound to a shared Azure OpenAI deployment.
|
||||
- AgentFunctionApp to register multiple agents and expose dedicated HTTP endpoints.
|
||||
- Custom tool functions to demonstrate tool invocation from different agents.
|
||||
|
||||
Prerequisites: set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, plus either
|
||||
`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_weather(location: str) -> dict[str, Any]:
|
||||
"""Get current weather for a location."""
|
||||
|
||||
logger.info(f"🔧 [TOOL CALLED] get_weather(location={location})")
|
||||
result = {
|
||||
"location": location,
|
||||
"temperature": 72,
|
||||
"conditions": "Sunny",
|
||||
"humidity": 45,
|
||||
}
|
||||
logger.info(f"✓ [TOOL RESULT] {result}")
|
||||
return result
|
||||
|
||||
|
||||
def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, Any]:
|
||||
"""Calculate tip amount and total bill."""
|
||||
|
||||
logger.info(
|
||||
f"🔧 [TOOL CALLED] calculate_tip(bill_amount={bill_amount}, tip_percentage={tip_percentage})"
|
||||
)
|
||||
tip = bill_amount * (tip_percentage / 100)
|
||||
total = bill_amount + tip
|
||||
result = {
|
||||
"bill_amount": bill_amount,
|
||||
"tip_percentage": tip_percentage,
|
||||
"tip_amount": round(tip, 2),
|
||||
"total": round(total, 2),
|
||||
}
|
||||
logger.info(f"✓ [TOOL RESULT] {result}")
|
||||
return result
|
||||
|
||||
|
||||
# 1. Create multiple agents, each with its own instruction set and tools.
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
weather_agent = chat_client.create_agent(
|
||||
name="WeatherAgent",
|
||||
instructions="You are a helpful weather assistant. Provide current weather information.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
math_agent = chat_client.create_agent(
|
||||
name="MathAgent",
|
||||
instructions="You are a helpful math assistant. Help users with calculations like tip calculations.",
|
||||
tools=[calculate_tip],
|
||||
)
|
||||
|
||||
|
||||
# 2. Register both agents with AgentFunctionApp to expose their HTTP routes and health check.
|
||||
app = AgentFunctionApp(agents=[weather_agent, math_agent], enable_health_check=True, max_poll_retries=50)
|
||||
|
||||
# Option 2: Add agents after initialization (commented out as we're using Option 1)
|
||||
# app = AgentFunctionApp(enable_health_check=True)
|
||||
# app.add_agent(weather_agent)
|
||||
# app.add_agent(math_agent)
|
||||
|
||||
"""
|
||||
Expected output when invoking `POST /api/agents/WeatherAgent/run`:
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
{
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "What is the weather in Seattle?",
|
||||
"conversation_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
|
||||
Expected output when invoking `POST /api/agents/MathAgent/run`:
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
{
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "Calculate a 20% tip on a $50 bill",
|
||||
"conversation_id": "<guid>",
|
||||
"correlation_id": "<guid>"
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"maxTelemetryItemsPerSecond": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>",
|
||||
"AZURE_OPENAI_API_KEY": "<AZURE_OPENAI_API_KEY>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
agent-framework-azurefunctions
|
||||
azure-identity
|
||||
@@ -0,0 +1,82 @@
|
||||
# Callback Telemetry Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Extension for Agent Framework's response callbacks to observe
|
||||
streaming updates and final agent responses in real time. The `ConversationAuditTrail` callback
|
||||
records each chunk received from the Azure OpenAI agent and exposes the collected events through
|
||||
an HTTP API that can be polled by a web client or dashboard.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Registers a default `AgentResponseCallbackProtocol` implementation that logs streaming and final
|
||||
responses.
|
||||
- Persists callback events in an in-memory store and exposes them via
|
||||
`GET /api/agents/{agentName}/callbacks/{thread_id}`.
|
||||
- Shows how to reset stored callback events with `DELETE /api/agents/{agentName}/callbacks/{thread_id}`.
|
||||
- Works alongside the standard `/api/agents/{agentName}/run` endpoint so you can correlate callback
|
||||
telemetry with agent responses.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Complete the shared environment setup steps in `../README.md`, including creating a virtual environment, installing dependencies, and configuring Azure OpenAI credentials and storage settings.
|
||||
|
||||
> **Note:** The sample stores callback events in memory for simplicity. For production scenarios you
|
||||
> should persist events to Application Insights, Azure Storage, Cosmos DB, or another durable store.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
Send a prompt to the agent:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/CallbackAgent/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Tell me a short joke"}'
|
||||
```
|
||||
|
||||
> **Note:** The run endpoint waits for the agent response by default. To return immediately, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body.
|
||||
|
||||
Poll callback telemetry (replace `<conversationId>` with the value from the POST response):
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/agents/CallbackAgent/callbacks/<conversationId>
|
||||
```
|
||||
|
||||
Reset stored events:
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:7071/api/agents/CallbackAgent/callbacks/<conversationId>
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
When you call `GET /api/agents/CallbackAgent/callbacks/{thread_id}` after sending a request to the agent,
|
||||
the API returns a list of streaming and final callback events similar to the following:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"thread_id": "<thread_id>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "stream",
|
||||
"update_kind": "text",
|
||||
"text": "Sure, here's a joke..."
|
||||
},
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:01Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"thread_id": "<thread_id>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "final",
|
||||
"response_text": "Why did the cloud...",
|
||||
"usage": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 159,
|
||||
"output_token_count": 29,
|
||||
"total_token_count": 188
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
### Callback Sample - API Tests
|
||||
### Use with VS Code REST Client or another HTTP testing tool.
|
||||
###
|
||||
### Endpoints introduced in this sample:
|
||||
### - POST /api/agents/{agentName}/run : send a message to the agent
|
||||
### - GET /api/agents/{agentName}/callbacks/{thread_id} : retrieve callback telemetry
|
||||
### - DELETE /api/agents/{agentName}/callbacks/{thread_id} : clear stored callback events
|
||||
|
||||
@baseUrl = http://localhost:7071
|
||||
@agentName = CallbackAgent
|
||||
@agentRoute = {{baseUrl}}/api/agents/{{agentName}}
|
||||
@thread_id = test-thread-00
|
||||
|
||||
### Health Check
|
||||
GET {{baseUrl}}/api/health
|
||||
|
||||
### Send message (callbacks will capture streaming + final response)
|
||||
POST {{agentRoute}}/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Generate a short weather update for Paris and mention streaming callbacks.",
|
||||
"thread_id": "{{thread_id}}"
|
||||
}
|
||||
|
||||
### Inspect callback telemetry
|
||||
GET {{agentRoute}}/callbacks/{{thread_id}}
|
||||
|
||||
### Clear stored callback telemetry for the thread
|
||||
DELETE {{agentRoute}}/callbacks/{{thread_id}}
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Capture agent response callbacks inside Azure Functions.
|
||||
|
||||
Components used in this sample:
|
||||
- AzureOpenAIChatClient to build an agent that streams interim updates.
|
||||
- AgentFunctionApp with a default AgentResponseCallbackProtocol implementation.
|
||||
- Azure Functions HTTP triggers that expose callback telemetry via REST.
|
||||
|
||||
Prerequisites: set `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and either
|
||||
`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, DefaultDict
|
||||
|
||||
import azure.functions as func
|
||||
from agent_framework import AgentRunResponseUpdate
|
||||
from agent_framework.azure import (
|
||||
AgentCallbackContext,
|
||||
AgentFunctionApp,
|
||||
AgentResponseCallbackProtocol,
|
||||
AzureOpenAIChatClient,
|
||||
)
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 1. Maintain an in-memory store for callback events keyed by thread ID (replace with durable storage in production).
|
||||
CallbackStore = DefaultDict[str, list[dict[str, Any]]]
|
||||
callback_events: CallbackStore = defaultdict(list)
|
||||
|
||||
|
||||
def _serialize_usage(usage: Any) -> Any:
|
||||
"""Best-effort serialization for agent usage metadata."""
|
||||
|
||||
if usage is None:
|
||||
return None
|
||||
|
||||
model_dump = getattr(usage, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
return model_dump()
|
||||
|
||||
to_dict = getattr(usage, "to_dict", None)
|
||||
if callable(to_dict):
|
||||
return to_dict()
|
||||
|
||||
return str(usage)
|
||||
|
||||
|
||||
class ConversationAuditTrail(AgentResponseCallbackProtocol):
|
||||
"""Callback that records streaming chunks and final responses for later inspection."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._logger = logging.getLogger("durableagent.samples.callbacks.audit")
|
||||
|
||||
async def on_streaming_response_update(
|
||||
self,
|
||||
update: AgentRunResponseUpdate,
|
||||
context: AgentCallbackContext,
|
||||
) -> None:
|
||||
event = self._build_base_event(context)
|
||||
event.update(
|
||||
{
|
||||
"event_type": "stream",
|
||||
"update_kind": getattr(update, "kind", "text"),
|
||||
"text": getattr(update, "text", None),
|
||||
}
|
||||
)
|
||||
thread_id = context.thread_id or ""
|
||||
callback_events[thread_id].append(event)
|
||||
|
||||
preview = event.get("text") or event.get("update_kind")
|
||||
self._logger.info(
|
||||
"[%s][%s] streaming chunk: %s",
|
||||
context.agent_name,
|
||||
context.correlation_id,
|
||||
preview,
|
||||
)
|
||||
|
||||
async def on_agent_response(self, response, context: AgentCallbackContext) -> None:
|
||||
event = self._build_base_event(context)
|
||||
event.update(
|
||||
{
|
||||
"event_type": "final",
|
||||
"response_text": getattr(response, "text", None),
|
||||
"usage": _serialize_usage(getattr(response, "usage_details", None)),
|
||||
}
|
||||
)
|
||||
thread_id = context.thread_id or ""
|
||||
callback_events[thread_id].append(event)
|
||||
|
||||
self._logger.info(
|
||||
"[%s][%s] final response recorded",
|
||||
context.agent_name,
|
||||
context.correlation_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_base_event(context: AgentCallbackContext) -> dict[str, Any]:
|
||||
thread_id = context.thread_id
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"agent_name": context.agent_name,
|
||||
"thread_id": thread_id,
|
||||
"correlation_id": context.correlation_id,
|
||||
"request_message": context.request_message,
|
||||
}
|
||||
|
||||
|
||||
# 2. Create the agent that will emit streaming updates and final responses.
|
||||
callback_agent = AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
name="CallbackAgent",
|
||||
instructions=(
|
||||
"You are a friendly assistant that narrates actions while responding. "
|
||||
"Keep answers concise and acknowledge when callbacks capture streaming updates."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# 3. Register the agent inside AgentFunctionApp with a default callback instance.
|
||||
audit_callback = ConversationAuditTrail()
|
||||
app = AgentFunctionApp(enable_health_check=True, default_callback=audit_callback)
|
||||
app.add_agent(callback_agent)
|
||||
|
||||
|
||||
@app.function_name("get_callback_events")
|
||||
@app.route(route="agents/{agent_name}/callbacks/{thread_id}", methods=["GET"])
|
||||
async def get_callback_events(req: func.HttpRequest) -> func.HttpResponse:
|
||||
"""Return all callback events collected for a thread."""
|
||||
|
||||
thread_id = req.route_params.get("thread_id", "")
|
||||
events = callback_events.get(thread_id, [])
|
||||
return func.HttpResponse(
|
||||
json.dumps(events, indent=2),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
@app.function_name("reset_callback_events")
|
||||
@app.route(route="agents/{agent_name}/callbacks/{thread_id}", methods=["DELETE"])
|
||||
async def reset_callback_events(req: func.HttpRequest) -> func.HttpResponse:
|
||||
"""Clear the stored callback events for a thread."""
|
||||
|
||||
thread_id = req.route_params.get("thread_id", "")
|
||||
callback_events.pop(thread_id, None)
|
||||
return func.HttpResponse(status_code=204)
|
||||
|
||||
|
||||
"""
|
||||
Expected output when querying `GET /api/agents/CallbackAgent/callbacks/{thread_id}`:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
[
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"thread_id": "<thread_id>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "stream",
|
||||
"update_kind": "text",
|
||||
"text": "Sure, here's a joke..."
|
||||
},
|
||||
{
|
||||
"timestamp": "2024-01-01T00:00:01Z",
|
||||
"agent_name": "CallbackAgent",
|
||||
"thread_id": "<thread_id>",
|
||||
"correlation_id": "<guid>",
|
||||
"request_message": "Tell me a short joke",
|
||||
"event_type": "final",
|
||||
"response_text": "Why did the cloud...",
|
||||
"usage": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 159,
|
||||
"output_token_count": 29,
|
||||
"total_token_count": 188
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"maxTelemetryItemsPerSecond": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>",
|
||||
"AZURE_OPENAI_API_KEY": "<AZURE_OPENAI_API_KEY>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
agent-framework-azurefunctions
|
||||
azure-identity
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Single Agent Orchestration Sample (Python)
|
||||
|
||||
This sample shows how to chain two invocations of the same agent inside a Durable Functions orchestration while
|
||||
preserving the conversation state between runs.
|
||||
|
||||
## Key Concepts
|
||||
- Deterministic orchestrations that make sequential agent calls on a shared thread
|
||||
- Reusing an agent thread to carry conversation history across invocations
|
||||
- HTTP endpoints for starting the orchestration and polling for status/output
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Start with the shared setup instructions in `../README.md` to create a virtual environment, install dependencies, and configure Azure OpenAI and storage settings.
|
||||
|
||||
## Running the Sample
|
||||
Start the orchestration:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/singleagent/run
|
||||
```
|
||||
|
||||
Poll the returned `statusQueryGetUri` until completion:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/singleagent/status/<instanceId>
|
||||
```
|
||||
|
||||
> **Note:** The underlying agent run endpoint now waits for responses by default. If you invoke it directly and prefer an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the payload.
|
||||
|
||||
The orchestration first requests an inspirational sentence from the agent, then refines the initial response while
|
||||
keeping it under 25 words—mirroring the behaviour of the corresponding .NET sample.
|
||||
|
||||
## Expected Output
|
||||
|
||||
Sample response when starting the orchestration:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Single-agent orchestration started.",
|
||||
"instanceId": "ebb5c1df123e4d6fb8e7d703ffd0d0b0",
|
||||
"statusQueryGetUri": "http://localhost:7071/api/singleagent/status/ebb5c1df123e4d6fb8e7d703ffd0d0b0"
|
||||
}
|
||||
```
|
||||
|
||||
Sample completed status payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"instanceId": "ebb5c1df123e4d6fb8e7d703ffd0d0b0",
|
||||
"runtimeStatus": "Completed",
|
||||
"output": "Learning is a journey where curiosity turns effort into mastery."
|
||||
}
|
||||
```
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
### Start the single-agent orchestration
|
||||
POST http://localhost:7071/api/singleagent/run
|
||||
|
||||
|
||||
### Check the status of the orchestration
|
||||
|
||||
@instanceId =<Replace with the instance ID from the response above>
|
||||
|
||||
GET http://localhost:7071/api/singleagent/status/{{instanceId}}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
"""Chain two runs of a single agent inside a Durable Functions orchestration.
|
||||
|
||||
Components used in this sample:
|
||||
- AzureOpenAIChatClient to construct the writer agent hosted by Agent Framework.
|
||||
- AgentFunctionApp to surface HTTP and orchestration triggers via the Azure Functions extension.
|
||||
- Durable Functions orchestration to run sequential agent invocations on the same conversation thread.
|
||||
|
||||
Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and either
|
||||
`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
from azure.durable_functions import DurableOrchestrationContext
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 1. Define the agent name used across the orchestration.
|
||||
WRITER_AGENT_NAME = "WriterAgent"
|
||||
|
||||
|
||||
# 2. Create the writer agent that will be invoked twice within the orchestration.
|
||||
def _create_writer_agent() -> Any:
|
||||
"""Create the writer agent with the same persona as the C# sample."""
|
||||
|
||||
instructions = (
|
||||
"You refine short pieces of text. When given an initial sentence you enhance it;\n"
|
||||
"when given an improved sentence you polish it further."
|
||||
)
|
||||
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
name=WRITER_AGENT_NAME,
|
||||
instructions=instructions,
|
||||
)
|
||||
|
||||
|
||||
# 3. Register the agent with AgentFunctionApp so HTTP and orchestration triggers are exposed.
|
||||
app = AgentFunctionApp(agents=[_create_writer_agent()], enable_health_check=True)
|
||||
|
||||
|
||||
# 4. Orchestration that runs the agent sequentially on a shared thread for chaining behaviour.
|
||||
@app.orchestration_trigger(context_name="context")
|
||||
def single_agent_orchestration(context: DurableOrchestrationContext):
|
||||
"""Run the writer agent twice on the same thread to mirror chaining behaviour."""
|
||||
|
||||
writer = app.get_agent(context, WRITER_AGENT_NAME)
|
||||
writer_thread = writer.get_new_thread()
|
||||
|
||||
initial = yield writer.run(
|
||||
messages="Write a concise inspirational sentence about learning.",
|
||||
thread=writer_thread,
|
||||
)
|
||||
|
||||
improved_prompt = (
|
||||
"Improve this further while keeping it under 25 words: "
|
||||
f"{initial.get('response', '').strip()}"
|
||||
)
|
||||
|
||||
refined = yield writer.run(
|
||||
messages=improved_prompt,
|
||||
thread=writer_thread,
|
||||
)
|
||||
|
||||
return refined.get("response", "")
|
||||
|
||||
|
||||
# 5. HTTP endpoint to kick off the orchestration and return the status query URI.
|
||||
@app.route(route="singleagent/run", methods=["POST"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def start_single_agent_orchestration(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
"""Start the orchestration and return status metadata."""
|
||||
|
||||
instance_id = await client.start_new(
|
||||
orchestration_function_name="single_agent_orchestration",
|
||||
)
|
||||
|
||||
logger.info("[HTTP] Started orchestration with instance_id: %s", instance_id)
|
||||
|
||||
status_url = _build_status_url(req.url, instance_id, route="singleagent")
|
||||
|
||||
payload = {
|
||||
"message": "Single-agent orchestration started.",
|
||||
"instanceId": instance_id,
|
||||
"statusQueryGetUri": status_url,
|
||||
}
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(payload),
|
||||
status_code=202,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 6. HTTP endpoint to fetch orchestration status using the original instance ID.
|
||||
@app.route(route="singleagent/status/{instanceId}", methods=["GET"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def get_orchestration_status(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
"""Return orchestration runtime status."""
|
||||
|
||||
instance_id = req.route_params.get("instanceId")
|
||||
if not instance_id:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Missing instanceId"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
status = await client.get_status(instance_id)
|
||||
if status is None:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Instance not found"}),
|
||||
status_code=404,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"instanceId": status.instance_id,
|
||||
"runtimeStatus": status.runtime_status.name if status.runtime_status else None,
|
||||
}
|
||||
|
||||
if status.input_ is not None:
|
||||
response_data["input"] = status.input_
|
||||
|
||||
if status.output is not None:
|
||||
response_data["output"] = status.output
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(response_data),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 7. Helper to construct durable status URLs similar to the .NET sample implementation.
|
||||
def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str:
|
||||
"""Construct the status query URI similar to DurableHttpApiExtensions in C#."""
|
||||
|
||||
# Split once on /api/ to preserve host and scheme in local emulator and Azure.
|
||||
base_url, _, _ = request_url.partition("/api/")
|
||||
if not base_url:
|
||||
base_url = request_url.rstrip("/")
|
||||
return f"{base_url}/api/{route}/status/{instance_id}"
|
||||
|
||||
|
||||
"""
|
||||
Expected output when calling `POST /api/singleagent/run` and following the returned status URL:
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
{
|
||||
"message": "Single-agent orchestration started.",
|
||||
"instanceId": "<guid>",
|
||||
"statusQueryGetUri": "http://localhost:7071/api/singleagent/status/<guid>"
|
||||
}
|
||||
|
||||
Subsequent `GET /api/singleagent/status/<guid>` after completion returns:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{
|
||||
"instanceId": "<guid>",
|
||||
"runtimeStatus": "Completed",
|
||||
"output": "Learning is a journey where curiosity turns effort into mastery."
|
||||
}
|
||||
"""
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>",
|
||||
"AZURE_OPENAI_API_KEY": "<AZURE_OPENAI_API_KEY>"
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
agent-framework-azurefunctions
|
||||
azure-identity
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Multi-Agent Orchestration (Concurrency) – Python
|
||||
|
||||
This sample starts a Durable Functions orchestration that runs two agents in parallel and merges their responses.
|
||||
|
||||
## Highlights
|
||||
- Two agents (`PhysicistAgent` and `ChemistAgent`) share a single Azure OpenAI deployment configuration.
|
||||
- The orchestration uses `context.task_all(...)` to safely run both agents concurrently.
|
||||
- HTTP routes (`/api/multiagent/run` and `/api/multiagent/status/{instanceId}`) mirror the .NET sample for parity.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Use the shared setup instructions in `../README.md` to prepare the environment, install dependencies, and configure Azure OpenAI and storage settings before running this sample.
|
||||
|
||||
## Running the Sample
|
||||
Start the orchestration:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data "What is temperature?" \
|
||||
http://localhost:7071/api/multiagent/run
|
||||
```
|
||||
|
||||
Poll the returned `statusQueryGetUri` until completion:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/multiagent/status/<instanceId>
|
||||
```
|
||||
|
||||
> **Note:** The agent run endpoints wait for responses by default. If you call them directly and need an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request payload.
|
||||
|
||||
The orchestration launches both agents simultaneously so their domain-specific answers can be combined for the caller.
|
||||
|
||||
## Expected Output
|
||||
|
||||
Example response when starting the orchestration:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Multi-agent concurrent orchestration started.",
|
||||
"prompt": "What is temperature?",
|
||||
"instanceId": "94d56266f0a04e5a8f9f3a1f77a4c597",
|
||||
"statusQueryGetUri": "http://localhost:7071/api/multiagent/status/94d56266f0a04e5a8f9f3a1f77a4c597"
|
||||
}
|
||||
```
|
||||
|
||||
Example completed status payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"instanceId": "94d56266f0a04e5a8f9f3a1f77a4c597",
|
||||
"runtimeStatus": "Completed",
|
||||
"output": {
|
||||
"physicist": "Temperature measures the average kinetic energy of particles in a system.",
|
||||
"chemist": "Temperature reflects how molecular motion influences reaction rates and equilibria."
|
||||
}
|
||||
}
|
||||
```
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
### Start the multi-agent concurrent orchestration
|
||||
POST http://localhost:7071/api/multiagent/run
|
||||
Content-Type: text/plain
|
||||
|
||||
What is temperature?
|
||||
|
||||
### Check the status of the orchestration
|
||||
|
||||
@instanceId =<Enter the instance ID from the response above>
|
||||
|
||||
GET http://localhost:7071/api/multiagent/status/{{instanceId}}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
"""Fan out concurrent runs across two agents inside a Durable Functions orchestration.
|
||||
|
||||
Components used in this sample:
|
||||
- AzureOpenAIChatClient to create domain-specific agents hosted by Agent Framework.
|
||||
- AgentFunctionApp to expose orchestration and HTTP triggers.
|
||||
- Durable Functions orchestration that executes agent calls in parallel and aggregates results.
|
||||
|
||||
Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and either
|
||||
`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
from azure.durable_functions import DurableOrchestrationContext
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 1. Define agent names shared across the orchestration.
|
||||
PHYSICIST_AGENT_NAME = "PhysicistAgent"
|
||||
CHEMIST_AGENT_NAME = "ChemistAgent"
|
||||
|
||||
|
||||
# 2. Instantiate both agents that the orchestration will run concurrently.
|
||||
def _create_agents() -> list[Any]:
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
physicist = chat_client.create_agent(
|
||||
name=PHYSICIST_AGENT_NAME,
|
||||
instructions="You are an expert in physics. You answer questions from a physics perspective.",
|
||||
)
|
||||
|
||||
chemist = chat_client.create_agent(
|
||||
name=CHEMIST_AGENT_NAME,
|
||||
instructions="You are an expert in chemistry. You answer questions from a chemistry perspective.",
|
||||
)
|
||||
|
||||
return [physicist, chemist]
|
||||
|
||||
|
||||
# 3. Register both agents with AgentFunctionApp and selectively enable HTTP endpoints.
|
||||
agents = _create_agents()
|
||||
app = AgentFunctionApp(enable_health_check=True, enable_http_endpoints=False)
|
||||
app.add_agent(agents[0], enable_http_endpoint=True)
|
||||
app.add_agent(agents[1])
|
||||
|
||||
|
||||
# 4. Durable Functions orchestration that runs both agents in parallel.
|
||||
@app.orchestration_trigger(context_name="context")
|
||||
def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext):
|
||||
"""Fan out to two domain-specific agents and aggregate their responses."""
|
||||
|
||||
prompt = context.get_input()
|
||||
if not prompt or not str(prompt).strip():
|
||||
raise ValueError("Prompt is required")
|
||||
|
||||
physicist = app.get_agent(context, PHYSICIST_AGENT_NAME)
|
||||
chemist = app.get_agent(context, CHEMIST_AGENT_NAME)
|
||||
|
||||
physicist_thread = physicist.get_new_thread()
|
||||
chemist_thread = chemist.get_new_thread()
|
||||
|
||||
physicist_task = physicist.run(messages=str(prompt), thread=physicist_thread)
|
||||
chemist_task = chemist.run(messages=str(prompt), thread=chemist_thread)
|
||||
|
||||
results = yield context.task_all([physicist_task, chemist_task])
|
||||
|
||||
return {
|
||||
"physicist": results[0].get("response", ""),
|
||||
"chemist": results[1].get("response", ""),
|
||||
}
|
||||
|
||||
|
||||
# 5. HTTP endpoint to accept prompts and start the concurrent orchestration.
|
||||
@app.route(route="multiagent/run", methods=["POST"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def start_multi_agent_concurrent_orchestration(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
"""Kick off the orchestration with a plain text prompt."""
|
||||
|
||||
body_bytes = req.get_body() or b""
|
||||
prompt = body_bytes.decode("utf-8", errors="replace").strip()
|
||||
if not prompt:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Prompt is required"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
instance_id = await client.start_new(
|
||||
orchestration_function_name="multi_agent_concurrent_orchestration",
|
||||
client_input=prompt,
|
||||
)
|
||||
|
||||
logger.info("[HTTP] Started orchestration with instance_id: %s", instance_id)
|
||||
|
||||
status_url = _build_status_url(req.url, instance_id, route="multiagent")
|
||||
|
||||
payload = {
|
||||
"message": "Multi-agent concurrent orchestration started.",
|
||||
"prompt": prompt,
|
||||
"instanceId": instance_id,
|
||||
"statusQueryGetUri": status_url,
|
||||
}
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(payload),
|
||||
status_code=202,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 6. HTTP endpoint to retrieve orchestration status and aggregated outputs.
|
||||
@app.route(route="multiagent/status/{instanceId}", methods=["GET"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def get_orchestration_status(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
instance_id = req.route_params.get("instanceId")
|
||||
if not instance_id:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Missing instanceId"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
status = await client.get_status(instance_id)
|
||||
if status is None:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Instance not found"}),
|
||||
status_code=404,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"instanceId": status.instance_id,
|
||||
"runtimeStatus": status.runtime_status.name if status.runtime_status else None,
|
||||
"createdTime": status.created_time.isoformat() if status.created_time else None,
|
||||
"lastUpdatedTime": status.last_updated_time.isoformat() if status.last_updated_time else None,
|
||||
}
|
||||
|
||||
if status.input_ is not None:
|
||||
response_data["input"] = status.input_
|
||||
|
||||
if status.output is not None:
|
||||
response_data["output"] = status.output
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(response_data),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 7. Helper to construct durable status URLs.
|
||||
def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str:
|
||||
base_url, _, _ = request_url.partition("/api/")
|
||||
if not base_url:
|
||||
base_url = request_url.rstrip("/")
|
||||
return f"{base_url}/api/{route}/status/{instance_id}"
|
||||
|
||||
|
||||
"""
|
||||
Expected output when calling `POST /api/multiagent/run` with a plain-text prompt:
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
{
|
||||
"message": "Multi-agent concurrent orchestration started.",
|
||||
"prompt": "What is temperature?",
|
||||
"instanceId": "<guid>",
|
||||
"statusQueryGetUri": "http://localhost:7071/api/multiagent/status/<guid>"
|
||||
}
|
||||
|
||||
Polling `GET /api/multiagent/status/<guid>` after completion returns:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{
|
||||
"instanceId": "<guid>",
|
||||
"runtimeStatus": "Completed",
|
||||
"output": {
|
||||
"physicist": "Temperature measures the average kinetic energy of particles in a system.",
|
||||
"chemist": "Temperature reflects how molecular motion influences reaction rates and equilibria."
|
||||
}
|
||||
}
|
||||
"""
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>",
|
||||
"AZURE_OPENAI_API_KEY": "<AZURE_OPENAI_API_KEY>"
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
agent-framework-azurefunctions
|
||||
azure-identity
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Multi-Agent Orchestration (Conditionals) – Python
|
||||
|
||||
This sample evaluates incoming emails with a spam detector agent and,
|
||||
when appropriate, drafts a response using an email assistant agent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Set up the shared prerequisites outlined in `../README.md`, including the virtual environment, dependency installation, and Azure OpenAI and storage configuration.
|
||||
|
||||
## Scenario Overview
|
||||
- Two Azure OpenAI agents share a single deployment: one flags spam, the other drafts replies.
|
||||
- Structured responses (`is_spam` and `reason`, or `response`) determine which orchestration branch runs.
|
||||
- Activity functions handle the side effects of spam handling and email sending.
|
||||
|
||||
## Running the Sample
|
||||
Submit an email payload:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/spamdetection/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"subject": "Sale now on", "body": "Limited time offer"}'
|
||||
```
|
||||
|
||||
Poll the returned `statusQueryGetUri` or call the status route directly:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/spamdetection/status/<instanceId>
|
||||
```
|
||||
|
||||
> **Note:** The spam detection run endpoint waits for responses by default. To opt into an immediate HTTP 202, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the POST body.
|
||||
|
||||
## Expected Responses
|
||||
- Spam payloads return `Email marked as spam: <reason>` by invoking the `handle_spam_email` activity.
|
||||
- Legitimate emails return `Email sent: <draft>` after the email assistant agent produces a structured reply.
|
||||
- The status endpoint mirrors Durable Functions metadata, including runtime status and the agent output.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
### Test spam detection with a legitimate email
|
||||
POST http://localhost:7071/api/spamdetection/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email_id": "email-001",
|
||||
"email_content": "Hi John, I hope you're doing well. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!"
|
||||
}
|
||||
|
||||
|
||||
### Test spam detection with a spam email
|
||||
POST http://localhost:7071/api/spamdetection/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email_id": "email-002",
|
||||
"email_content": "URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!"
|
||||
}
|
||||
|
||||
|
||||
### Check the status of the orchestration
|
||||
@instanceId =<Replace with the instance ID from the response above>
|
||||
|
||||
GET http://localhost:7071/api/spamdetection/status/{{instanceId}}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
"""Route email requests through conditional orchestration with two agents.
|
||||
|
||||
Components used in this sample:
|
||||
- AzureOpenAIChatClient agents for spam detection and email drafting.
|
||||
- AgentFunctionApp with Durable orchestration, activity, and HTTP triggers.
|
||||
- Pydantic models that validate payloads and agent JSON responses.
|
||||
|
||||
Prerequisites: set `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`,
|
||||
and either `AZURE_OPENAI_API_KEY` or sign in with Azure CLI before running the
|
||||
Functions host."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
from azure.durable_functions import DurableOrchestrationContext
|
||||
from azure.identity import AzureCliCredential
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 1. Define agent names shared across the orchestration.
|
||||
SPAM_AGENT_NAME = "SpamDetectionAgent"
|
||||
EMAIL_AGENT_NAME = "EmailAssistantAgent"
|
||||
|
||||
|
||||
class SpamDetectionResult(BaseModel):
|
||||
is_spam: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class EmailResponse(BaseModel):
|
||||
response: str
|
||||
|
||||
|
||||
class EmailPayload(BaseModel):
|
||||
email_id: str
|
||||
email_content: str
|
||||
|
||||
# 2. Instantiate both agents so they can be registered with AgentFunctionApp.
|
||||
def _create_agents() -> list[Any]:
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
spam_agent = chat_client.create_agent(
|
||||
name=SPAM_AGENT_NAME,
|
||||
instructions="You are a spam detection assistant that identifies spam emails.",
|
||||
)
|
||||
|
||||
email_agent = chat_client.create_agent(
|
||||
name=EMAIL_AGENT_NAME,
|
||||
instructions="You are an email assistant that helps users draft responses to emails with professionalism.",
|
||||
)
|
||||
|
||||
return [spam_agent, email_agent]
|
||||
|
||||
|
||||
app = AgentFunctionApp(agents=_create_agents(), enable_health_check=True)
|
||||
|
||||
|
||||
# 3. Activities handle the side effects for spam and legitimate emails.
|
||||
@app.activity_trigger(input_name="reason")
|
||||
def handle_spam_email(reason: str) -> str:
|
||||
return f"Email marked as spam: {reason}"
|
||||
|
||||
|
||||
@app.activity_trigger(input_name="message")
|
||||
def send_email(message: str) -> str:
|
||||
return f"Email sent: {message}"
|
||||
|
||||
|
||||
# 4. Orchestration validates input, runs agents, and branches on spam results.
|
||||
@app.orchestration_trigger(context_name="context")
|
||||
def spam_detection_orchestration(context: DurableOrchestrationContext):
|
||||
payload_raw = context.get_input()
|
||||
if not isinstance(payload_raw, Mapping):
|
||||
raise ValueError("Email data is required")
|
||||
|
||||
try:
|
||||
payload = EmailPayload.model_validate(payload_raw)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid email payload: {exc}") from exc
|
||||
|
||||
spam_agent = app.get_agent(context, SPAM_AGENT_NAME)
|
||||
email_agent = app.get_agent(context, EMAIL_AGENT_NAME)
|
||||
|
||||
spam_thread = spam_agent.get_new_thread()
|
||||
|
||||
spam_prompt = (
|
||||
"Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) "
|
||||
"and 'reason' (string) fields:\n"
|
||||
f"Email ID: {payload.email_id}\n"
|
||||
f"Content: {payload.email_content}"
|
||||
)
|
||||
|
||||
spam_result_raw = yield spam_agent.run(
|
||||
messages=spam_prompt,
|
||||
thread=spam_thread,
|
||||
response_format=SpamDetectionResult,
|
||||
)
|
||||
|
||||
spam_result = cast(SpamDetectionResult, _coerce_structured(spam_result_raw, SpamDetectionResult))
|
||||
|
||||
if spam_result.is_spam:
|
||||
result = yield context.call_activity("handle_spam_email", spam_result.reason)
|
||||
return result
|
||||
|
||||
email_thread = email_agent.get_new_thread()
|
||||
|
||||
email_prompt = (
|
||||
"Draft a professional response to this email. Return a JSON response with a 'response' field "
|
||||
"containing the reply:\n\n"
|
||||
f"Email ID: {payload.email_id}\n"
|
||||
f"Content: {payload.email_content}"
|
||||
)
|
||||
|
||||
email_result_raw = yield email_agent.run(
|
||||
messages=email_prompt,
|
||||
thread=email_thread,
|
||||
response_format=EmailResponse,
|
||||
)
|
||||
|
||||
email_result = cast(EmailResponse, _coerce_structured(email_result_raw, EmailResponse))
|
||||
|
||||
result = yield context.call_activity("send_email", email_result.response)
|
||||
return result
|
||||
|
||||
|
||||
# 5. HTTP starter endpoint launches the orchestration for each email payload.
|
||||
@app.route(route="spamdetection/run", methods=["POST"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def start_spam_detection_orchestration(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
try:
|
||||
body = req.get_json()
|
||||
except ValueError:
|
||||
body = None
|
||||
|
||||
if not isinstance(body, Mapping):
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Email data is required"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = EmailPayload.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": f"Invalid email payload: {exc}"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
instance_id = await client.start_new(
|
||||
orchestration_function_name="spam_detection_orchestration",
|
||||
client_input=payload.model_dump(),
|
||||
)
|
||||
|
||||
logger.info("[HTTP] Started spam detection orchestration with instance_id: %s", instance_id)
|
||||
|
||||
status_url = _build_status_url(req.url, instance_id, route="spamdetection")
|
||||
|
||||
payload_json = {
|
||||
"message": "Spam detection orchestration started.",
|
||||
"emailId": payload.email_id,
|
||||
"instanceId": instance_id,
|
||||
"statusQueryGetUri": status_url,
|
||||
}
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(payload_json),
|
||||
status_code=202,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 6. Status endpoint mirrors Durable Functions default payload with agent data.
|
||||
@app.route(route="spamdetection/status/{instanceId}", methods=["GET"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def get_orchestration_status(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
instance_id = req.route_params.get("instanceId")
|
||||
if not instance_id:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Missing instanceId"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
status = await client.get_status(instance_id)
|
||||
if status is None:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Instance not found"}),
|
||||
status_code=404,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"instanceId": status.instance_id,
|
||||
"runtimeStatus": status.runtime_status.name if status.runtime_status else None,
|
||||
"createdTime": status.created_time.isoformat() if status.created_time else None,
|
||||
"lastUpdatedTime": status.last_updated_time.isoformat() if status.last_updated_time else None,
|
||||
}
|
||||
|
||||
if status.input_ is not None:
|
||||
response_data["input"] = status.input_
|
||||
|
||||
if status.output is not None:
|
||||
response_data["output"] = status.output
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(response_data),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 7. Helper utilities keep URL construction and structured parsing deterministic.
|
||||
def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str:
|
||||
base_url, _, _ = request_url.partition("/api/")
|
||||
if not base_url:
|
||||
base_url = request_url.rstrip("/")
|
||||
return f"{base_url}/api/{route}/status/{instance_id}"
|
||||
|
||||
|
||||
def _coerce_structured(result: Mapping[str, Any], model: type[BaseModel]) -> BaseModel:
|
||||
structured = result.get("structured_response") if isinstance(result, Mapping) else None
|
||||
if structured is not None:
|
||||
return model.model_validate(structured)
|
||||
|
||||
response_text = result.get("response") if isinstance(result, Mapping) else None
|
||||
if isinstance(response_text, str) and response_text.strip():
|
||||
try:
|
||||
parsed = json.loads(response_text)
|
||||
if isinstance(parsed, Mapping):
|
||||
return model.model_validate(parsed)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[ConditionalOrchestration] Failed to parse agent JSON response; raising error.")
|
||||
|
||||
# If parsing failed, raise to surface the issue to the caller.
|
||||
raise ValueError(f"Agent response could not be parsed as {model.__name__}.")
|
||||
|
||||
|
||||
"""
|
||||
Expected response from `POST /api/spamdetection/run`:
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
{
|
||||
"message": "Spam detection orchestration started.",
|
||||
"emailId": "123",
|
||||
"instanceId": "<durable-instance-id>",
|
||||
"statusQueryGetUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/<durable-instance-id>"
|
||||
}
|
||||
|
||||
Expected response from `GET /api/spamdetection/status/{instanceId}` once complete:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{
|
||||
"instanceId": "<durable-instance-id>",
|
||||
"runtimeStatus": "Completed",
|
||||
"createdTime": "2024-01-01T00:00:00+00:00",
|
||||
"lastUpdatedTime": "2024-01-01T00:00:10+00:00",
|
||||
"output": "Email sent: Thank you for reaching out..."
|
||||
}
|
||||
"""
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>",
|
||||
"AZURE_OPENAI_API_KEY": "<AZURE_OPENAI_API_KEY>"
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
agent-framework-azurefunctions
|
||||
azure-identity
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Single-Agent Orchestration (HITL) – Python
|
||||
|
||||
This sample demonstrates the human-in-the-loop (HITL) scenario.
|
||||
A single writer agent iterates on content until a human reviewer approves the
|
||||
output or a maximum number of attempts is reached.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Complete the common setup instructions in `../README.md` to prepare the virtual environment, install dependencies, and configure Azure OpenAI and storage settings.
|
||||
|
||||
## What It Shows
|
||||
- Identical environment variable usage (`AZURE_OPENAI_ENDPOINT`,
|
||||
`AZURE_OPENAI_DEPLOYMENT`) and HTTP surface area (`/api/hitl/...`).
|
||||
- Durable orchestrations that pause for external events while maintaining
|
||||
deterministic state (`context.wait_for_external_event` + timed cancellation).
|
||||
- Activity functions that encapsulate the out-of-band operations such as notifying
|
||||
a reviewer and publishing content.
|
||||
|
||||
## Running the Sample
|
||||
Start the HITL orchestration:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/hitl/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"topic": "Write a friendly release note"}'
|
||||
```
|
||||
|
||||
Poll the returned `statusQueryGetUri` or call the status route directly:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/hitl/status/<instanceId>
|
||||
```
|
||||
|
||||
Approve or reject the draft:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/hitl/approve/<instanceId> \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"approved": true, "feedback": "Looks good"}'
|
||||
```
|
||||
|
||||
> **Note:** Calls to the underlying agent run endpoint wait for responses by default. If you need an immediate HTTP 202 response, set the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body.
|
||||
|
||||
## Expected Responses
|
||||
- `POST /api/hitl/run` returns a 202 Accepted payload with the Durable Functions instance ID.
|
||||
- `POST /api/hitl/approve/{instanceId}` echoes the decision that the orchestration receives.
|
||||
- `GET /api/hitl/status/{instanceId}` reports `runtimeStatus`, custom status messages, and the final content when approved.
|
||||
The orchestration sets custom status messages, retries on rejection with reviewer feedback, and raises a timeout if human approval does not arrive.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
### Start the HITL content generation orchestration with default timeout (72 hours)
|
||||
POST http://localhost:7071/api/hitl/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"topic": "The Future of Artificial Intelligence",
|
||||
"max_review_attempts": 3
|
||||
}
|
||||
|
||||
|
||||
### Start the HITL content generation orchestration with a short timeout (~4 seconds)
|
||||
POST http://localhost:7071/api/hitl/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"topic": "The Future of Artificial Intelligence",
|
||||
"max_review_attempts": 3,
|
||||
"approval_timeout_hours": 0.001
|
||||
}
|
||||
|
||||
|
||||
### Replace INSTANCE_ID_GOES_HERE below with the value returned from the POST call
|
||||
@instanceId=<INSTANCE_ID_GOES_HERE>
|
||||
|
||||
### Check the status of the orchestration
|
||||
GET http://localhost:7071/api/hitl/status/{{instanceId}}
|
||||
|
||||
### Send human approval
|
||||
POST http://localhost:7071/api/hitl/approve/{{instanceId}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"approved": true,
|
||||
"feedback": "Great article! The content is well-structured and informative."
|
||||
}
|
||||
|
||||
### Send human rejection with feedback
|
||||
POST http://localhost:7071/api/hitl/approve/{{instanceId}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"approved": false,
|
||||
"feedback": "The article needs more technical depth and better examples."
|
||||
}
|
||||
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
"""Iterate on generated content with a human-in-the-loop Durable orchestration.
|
||||
|
||||
Components used in this sample:
|
||||
- AzureOpenAIChatClient for a single writer agent that emits structured JSON.
|
||||
- AgentFunctionApp with Durable orchestration, HTTP triggers, and activity triggers.
|
||||
- External events that pause the workflow until a human decision arrives or times out.
|
||||
|
||||
Prerequisites: configure `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and
|
||||
either `AZURE_OPENAI_API_KEY` or sign in with Azure CLI before running `func start`."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import azure.durable_functions as df
|
||||
import azure.functions as func
|
||||
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
|
||||
from azure.durable_functions import DurableOrchestrationContext
|
||||
from azure.identity import AzureCliCredential
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 1. Define orchestration constants used throughout the workflow.
|
||||
WRITER_AGENT_NAME = "WriterAgent"
|
||||
HUMAN_APPROVAL_EVENT = "HumanApproval"
|
||||
|
||||
|
||||
class ContentGenerationInput(BaseModel):
|
||||
topic: str
|
||||
max_review_attempts: int = 3
|
||||
approval_timeout_hours: float = 72
|
||||
|
||||
|
||||
class GeneratedContent(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
|
||||
|
||||
class HumanApproval(BaseModel):
|
||||
approved: bool
|
||||
feedback: str = ""
|
||||
|
||||
|
||||
# 2. Create the writer agent that produces structured JSON responses.
|
||||
def _create_writer_agent() -> Any:
|
||||
instructions = (
|
||||
"You are a professional content writer who creates high-quality articles on various topics. "
|
||||
"You write engaging, informative, and well-structured content that follows best practices for readability and accuracy. "
|
||||
"Return your response as JSON with 'title' and 'content' fields."
|
||||
)
|
||||
|
||||
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
|
||||
name=WRITER_AGENT_NAME,
|
||||
instructions=instructions,
|
||||
)
|
||||
|
||||
|
||||
app = AgentFunctionApp(agents=[_create_writer_agent()], enable_health_check=True)
|
||||
|
||||
|
||||
# 3. Activities encapsulate external work for review notifications and publishing.
|
||||
@app.activity_trigger(input_name="content")
|
||||
def notify_user_for_approval(content: dict) -> None:
|
||||
model = GeneratedContent.model_validate(content)
|
||||
logger.info("NOTIFICATION: Please review the following content for approval:")
|
||||
logger.info("Title: %s", model.title or "(untitled)")
|
||||
logger.info("Content: %s", model.content)
|
||||
logger.info("Use the approval endpoint to approve or reject this content.")
|
||||
|
||||
|
||||
@app.activity_trigger(input_name="content")
|
||||
def publish_content(content: dict) -> None:
|
||||
model = GeneratedContent.model_validate(content)
|
||||
logger.info("PUBLISHING: Content has been published successfully:")
|
||||
logger.info("Title: %s", model.title or "(untitled)")
|
||||
logger.info("Content: %s", model.content)
|
||||
|
||||
|
||||
# 4. Orchestration loops until the human approves, times out, or attempts are exhausted.
|
||||
@app.orchestration_trigger(context_name="context")
|
||||
def content_generation_hitl_orchestration(context: DurableOrchestrationContext):
|
||||
payload_raw = context.get_input()
|
||||
if not isinstance(payload_raw, Mapping):
|
||||
raise ValueError("Content generation input is required")
|
||||
|
||||
try:
|
||||
payload = ContentGenerationInput.model_validate(payload_raw)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid content generation input: {exc}") from exc
|
||||
|
||||
writer = app.get_agent(context, WRITER_AGENT_NAME)
|
||||
writer_thread = writer.get_new_thread()
|
||||
|
||||
context.set_custom_status(f"Starting content generation for topic: {payload.topic}")
|
||||
|
||||
initial_raw = yield writer.run(
|
||||
messages=f"Write a short article about '{payload.topic}'.",
|
||||
thread=writer_thread,
|
||||
response_format=GeneratedContent,
|
||||
)
|
||||
content = _coerce_generated_content(initial_raw)
|
||||
|
||||
attempt = 0
|
||||
while attempt < payload.max_review_attempts:
|
||||
attempt += 1
|
||||
context.set_custom_status(
|
||||
f"Requesting human feedback. Iteration #{attempt}. Timeout: {payload.approval_timeout_hours} hour(s)."
|
||||
)
|
||||
|
||||
yield context.call_activity("notify_user_for_approval", content.model_dump())
|
||||
|
||||
approval_task = context.wait_for_external_event(HUMAN_APPROVAL_EVENT)
|
||||
timeout_task = context.create_timer(
|
||||
context.current_utc_datetime + timedelta(hours=payload.approval_timeout_hours)
|
||||
)
|
||||
|
||||
winner = yield context.task_any([approval_task, timeout_task])
|
||||
|
||||
if winner == approval_task:
|
||||
timeout_task.cancel() # type: ignore[attr-defined]
|
||||
approval_payload = _parse_human_approval(approval_task.result)
|
||||
|
||||
if approval_payload.approved:
|
||||
context.set_custom_status("Content approved by human reviewer. Publishing content...")
|
||||
yield context.call_activity("publish_content", content.model_dump())
|
||||
context.set_custom_status(
|
||||
f"Content published successfully at {context.current_utc_datetime:%Y-%m-%dT%H:%M:%S}"
|
||||
)
|
||||
return {"content": content.content}
|
||||
|
||||
context.set_custom_status(
|
||||
"Content rejected by human reviewer. Incorporating feedback and regenerating..."
|
||||
)
|
||||
rewrite_prompt = (
|
||||
"The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.\n\n"
|
||||
f"Human Feedback: {approval_payload.feedback or 'No feedback provided.'}"
|
||||
)
|
||||
rewritten_raw = yield writer.run(
|
||||
messages=rewrite_prompt,
|
||||
thread=writer_thread,
|
||||
response_format=GeneratedContent,
|
||||
)
|
||||
content = _coerce_generated_content(rewritten_raw)
|
||||
else:
|
||||
context.set_custom_status(
|
||||
f"Human approval timed out after {payload.approval_timeout_hours} hour(s). Treating as rejection."
|
||||
)
|
||||
raise TimeoutError(
|
||||
f"Human approval timed out after {payload.approval_timeout_hours} hour(s)."
|
||||
)
|
||||
|
||||
raise RuntimeError(f"Content could not be approved after {payload.max_review_attempts} iteration(s).")
|
||||
|
||||
|
||||
# 5. HTTP endpoint that starts the human-in-the-loop orchestration.
|
||||
@app.route(route="hitl/run", methods=["POST"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def start_content_generation(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
try:
|
||||
body = req.get_json()
|
||||
except ValueError:
|
||||
body = None
|
||||
|
||||
if not isinstance(body, Mapping):
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Request body must be valid JSON."}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = ContentGenerationInput.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": f"Invalid content generation input: {exc}"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
instance_id = await client.start_new(
|
||||
orchestration_function_name="content_generation_hitl_orchestration",
|
||||
client_input=payload.model_dump(),
|
||||
)
|
||||
|
||||
status_url = _build_status_url(req.url, instance_id, route="hitl")
|
||||
|
||||
payload_json = {
|
||||
"message": "HITL content generation orchestration started.",
|
||||
"topic": payload.topic,
|
||||
"instanceId": instance_id,
|
||||
"statusQueryGetUri": status_url,
|
||||
}
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(payload_json),
|
||||
status_code=202,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 6. Endpoint that delivers human approval or rejection back into the orchestration.
|
||||
@app.route(route="hitl/approve/{instanceId}", methods=["POST"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def send_human_approval(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
instance_id = req.route_params.get("instanceId")
|
||||
if not instance_id:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Missing instanceId in route."}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
try:
|
||||
body = req.get_json()
|
||||
except ValueError:
|
||||
body = None
|
||||
|
||||
if not isinstance(body, Mapping):
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Approval response is required"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
try:
|
||||
approval = HumanApproval.model_validate(body)
|
||||
except ValidationError as exc:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": f"Invalid approval payload: {exc}"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
await client.raise_event(instance_id, HUMAN_APPROVAL_EVENT, approval.model_dump())
|
||||
|
||||
payload_json = {
|
||||
"message": "Human approval sent to orchestration.",
|
||||
"instanceId": instance_id,
|
||||
"approved": approval.approved,
|
||||
}
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(payload_json),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 7. Endpoint that mirrors Durable Functions status plus custom workflow messaging.
|
||||
@app.route(route="hitl/status/{instanceId}", methods=["GET"])
|
||||
@app.durable_client_input(client_name="client")
|
||||
async def get_orchestration_status(
|
||||
req: func.HttpRequest,
|
||||
client: df.DurableOrchestrationClient,
|
||||
) -> func.HttpResponse:
|
||||
instance_id = req.route_params.get("instanceId")
|
||||
if not instance_id:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Missing instanceId"}),
|
||||
status_code=400,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
status = await client.get_status(
|
||||
instance_id,
|
||||
show_history=False,
|
||||
show_history_output=False,
|
||||
show_input=True,
|
||||
)
|
||||
|
||||
# Check if status is None or if the instance doesn't exist (runtime_status is None)
|
||||
if status is None or getattr(status, "runtime_status", None) is None:
|
||||
return func.HttpResponse(
|
||||
body=json.dumps({"error": "Instance not found."}),
|
||||
status_code=404,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"instanceId": getattr(status, "instance_id", None),
|
||||
"runtimeStatus": getattr(status.runtime_status, "name", None)
|
||||
if getattr(status, "runtime_status", None)
|
||||
else None,
|
||||
"workflowStatus": getattr(status, "custom_status", None),
|
||||
}
|
||||
|
||||
if getattr(status, "input_", None) is not None:
|
||||
response_data["input"] = status.input_
|
||||
|
||||
if getattr(status, "output", None) is not None:
|
||||
response_data["output"] = status.output
|
||||
|
||||
failure_details = getattr(status, "failure_details", None)
|
||||
if failure_details is not None:
|
||||
response_data["failureDetails"] = failure_details
|
||||
|
||||
return func.HttpResponse(
|
||||
body=json.dumps(response_data),
|
||||
status_code=200,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
# 8. Helper utilities keep parsing logic deterministic.
|
||||
def _build_status_url(request_url: str, instance_id: str, *, route: str) -> str:
|
||||
base_url, _, _ = request_url.partition("/api/")
|
||||
if not base_url:
|
||||
base_url = request_url.rstrip("/")
|
||||
return f"{base_url}/api/{route}/status/{instance_id}"
|
||||
|
||||
|
||||
def _coerce_generated_content(result: Mapping[str, Any]) -> GeneratedContent:
|
||||
structured = result.get("structured_response") if isinstance(result, Mapping) else None
|
||||
if structured is not None:
|
||||
return GeneratedContent.model_validate(structured)
|
||||
|
||||
response_text = result.get("response") if isinstance(result, Mapping) else None
|
||||
if isinstance(response_text, str) and response_text.strip():
|
||||
try:
|
||||
parsed = json.loads(response_text)
|
||||
if isinstance(parsed, Mapping):
|
||||
return GeneratedContent.model_validate(parsed)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[HITL] Failed to parse agent JSON response; falling back to defaults.")
|
||||
|
||||
raise ValueError("Agent response could not be parsed as GeneratedContent.")
|
||||
|
||||
|
||||
def _parse_human_approval(raw: Any) -> HumanApproval:
|
||||
if isinstance(raw, Mapping):
|
||||
return HumanApproval.model_validate(raw)
|
||||
|
||||
if isinstance(raw, str):
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
return HumanApproval(approved=False, feedback="")
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
if isinstance(parsed, Mapping):
|
||||
return HumanApproval.model_validate(parsed)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(
|
||||
"[HITL] Approval payload is not valid JSON; using string heuristics.",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
affirmative = {"true", "yes", "approved", "y", "1"}
|
||||
negative = {"false", "no", "rejected", "n", "0"}
|
||||
lower = stripped.lower()
|
||||
if lower in affirmative:
|
||||
return HumanApproval(approved=True, feedback="")
|
||||
if lower in negative:
|
||||
return HumanApproval(approved=False, feedback="")
|
||||
return HumanApproval(approved=False, feedback=stripped)
|
||||
|
||||
raise ValueError("Approval payload must be a JSON object or string.")
|
||||
|
||||
|
||||
"""
|
||||
Expected response from `POST /api/hitl/run`:
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
{
|
||||
"message": "HITL content generation orchestration started.",
|
||||
"topic": "Contoso launch",
|
||||
"instanceId": "<durable-instance-id>",
|
||||
"statusQueryGetUri": "http://localhost:7071/api/hitl/status/<durable-instance-id>"
|
||||
}
|
||||
|
||||
Expected response after approving via `POST /api/hitl/approve/{instanceId}`:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{
|
||||
"message": "Human approval sent to orchestration.",
|
||||
"instanceId": "<durable-instance-id>",
|
||||
"approved": true
|
||||
}
|
||||
|
||||
Expected response from `GET /api/hitl/status/{instanceId}` once published:
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{
|
||||
"instanceId": "<durable-instance-id>",
|
||||
"runtimeStatus": "Completed",
|
||||
"workflowStatus": "Content published successfully at 2024-01-01T12:00:00",
|
||||
"output": {
|
||||
"content": "Thank you for joining the Contoso product launch..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
|
||||
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>",
|
||||
"AZURE_OPENAI_API_KEY": "<AZURE_OPENAI_API_KEY>"
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
agent-framework-azurefunctions
|
||||
azure-identity
|
||||
@@ -0,0 +1,48 @@
|
||||
These are common instructions for setting up your environment for every sample in this directory.
|
||||
These samples illustrate the Durable extensibility for Agent Framework running in Azure Functions.
|
||||
|
||||
All of these samples are set up to run in Azure Functions. Azure Functions has a local development tool called [CoreTools](https://learn.microsoft.com/azure/azure-functions/functions-run-local?tabs=windows%2Cpython%2Cv2&pivots=programming-language-python#install-the-azure-functions-core-tools) which we will set up to run these samples locally.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### 1. Install dependencies and create appropriate services
|
||||
|
||||
- Install [Azure Functions Core Tools 4.x](https://learn.microsoft.com/azure/azure-functions/functions-run-local?tabs=windows%2Cpython%2Cv2&pivots=programming-language-python#install-the-azure-functions-core-tools)
|
||||
|
||||
- Install [Azurite storage emulator](https://learn.microsoft.com/en-us/azure/storage/common/storage-install-azurite?toc=%2Fazure%2Fstorage%2Fblobs%2Ftoc.json&bc=%2Fazure%2Fstorage%2Fblobs%2Fbreadcrumb%2Ftoc.json&tabs=visual-studio%2Cblob-storage)
|
||||
|
||||
- Create an [Azure OpenAI](https://azure.microsoft.com/en-us/products/ai-foundry/models/openai) resource. Note the Azure OpenAI endpoint, deployment name, and the key (or ensure you can authenticate with `AzureCliCredential`).
|
||||
|
||||
- Install a tool to execute HTTP calls, for example the [REST Client extension](https://marketplace.visualstudio.com/items?itemName=humao.rest-client)
|
||||
|
||||
- [Optionally] Create an [Azure Function Python app](https://learn.microsoft.com/en-us/azure/azure-functions/functions-create-function-app-portal?tabs=core-tools&pivots=flex-consumption-plan) to later deploy your app to Azure if you so desire.
|
||||
|
||||
### 2. Create and activate a virtual environment
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 3. Running the samples
|
||||
|
||||
- [Start the Azurite emulator](https://learn.microsoft.com/en-us/azure/storage/common/storage-install-azurite?tabs=npm%2Cblob-storage#run-azurite)
|
||||
|
||||
- Inside each sample:
|
||||
|
||||
- Install Python dependencies – from the sample directory, run `pip install -r requirements.txt` (or the equivalent in your active virtual environment).
|
||||
|
||||
- Copy `local.settings.json.template` to `local.settings.json`, then update `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` for Azure OpenAI authentication. The samples use `AzureCliCredential` by default, so ensure you're logged in via `az login`.
|
||||
- Alternatively, you can use API key authentication by setting `AZURE_OPENAI_API_KEY` and updating the code to use `AzureOpenAIChatClient()` without the credential parameter.
|
||||
- Keep `TASKHUB_NAME` set to `default` unless you plan to change the durable task hub name.
|
||||
|
||||
- Run the command `func start` from the root of the sample
|
||||
|
||||
- Follow each sample's README for scenario-specific steps, and use its `demo.http` file (or provided curl examples) to trigger the hosted HTTP endpoints.
|
||||
Generated
+76
-1
@@ -28,6 +28,7 @@ members = [
|
||||
"agent-framework-ag-ui",
|
||||
"agent-framework-anthropic",
|
||||
"agent-framework-azure-ai",
|
||||
"agent-framework-azurefunctions",
|
||||
"agent-framework-chatkit",
|
||||
"agent-framework-copilotstudio",
|
||||
"agent-framework-core",
|
||||
@@ -88,6 +89,7 @@ dependencies = [
|
||||
{ name = "agent-framework-ag-ui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-anthropic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-azurefunctions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -132,6 +134,7 @@ requires-dist = [
|
||||
{ name = "agent-framework-ag-ui", editable = "packages/ag-ui" },
|
||||
{ name = "agent-framework-anthropic", editable = "packages/anthropic" },
|
||||
{ name = "agent-framework-azure-ai", editable = "packages/azure-ai" },
|
||||
{ name = "agent-framework-azurefunctions", editable = "packages/azurefunctions" },
|
||||
{ name = "agent-framework-chatkit", editable = "packages/chatkit" },
|
||||
{ name = "agent-framework-copilotstudio", editable = "packages/copilotstudio" },
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
@@ -249,6 +252,23 @@ requires-dist = [
|
||||
{ name = "azure-ai-projects", specifier = ">=2.0.0b1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azurefunctions"
|
||||
version = "1.0.0b251112"
|
||||
source = { editable = "packages/azurefunctions" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-functions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-functions-durable", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "azure-functions" },
|
||||
{ name = "azure-functions-durable" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-chatkit"
|
||||
version = "1.0.0b251112"
|
||||
@@ -905,6 +925,36 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/3c/b90d5afc2e47c4a45f4bba00f9c3193b0417fad5ad3bb07869f9d12832aa/azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b", size = 213302, upload-time = "2025-10-15T00:33:51.058Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-functions"
|
||||
version = "1.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/be/5535830e0658e9668093941b3c33b0ea03eceadbf6bd6b7870aa37ef071a/azure_functions-1.24.0.tar.gz", hash = "sha256:18ea1607c7a7268b7a1e1bd0cc28c5cc57a9db6baaacddb39ba0e9f865728187", size = 134495, upload-time = "2025-10-06T19:08:08.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/76/e6c5809ee0295e882b6c9ad595896748e33989d353b67316a854f65fb754/azure_functions-1.24.0-py3-none-any.whl", hash = "sha256:32b12c2a219824525849dd92036488edeb70d306d164efd9e941f10f9ac0a91c", size = 108341, upload-time = "2025-10-06T19:08:07.128Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-functions-durable"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "azure-functions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "furl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/3a/f168b434fa69eaaf5d14b54d88239b851eceb7e10f666b55289dd0933ccb/azure-functions-durable-1.4.0.tar.gz", hash = "sha256:945488ef28917dae4295a4dd6e6f6601ffabe32e3fbb94ceb261c9b65b6e6c0f", size = 176584, upload-time = "2025-09-24T23:57:46.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/01/7f03229fa5c05a5cc7e41172aef80c5242d28aeea0825f592f93141a4b91/azure_functions_durable-1.4.0-py3-none-any.whl", hash = "sha256:0efe919cdda96924791feabe192a37c7d872414b4c6ce348417a02ee53d8cc31", size = 143159, upload-time = "2025-09-24T23:57:45.294Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "azure-identity"
|
||||
version = "1.25.1"
|
||||
@@ -1679,7 +1729,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
|
||||
wheels = [
|
||||
@@ -2040,6 +2090,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "furl"
|
||||
version = "2.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "orderedmultidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/203a76fa2ef46cdb0a618295cc115220cbb874229d4d8721068335eb87f0/furl-2.1.4.tar.gz", hash = "sha256:877657501266c929269739fb5f5980534a41abd6bbabcb367c136d1d3b2a6015", size = 57526, upload-time = "2025-03-09T05:36:21.175Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/8c/dce3b1b7593858eba995b2dfdb833f872c7f863e3da92aab7128a6b11af4/furl-2.1.4-py2.py3-none-any.whl", hash = "sha256:da34d0b34e53ffe2d2e6851a7085a05d96922b5b578620a37377ff1dbeeb11c8", size = 27550, upload-time = "2025-03-09T05:36:19.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.28.1"
|
||||
@@ -3750,6 +3813,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orderedmultidict"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/4e/3823a27d764bb8388711f4cb6f24e58453e92d6928f4163fdb01e3a3789f/orderedmultidict-1.0.1.tar.gz", hash = "sha256:04070bbb5e87291cc9bfa51df413677faf2141c73c61d2a5f7b26bea3cd882ad", size = 20706, upload-time = "2019-07-10T20:11:47.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/16/5e95c70bda8fe6ea715005c0db8e602400bdba50ae3c72cb380eba551289/orderedmultidict-1.0.1-py2.py3-none-any.whl", hash = "sha256:43c839a17ee3cdd62234c47deca1a8508a3f2ca1d0678a3bf791c87cf84adbf3", size = 11699, upload-time = "2019-07-10T20:11:45.622Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orderly-set"
|
||||
version = "5.5.0"
|
||||
|
||||
Reference in New Issue
Block a user