chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Blocked by required conditions
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / paths-filter (push) Waiting to run
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Blocked by required conditions
dotnet-build-and-test / dotnet-test-functions (push) Blocked by required conditions
dotnet-build-and-test / dotnet-build-and-test-check (push) Blocked by required conditions
dotnet-build-and-test / Integration Test Report (push) Blocked by required conditions
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Blocked by required conditions
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / paths-filter (push) Waiting to run
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Blocked by required conditions
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Blocked by required conditions
dotnet-build-and-test / dotnet-test-functions (push) Blocked by required conditions
dotnet-build-and-test / dotnet-build-and-test-check (push) Blocked by required conditions
dotnet-build-and-test / Integration Test Report (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Hosting Samples
|
||||
|
||||
This directory contains Python samples that demonstrate different ways to host Agent Framework agents. Use this page to choose the hosting model that best fits your scenario, then continue to the README in the relevant subdirectory.
|
||||
|
||||
## Hosting Options
|
||||
|
||||
| Option | Use this when you need... | Start here |
|
||||
|--------|----------------------------|------------|
|
||||
| A2A | Agent-to-Agent protocol interoperability or remote agent invocation. | [`a2a/README.md`](./a2a/README.md) |
|
||||
| Azure Functions | HTTP or serverless hosting on Azure Functions. | [`azure_functions/README.md`](./azure_functions/README.md) |
|
||||
| Durable Task | Durable execution, long-running flows, or orchestration patterns. | [`durabletask/README.md`](./durabletask/README.md) |
|
||||
| Foundry Hosted Agents | Azure AI Foundry hosted agent deployment. | [`foundry-hosted-agents/README.md`](./foundry-hosted-agents/README.md) |
|
||||
|
||||
## How to Choose
|
||||
|
||||
- Start with **A2A** if you want one agent to call or expose another agent over the A2A protocol.
|
||||
- Start with **Azure Functions** if you want an HTTP-hosted or serverless entry point using Azure Functions.
|
||||
- Start with **Durable Task** if you need persistent state, durable workflows, or orchestration across multiple steps.
|
||||
- Start with **Foundry Hosted Agents** if you want to package and deploy an agent as a hosted agent in Azure AI Foundry.
|
||||
|
||||
## Common Prerequisites
|
||||
|
||||
Most hosting samples share a small set of prerequisites:
|
||||
|
||||
- A supported Python environment for running the samples locally.
|
||||
- An Azure AI Foundry project endpoint and model deployment name for `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL`.
|
||||
- Azure CLI authentication via `az login` when the sample uses `AzureCliCredential`.
|
||||
- Any hosting-specific tools or extra services called out in the subdirectory README.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Pick the hosting approach that matches your scenario.
|
||||
2. Open the corresponding README for setup and run instructions.
|
||||
3. Follow that sample's environment, dependency, and execution steps.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Azure AI Foundry Configuration (required for a2a_server.py and a2a_agent_as_function_tools.py)
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/api/projects/your-project
|
||||
FOUNDRY_MODEL=gpt-4o
|
||||
|
||||
# A2A Client Configuration (required for agent_with_a2a.py and a2a_agent_as_function_tools.py)
|
||||
# The function-tools flow uses http://localhost:5000/ in the sample docs/output.
|
||||
# Update this value if you start a different local A2A server instance on another port.
|
||||
A2A_AGENT_HOST=http://localhost:5000/
|
||||
@@ -0,0 +1,94 @@
|
||||
# A2A Server Hosting Examples
|
||||
|
||||
This sample demonstrates how to **host** Agent Framework agents as A2A-compliant servers using the [A2A (Agent2Agent) protocol](https://a2a-protocol.org/latest/).
|
||||
|
||||
> **Looking for client samples?** See [`samples/02-agents/a2a/`](../../02-agents/a2a/) for consuming remote A2A agents.
|
||||
|
||||
## Server Samples
|
||||
|
||||
| Run this file | To... |
|
||||
|---------------|-------|
|
||||
| **[`a2a_server.py`](a2a_server.py)** | Host an Agent Framework agent as an A2A-compliant server (multi-agent). |
|
||||
| **[`agent_framework_to_a2a.py`](agent_framework_to_a2a.py)** | Minimal example: expose a single agent as an A2A server. |
|
||||
|
||||
## Supporting Modules
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`agent_definitions.py`](agent_definitions.py) | Agent and AgentCard factory definitions for invoice, policy, and logistics agents. |
|
||||
| [`invoice_data.py`](invoice_data.py) | Mock invoice data and tool functions for the invoice agent. |
|
||||
| [`a2a_server.http`](a2a_server.http) | REST Client requests for testing the server directly from VS Code. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required (Server)
|
||||
- `FOUNDRY_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint
|
||||
- `FOUNDRY_MODEL` — Model deployment name (e.g. `gpt-4o`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
All commands below should be run from this directory:
|
||||
|
||||
```powershell
|
||||
cd python/samples/04-hosting/a2a
|
||||
```
|
||||
|
||||
### 0. Install Dependencies
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your values:
|
||||
|
||||
```powershell
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
**Option A — pip (standard):**
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\Activate.ps1 # Windows
|
||||
# source .venv/bin/activate # macOS / Linux
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Option B — uv:**
|
||||
|
||||
```powershell
|
||||
uv run python a2a_server.py --agent-type policy
|
||||
```
|
||||
|
||||
### 1. Start the A2A Server
|
||||
|
||||
> **Note (Option A — pip users):** Replace `uv run python` with `python` in all `uv run` commands below. `uv` is not required once the virtual environment is activated.
|
||||
|
||||
Pick an agent type and start the server (each in its own terminal):
|
||||
|
||||
```powershell
|
||||
uv run python a2a_server.py --agent-type invoice --port 5000
|
||||
uv run python a2a_server.py --agent-type policy --port 5001
|
||||
uv run python a2a_server.py --agent-type logistics --port 5002
|
||||
```
|
||||
|
||||
You can run one agent or all three — each listens on its own port.
|
||||
|
||||
### 2. Run a Client
|
||||
|
||||
Once a server is running, use any of the client samples in [`samples/02-agents/a2a/`](../../02-agents/a2a/):
|
||||
|
||||
```powershell
|
||||
cd python/samples/02-agents/a2a
|
||||
$env:A2A_AGENT_HOST = "http://localhost:5001/"
|
||||
uv run python agent_with_a2a.py
|
||||
```
|
||||
|
||||
## Security considerations for multi-tenant hosting
|
||||
|
||||
The default `a2a-sdk` task/push-config stores scope ownership by `user_name` only. **Any host that mounts tenant-bearing routes must pass a tenant-aware `owner_resolver`** to the stores, e.g.:
|
||||
|
||||
```python
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
def resolve_tenant_user_scope(context):
|
||||
# Derive tenant + user identity from your host's auth/session context.
|
||||
return f"{context.tenant}:{context.user.user_name}"
|
||||
task_store = InMemoryTaskStore(owner_resolver=resolve_tenant_user_scope)
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
### Each A2A agent is available at a different host address
|
||||
@hostInvoice = http://localhost:5000
|
||||
@hostPolicy = http://localhost:5001
|
||||
@hostLogistics = http://localhost:5002
|
||||
|
||||
### Query agent card for the invoice agent
|
||||
GET {{hostInvoice}}/.well-known/agent.json
|
||||
|
||||
### Send a message to the invoice agent
|
||||
POST {{hostInvoice}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "1",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"messageId": "msg_1",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "Show me all invoices for Contoso"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Query agent card for the policy agent
|
||||
GET {{hostPolicy}}/.well-known/agent.json
|
||||
|
||||
### Send a message to the policy agent
|
||||
POST {{hostPolicy}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "2",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"messageId": "msg_2",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "What is the policy for short shipments?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Query agent card for the logistics agent
|
||||
GET {{hostLogistics}}/.well-known/agent.json
|
||||
|
||||
### Send a message to the logistics agent
|
||||
POST {{hostLogistics}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "3",
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"messageId": "msg_3",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "What is the status for SHPMT-SAP-001?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from agent_definitions import AGENT_CARD_FACTORIES, AGENT_FACTORIES # pyrefly: ignore[missing-import]
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from starlette.applications import Starlette
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
A2A Server Sample — Host an Agent Framework agent as an A2A endpoint
|
||||
|
||||
This sample creates a Python-based A2A-compliant server that wraps an Agent
|
||||
Framework agent. The server uses the a2a-sdk's Starlette application to handle
|
||||
JSON-RPC requests and serves the AgentCard at /.well-known/agent.json.
|
||||
|
||||
Three agent types are available:
|
||||
- invoice — Answers invoice queries using mock data and function tools.
|
||||
- policy — Returns a fixed policy response.
|
||||
- logistics — Returns a fixed logistics response.
|
||||
|
||||
Usage:
|
||||
uv run python a2a_server.py --agent-type policy --port 5001
|
||||
uv run python a2a_server.py --agent-type invoice --port 5000
|
||||
uv run python a2a_server.py --agent-type logistics --port 5002
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Your Azure AI Foundry project endpoint
|
||||
FOUNDRY_MODEL — Model deployment name (e.g. gpt-4o)
|
||||
"""
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="A2A Agent Server")
|
||||
parser.add_argument(
|
||||
"--agent-type",
|
||||
choices=["invoice", "policy", "logistics"],
|
||||
default="policy",
|
||||
help="Type of agent to host (default: policy)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="localhost",
|
||||
help="Host to bind to (default: localhost)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=5001,
|
||||
help="Port to listen on (default: 5001)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
# Validate environment
|
||||
project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT")
|
||||
model = os.getenv("FOUNDRY_MODEL")
|
||||
|
||||
if not project_endpoint:
|
||||
print("Error: FOUNDRY_PROJECT_ENDPOINT environment variable is not set.")
|
||||
sys.exit(1)
|
||||
if not model:
|
||||
print("Error: FOUNDRY_MODEL environment variable is not set.")
|
||||
sys.exit(1)
|
||||
|
||||
# Create the LLM client
|
||||
credential = AzureCliCredential()
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=project_endpoint,
|
||||
model=model,
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
# Create the Agent Framework agent for the chosen type
|
||||
agent_factory = AGENT_FACTORIES[args.agent_type]
|
||||
agent = agent_factory(client)
|
||||
|
||||
# Build the A2A server components
|
||||
url = f"http://{args.host}:{args.port}/"
|
||||
agent_card = AGENT_CARD_FACTORIES[args.agent_type](url)
|
||||
executor = A2AExecutor(agent, stream=True)
|
||||
task_store = InMemoryTaskStore()
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=executor,
|
||||
task_store=task_store,
|
||||
agent_card=agent_card,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(agent_card),
|
||||
*create_jsonrpc_routes(request_handler, "/"),
|
||||
]
|
||||
)
|
||||
|
||||
print(f"Starting A2A server: {agent_card.name}")
|
||||
print(f" Agent type : {args.agent_type}")
|
||||
print(f" Listening : {url}")
|
||||
print(f" Agent card : {url}.well-known/agent.json")
|
||||
print()
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent definitions and AgentCard factories for the A2A server sample.
|
||||
|
||||
Provides factory functions to create Agent Framework agents and A2A
|
||||
AgentCards for the invoice, policy, and logistics agent types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
|
||||
from agent_framework import Agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from invoice_data import query_by_invoice_id, query_by_transaction_id, query_invoices # pyrefly: ignore[missing-import]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent instructions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INVOICE_INSTRUCTIONS = "You specialize in handling queries related to invoices."
|
||||
|
||||
POLICY_INSTRUCTIONS = """\
|
||||
You specialize in handling queries related to policies and customer communications.
|
||||
|
||||
Always reply with exactly this text:
|
||||
|
||||
Policy: Short Shipment Dispute Handling Policy V2.1
|
||||
|
||||
Summary: "For short shipments reported by customers, first verify internal shipment records
|
||||
(SAP) and physical logistics scan data (BigQuery). If discrepancy is confirmed and logistics data
|
||||
shows fewer items packed than invoiced, issue a credit for the missing items. Document the
|
||||
resolution in SAP CRM and notify the customer via email within 2 business days, referencing the
|
||||
original invoice and the credit memo number. Use the 'Formal Credit Notification' email
|
||||
template."
|
||||
"""
|
||||
|
||||
LOGISTICS_INSTRUCTIONS = """\
|
||||
You specialize in handling queries related to logistics.
|
||||
|
||||
Always reply with exactly:
|
||||
|
||||
Shipment number: SHPMT-SAP-001
|
||||
Item: TSHIRT-RED-L
|
||||
Quantity: 900
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_invoice_agent(client: FoundryChatClient) -> Agent:
|
||||
"""Create an invoice agent backed by the given client with query tools."""
|
||||
return Agent(
|
||||
client=client,
|
||||
name="InvoiceAgent",
|
||||
instructions=INVOICE_INSTRUCTIONS,
|
||||
tools=[query_invoices, query_by_transaction_id, query_by_invoice_id],
|
||||
)
|
||||
|
||||
|
||||
def create_policy_agent(client: FoundryChatClient) -> Agent:
|
||||
"""Create a policy agent backed by the given client."""
|
||||
return Agent(
|
||||
client=client,
|
||||
name="PolicyAgent",
|
||||
instructions=POLICY_INSTRUCTIONS,
|
||||
)
|
||||
|
||||
|
||||
def create_logistics_agent(client: FoundryChatClient) -> Agent:
|
||||
"""Create a logistics agent backed by the given client."""
|
||||
return Agent(
|
||||
client=client,
|
||||
name="LogisticsAgent",
|
||||
instructions=LOGISTICS_INSTRUCTIONS,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentCard factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CAPABILITIES = AgentCapabilities(streaming=True, push_notifications=False)
|
||||
|
||||
|
||||
def get_invoice_agent_card(url: str) -> AgentCard:
|
||||
"""Return an A2A AgentCard for the invoice agent."""
|
||||
return AgentCard(
|
||||
name="InvoiceAgent",
|
||||
description="Handles requests relating to invoices.",
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_invoice_agent",
|
||||
name="InvoiceQuery",
|
||||
description="Handles requests relating to invoices.",
|
||||
tags=["invoice", "agent-framework"],
|
||||
examples=["List the latest invoices for Contoso."],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_policy_agent_card(url: str) -> AgentCard:
|
||||
"""Return an A2A AgentCard for the policy agent."""
|
||||
return AgentCard(
|
||||
name="PolicyAgent",
|
||||
description="Handles requests relating to policies and customer communications.",
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_policy_agent",
|
||||
name="PolicyAgent",
|
||||
description="Handles requests relating to policies and customer communications.",
|
||||
tags=["policy", "agent-framework"],
|
||||
examples=["What is the policy for short shipments?"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_logistics_agent_card(url: str) -> AgentCard:
|
||||
"""Return an A2A AgentCard for the logistics agent."""
|
||||
return AgentCard(
|
||||
name="LogisticsAgent",
|
||||
description="Handles requests relating to logistics.",
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=_CAPABILITIES,
|
||||
supported_interfaces=[AgentInterface(url=url, protocol_binding="JSONRPC")],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="id_logistics_agent",
|
||||
name="LogisticsQuery",
|
||||
description="Handles requests relating to logistics.",
|
||||
tags=["logistics", "agent-framework"],
|
||||
examples=["What is the status for SHPMT-SAP-001"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookup helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AGENT_FACTORIES = {
|
||||
"invoice": create_invoice_agent,
|
||||
"policy": create_policy_agent,
|
||||
"logistics": create_logistics_agent,
|
||||
}
|
||||
|
||||
AGENT_CARD_FACTORIES = {
|
||||
"invoice": get_invoice_agent_card,
|
||||
"policy": get_policy_agent_card,
|
||||
"logistics": get_logistics_agent_card,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import uvicorn
|
||||
from a2a.server.request_handlers import DefaultRequestHandler
|
||||
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
from a2a.types import (
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentInterface,
|
||||
AgentSkill,
|
||||
)
|
||||
from agent_framework import Agent
|
||||
from agent_framework.a2a import A2AExecutor
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from dotenv import load_dotenv
|
||||
from starlette.applications import Starlette
|
||||
|
||||
load_dotenv()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# --8<-- [start:AgentSkill]
|
||||
flight_skill = AgentSkill(
|
||||
id="Flight_Booking",
|
||||
name="Flight Booking",
|
||||
description="Search and book flights across Europe.",
|
||||
tags=["flights", "travel", "europe"],
|
||||
examples=[],
|
||||
)
|
||||
hotel_skill = AgentSkill(
|
||||
id="Hotel_Booking",
|
||||
name="Hotel Booking",
|
||||
description="Search and book hotels across Europe.",
|
||||
tags=["hotels", "travel", "accommodation"],
|
||||
examples=[],
|
||||
)
|
||||
# --8<-- [end:AgentSkill]
|
||||
|
||||
# --8<-- [start:AgentCard]
|
||||
# This will be the public-facing agent card
|
||||
public_agent_card = AgentCard(
|
||||
name="Europe Travel Agent",
|
||||
description="A helpful Europe Travel Agent that can help users search and book flights and hotels across Europe.",
|
||||
version="1.0.0",
|
||||
default_input_modes=["text"],
|
||||
default_output_modes=["text"],
|
||||
capabilities=AgentCapabilities(streaming=True),
|
||||
supported_interfaces=[AgentInterface(url="http://localhost:9999/", protocol_binding="JSONRPC")],
|
||||
skills=[flight_skill, hotel_skill],
|
||||
)
|
||||
# --8<-- [end:AgentCard]
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Europe Travel Agent",
|
||||
instructions="You are a helpful Europe Travel Agent. You can help users search and book flights and hotels across Europe.",
|
||||
)
|
||||
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=A2AExecutor(agent),
|
||||
task_store=InMemoryTaskStore(),
|
||||
agent_card=public_agent_card,
|
||||
)
|
||||
|
||||
server = Starlette(
|
||||
routes=[
|
||||
*create_agent_card_routes(public_agent_card),
|
||||
*create_jsonrpc_routes(request_handler, "/"),
|
||||
]
|
||||
)
|
||||
|
||||
uvicorn.run(server, host="0.0.0.0", port=9999)
|
||||
@@ -0,0 +1,227 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mock invoice data and tool functions for the A2A server sample.
|
||||
|
||||
Provides mock invoice data and query tools for the A2A server sample,
|
||||
enabling invoice-related queries through the A2A protocol.
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import tool
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Product:
|
||||
"""A product line item on an invoice."""
|
||||
|
||||
name: str
|
||||
quantity: int
|
||||
price_per_unit: float
|
||||
|
||||
@property
|
||||
def total_price(self) -> float:
|
||||
return self.quantity * self.price_per_unit
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"quantity": self.quantity,
|
||||
"price_per_unit": self.price_per_unit,
|
||||
"total_price": self.total_price,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Invoice:
|
||||
"""An invoice record with products."""
|
||||
|
||||
transaction_id: str
|
||||
invoice_id: str
|
||||
company_name: str
|
||||
invoice_date: datetime
|
||||
products: list[Product] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total_invoice_price(self) -> float:
|
||||
return sum(p.total_price for p in self.products)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"transaction_id": self.transaction_id,
|
||||
"invoice_id": self.invoice_id,
|
||||
"company_name": self.company_name,
|
||||
"invoice_date": self.invoice_date.strftime("%Y-%m-%d"),
|
||||
"products": [p.to_dict() for p in self.products],
|
||||
"total_invoice_price": self.total_invoice_price,
|
||||
}
|
||||
|
||||
|
||||
def _random_date_within_last_two_months() -> datetime:
|
||||
end_date = datetime.now(timezone.utc)
|
||||
start_date = end_date - timedelta(days=60)
|
||||
random_days = random.randint(0, 60)
|
||||
return start_date + timedelta(days=random_days)
|
||||
|
||||
|
||||
def _build_invoices() -> list[Invoice]:
|
||||
"""Build 10 mock invoices."""
|
||||
return [
|
||||
Invoice(
|
||||
"TICKET-XYZ987",
|
||||
"INV789",
|
||||
"Contoso",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 150, 10.00),
|
||||
Product("Hats", 200, 15.00),
|
||||
Product("Glasses", 300, 5.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ111",
|
||||
"INV111",
|
||||
"XStore",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 2500, 12.00),
|
||||
Product("Hats", 1500, 8.00),
|
||||
Product("Glasses", 200, 20.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ222",
|
||||
"INV222",
|
||||
"Cymbal Direct",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 1200, 14.00),
|
||||
Product("Hats", 800, 7.00),
|
||||
Product("Glasses", 500, 25.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ333",
|
||||
"INV333",
|
||||
"Contoso",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 400, 11.00),
|
||||
Product("Hats", 600, 15.00),
|
||||
Product("Glasses", 700, 5.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ444",
|
||||
"INV444",
|
||||
"XStore",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 800, 10.00),
|
||||
Product("Hats", 500, 18.00),
|
||||
Product("Glasses", 300, 22.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ555",
|
||||
"INV555",
|
||||
"Cymbal Direct",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 1100, 9.00),
|
||||
Product("Hats", 900, 12.00),
|
||||
Product("Glasses", 1200, 15.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ666",
|
||||
"INV666",
|
||||
"Contoso",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 2500, 8.00),
|
||||
Product("Hats", 1200, 10.00),
|
||||
Product("Glasses", 1000, 6.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ777",
|
||||
"INV777",
|
||||
"XStore",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 1900, 13.00),
|
||||
Product("Hats", 1300, 16.00),
|
||||
Product("Glasses", 800, 19.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ888",
|
||||
"INV888",
|
||||
"Cymbal Direct",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 2200, 11.00),
|
||||
Product("Hats", 1700, 8.50),
|
||||
Product("Glasses", 600, 21.00),
|
||||
],
|
||||
),
|
||||
Invoice(
|
||||
"TICKET-XYZ999",
|
||||
"INV999",
|
||||
"Contoso",
|
||||
_random_date_within_last_two_months(),
|
||||
[
|
||||
Product("T-Shirts", 1400, 10.50),
|
||||
Product("Hats", 1100, 9.00),
|
||||
Product("Glasses", 950, 12.00),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Module-level singleton so dates are stable for the lifetime of the server
|
||||
INVOICES = _build_invoices()
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def query_invoices(
|
||||
company_name: Annotated[str, Field(description="The company name to filter invoices by.")],
|
||||
start_date: Annotated[str | None, Field(description="Optional start date (YYYY-MM-DD) to filter invoices.")] = None,
|
||||
end_date: Annotated[str | None, Field(description="Optional end date (YYYY-MM-DD) to filter invoices.")] = None,
|
||||
) -> str:
|
||||
"""Retrieves invoices for the specified company and optionally within the specified time range."""
|
||||
results = [i for i in INVOICES if i.company_name.lower() == company_name.lower()]
|
||||
|
||||
if start_date:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
results = [i for i in results if i.invoice_date >= start]
|
||||
|
||||
if end_date:
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||
results = [i for i in results if i.invoice_date < end]
|
||||
|
||||
return json.dumps([i.to_dict() for i in results], indent=2)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def query_by_transaction_id(
|
||||
transaction_id: Annotated[str, Field(description="The transaction ID to look up (e.g. TICKET-XYZ987).")],
|
||||
) -> str:
|
||||
"""Retrieves invoice using the transaction id."""
|
||||
results = [i for i in INVOICES if i.transaction_id.lower() == transaction_id.lower()]
|
||||
return json.dumps([i.to_dict() for i in results], indent=2)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def query_by_invoice_id(
|
||||
invoice_id: Annotated[str, Field(description="The invoice ID to look up (e.g. INV789).")],
|
||||
) -> str:
|
||||
"""Retrieves invoice using the invoice id."""
|
||||
results = [i for i in INVOICES if i.invoice_id.lower() == invoice_id.lower()]
|
||||
return json.dumps([i.to_dict() for i in results], indent=2)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-a2a
|
||||
# agent-framework-foundry
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../packages/foundry # Foundry support - dependency for FoundryChatClient in a2a_server.py
|
||||
-e ../../../packages/a2a # A2A integration - provides A2AAgent and a2a-sdk
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
|
||||
# A2A server runtime
|
||||
uvicorn
|
||||
|
||||
# HTTP client used by A2A client samples
|
||||
httpx
|
||||
|
||||
# Environment variable loading
|
||||
python-dotenv
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Framework hosting helper samples
|
||||
|
||||
End-to-end samples for exposing Agent Framework targets through app-owned
|
||||
hosting routes.
|
||||
|
||||
The helper-first hosting packages provide protocol conversion and optional
|
||||
execution state. The application still owns the web framework, native SDK
|
||||
clients, authentication, response construction, and deployment shape.
|
||||
|
||||
| Sample | What it shows | Packaging |
|
||||
|---|---|---|
|
||||
| [`local_responses/`](./local_responses) | One agent + one `@tool` + native FastAPI route + Responses helper functions + `AgentState` / `SessionStore`. | **Local only.** Start here to learn the helper seam. |
|
||||
| [`local_responses_workflow/`](./local_responses_workflow) | A workflow target behind a native FastAPI route using Responses helper functions, `WorkflowState`, explicit `CheckpointStorage`, and an app-owned checkpoint cursor. | **Local only.** |
|
||||
|
||||
Each sample is self-contained with its own `pyproject.toml`, server `app.py`,
|
||||
calling script(s), and `storage/` directory. Samples use `[tool.uv.sources]`
|
||||
to wire unreleased hosting packages to the upstream repo while those packages
|
||||
are still pre-PyPI. Once those packages publish, drop the `[tool.uv.sources]`
|
||||
block and let the declared dependencies resolve from PyPI.
|
||||
|
||||
## Relationship to `../foundry-hosted-agents/`
|
||||
|
||||
The sibling [`../foundry-hosted-agents/`](../foundry-hosted-agents) directory
|
||||
contains samples for agents that run inside the Foundry Hosted Agents platform.
|
||||
Those samples use the Foundry-managed protocol surface with no
|
||||
`agent-framework-hosting` package involved.
|
||||
|
||||
| Aspect | `af-hosting/` (this directory) | `foundry-hosted-agents/` |
|
||||
|---|---|---|
|
||||
| Server stack | App-owned FastAPI + hosting protocol helpers | Foundry Hosted Agents runtime |
|
||||
| Protocol surface | The app exposes the route and calls helpers | The platform exposes Responses + Invocations |
|
||||
| Run target | Local Hypercorn (`local_responses/`, `local_responses_workflow/`) | Hosted Agents or local container targeting the Hosted Agents contract |
|
||||
| When to pick this | You need custom hosting code or want to learn the helper seam | You want the Foundry-managed hosting surface |
|
||||
@@ -0,0 +1,90 @@
|
||||
# local_responses — Responses helpers with native FastAPI routes
|
||||
|
||||
The smallest end-to-end Responses hosting shape: one Foundry agent with a
|
||||
`@tool`, one native FastAPI route, a small `SessionStore`, and the Responses
|
||||
helper functions:
|
||||
|
||||
- `responses_to_run(...)`
|
||||
- `responses_session_id(...)`
|
||||
- `create_response_id(...)`
|
||||
- `responses_from_run(...)`
|
||||
|
||||
The sample demonstrates the lighter hosting direction. Agent Framework provides
|
||||
the run conversion and session-state pieces; FastAPI owns route registration,
|
||||
request bodies, response objects, and server startup.
|
||||
|
||||
What the route demonstrates:
|
||||
|
||||
- Uses an explicit request-option allowlist. This sample only allows
|
||||
`max_tokens` and then overrides `reasoning`; all other caller-supplied
|
||||
options, including `model`, `temperature`, `store`, `tools`, and
|
||||
`tool_choice`, are denied by default. Your app decides the exact allowed,
|
||||
altered, and denied options.
|
||||
- **Forces** a `reasoning` preset (`effort=medium`, `summary=auto`) on every
|
||||
turn.
|
||||
- Produces the AF messages, options, and session id that the route passes to
|
||||
`agent.run(...)`.
|
||||
- **Stores** each newly minted response id for the session it was just
|
||||
resolved from, via `state.set_session(response_id, session)` after
|
||||
`agent.run(...)` has updated the session.
|
||||
OpenAI's `previous_response_id` rotates every turn *by design* — it lets a
|
||||
caller continue from any earlier response, not just the latest one — so
|
||||
every response id needs to stay independently resolvable, not just the
|
||||
most recent.
|
||||
- Treats an unknown `conversation_id` as a request to create a new local
|
||||
session. Your app can choose a stricter policy, such as requiring a separate
|
||||
API to create new conversations before callers can continue them.
|
||||
|
||||
`app:app` is a module-level FastAPI ASGI app; recommended local launch is
|
||||
Hypercorn.
|
||||
|
||||
## Production readiness
|
||||
|
||||
This is not a full-fledged production deployment. Before exposing this pattern
|
||||
to callers, add authentication and authorization at the infrastructure layer,
|
||||
the FastAPI app layer, or inside the route body.
|
||||
|
||||
Session continuation deserves particular care: treat `previous_response_id` and
|
||||
`conversation_id` as untrusted request values, authorize the caller before
|
||||
loading or storing a session for those ids, and partition any durable session
|
||||
store by tenant/user as appropriate for your application.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
|
||||
export FOUNDRY_MODEL=gpt-5-nano
|
||||
az login
|
||||
|
||||
uv sync
|
||||
uv run hypercorn app:app --bind 0.0.0.0:8000
|
||||
```
|
||||
|
||||
Single-process for quick iteration:
|
||||
|
||||
```bash
|
||||
uv run python app.py
|
||||
```
|
||||
|
||||
## Call locally
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
|
||||
# Plain OpenAI SDK call:
|
||||
uv run python call_server.py
|
||||
|
||||
# The client intentionally omits `model`; the app chooses the backing deployment
|
||||
# from FOUNDRY_MODEL.
|
||||
|
||||
# The script then sends two more turns, each continuing from the previous
|
||||
# turn's `response.id` as `previous_response_id`. The third turn asks about
|
||||
# the first turn's city, so it only succeeds if the server still remembers
|
||||
# that far back in the chain.
|
||||
|
||||
# Same three-turn interaction through an Agent Framework Agent backed by
|
||||
# OpenAIChatClient:
|
||||
uv run python call_server_af.py
|
||||
```
|
||||
|
||||
> This sample is **local-only** — no Dockerfile, no Foundry packaging.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Minimal Responses-only hosting sample with native FastAPI routes.
|
||||
|
||||
This sample demonstrates the helper-first hosting shape:
|
||||
|
||||
1. ``agent-framework-hosting-responses`` converts Responses request/response
|
||||
payloads to and from Agent Framework run values.
|
||||
2. ``agent-framework-hosting`` owns shared execution state via
|
||||
``AgentState`` and ``SessionStore``.
|
||||
3. FastAPI owns the route, request parsing, policy decisions, and response
|
||||
object.
|
||||
|
||||
Production readiness
|
||||
---
|
||||
This sample is not a full-fledged production deployment. Before exposing this
|
||||
route to callers, add authentication and authorization at the infrastructure
|
||||
layer, the FastAPI app layer, or inside the route body.
|
||||
|
||||
Session continuation deserves particular care: treat ``previous_response_id``
|
||||
and ``conversation_id`` as untrusted request values, authorize the caller
|
||||
before loading or storing a session for those ids, and partition durable session
|
||||
storage by tenant/user as appropriate for your application. See
|
||||
``README.md#production-readiness``.
|
||||
|
||||
Unknown ``conversation_id`` values create a new local session in this sample.
|
||||
Your app can choose a different policy, such as requiring a separate API to
|
||||
create new conversations before callers can continue them.
|
||||
|
||||
Run
|
||||
---
|
||||
``app`` is a module-level FastAPI ASGI app. Recommended local launch::
|
||||
|
||||
uv sync
|
||||
az login
|
||||
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
|
||||
export FOUNDRY_MODEL=gpt-5-nano
|
||||
uv run hypercorn app:app --bind 0.0.0.0:8000
|
||||
|
||||
Or use the ``__main__`` block (single-process Hypercorn) for quick
|
||||
iteration::
|
||||
|
||||
uv run python app.py
|
||||
|
||||
Then call it::
|
||||
|
||||
uv run python call_server.py "What is the weather in Tokyo?"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
from agent_framework import Agent, FileHistoryProvider, ResponseStream, tool
|
||||
from agent_framework_foundry import FoundryChatClient
|
||||
from agent_framework_hosting import AgentState
|
||||
from agent_framework_hosting_responses import (
|
||||
create_response_id,
|
||||
responses_from_run,
|
||||
responses_from_streaming_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config
|
||||
|
||||
SESSIONS_DIR = Path(__file__).resolve().parent / "storage" / "sessions"
|
||||
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def lookup_weather(
|
||||
location: Annotated[str, "The city to look up weather for."],
|
||||
) -> str:
|
||||
"""Return a deterministic weather report for a city."""
|
||||
high_temp = 5 + (sum(location.encode("utf-8")) % 21)
|
||||
reports = {
|
||||
"Seattle": f"Seattle is rainy with a high of {high_temp}°C.",
|
||||
"Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.",
|
||||
"Tokyo": f"Tokyo is clear with a high of {high_temp}°C.",
|
||||
}
|
||||
return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.")
|
||||
|
||||
|
||||
def create_agent() -> Agent:
|
||||
"""Create the sample weather agent."""
|
||||
return Agent(
|
||||
client=FoundryChatClient(credential=DefaultAzureCredential()),
|
||||
name="WeatherAgent",
|
||||
instructions=(
|
||||
"You are a friendly weather assistant. Use the lookup_weather tool "
|
||||
"for any weather question and answer in one short sentence."
|
||||
),
|
||||
tools=[lookup_weather],
|
||||
context_providers=[FileHistoryProvider(SESSIONS_DIR)],
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
state = AgentState(create_agent)
|
||||
|
||||
ALLOWED_REQUEST_OPTIONS = frozenset({"max_tokens", "reasoning"})
|
||||
|
||||
|
||||
@app.post("/responses", response_model=None)
|
||||
async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008
|
||||
"""Handle one OpenAI Responses-shaped request."""
|
||||
try:
|
||||
run = responses_to_run(body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
session_id = responses_session_id(body)
|
||||
response_id = create_response_id()
|
||||
|
||||
# App-specific policy: allow only the request options this route is willing
|
||||
# to honor. This denies tools, tool_choice, deployment/persistence fields,
|
||||
# and all other caller-supplied options by default. Your app decides which
|
||||
# options are allowed, altered, or denied.
|
||||
options = {key: value for key, value in run["options"].items() if key in ALLOWED_REQUEST_OPTIONS}
|
||||
options["reasoning"] = {"effort": "medium", "summary": "auto"}
|
||||
options_for_run = cast(Any, options)
|
||||
|
||||
target = await state.get_target()
|
||||
lookup_id = session_id or response_id
|
||||
# An unknown `conversation_id` becomes a new session here. Production apps
|
||||
# can choose to require a separate "create conversation" API instead.
|
||||
session = await state.get_or_create_session(lookup_id)
|
||||
if run["stream"]:
|
||||
stream = target.run(
|
||||
run["messages"],
|
||||
stream=True,
|
||||
session=session,
|
||||
options=options_for_run,
|
||||
)
|
||||
if not isinstance(stream, ResponseStream):
|
||||
raise HTTPException(status_code=500, detail="agent did not return a response stream")
|
||||
|
||||
async def stream_events() -> AsyncIterator[str]:
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id=response_id,
|
||||
session_id=session_id,
|
||||
):
|
||||
yield event
|
||||
# `agent.run(..., stream=True)` updates the session while the stream
|
||||
# is consumed/finalized. Store it under the newly minted response id
|
||||
# after finalization so a later `previous_response_id` can restore
|
||||
# this exact continuation point.
|
||||
await state.set_session(response_id, session)
|
||||
|
||||
return StreamingResponse(
|
||||
stream_events(),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
result = await target.run(
|
||||
run["messages"],
|
||||
session=session,
|
||||
options=options_for_run,
|
||||
)
|
||||
# `agent.run(...)` updates the session. Store it under the newly minted
|
||||
# response id after the run so `previous_response_id=response_id` continues
|
||||
# from this exact point.
|
||||
await state.set_session(response_id, session)
|
||||
return JSONResponse(
|
||||
responses_from_run(
|
||||
result,
|
||||
response_id=response_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the sample with Hypercorn for local development."""
|
||||
config = Config()
|
||||
config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"]
|
||||
await serve(cast(Any, app), config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
# Sample output:
|
||||
# User: What is the weather in Tokyo?
|
||||
# Agent: Tokyo is clear with a high of 18°C.
|
||||
# Response ID: resp_...
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Local client for the local_responses sample.
|
||||
|
||||
Posts to ``/responses`` using the standard ``openai`` SDK.
|
||||
|
||||
Pass ``--previous-response-id <id>`` to continue a conversation by its
|
||||
``response.id`` (returned in the prior response).
|
||||
|
||||
Start the server first (in another shell)::
|
||||
|
||||
uv run python app.py
|
||||
|
||||
Then::
|
||||
|
||||
uv run python call_server.py
|
||||
|
||||
The script sends two follow-up turns, each continuing from the previous
|
||||
turn's ``response.id`` as ``previous_response_id``. The third turn asks about
|
||||
information from the *first* turn only, so it also exercises session
|
||||
continuity across a rotating response id chain, not just a single hop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
PROMPT = "What is the weather in Tokyo?"
|
||||
FOLLOW_UP_PROMPT = "And what about Amsterdam?"
|
||||
THIRD_PROMPT = "Which of the two cities we just discussed is warmer?"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
|
||||
response = client.responses.create(
|
||||
input=PROMPT,
|
||||
)
|
||||
print(f"User: {PROMPT}")
|
||||
print(f"Agent: {response.output_text}")
|
||||
print(f"Response ID: {response.id}")
|
||||
|
||||
follow_up = client.responses.create(
|
||||
input=FOLLOW_UP_PROMPT,
|
||||
previous_response_id=response.id,
|
||||
)
|
||||
print()
|
||||
print(f"User: {FOLLOW_UP_PROMPT}")
|
||||
print(f"Agent: {follow_up.output_text}")
|
||||
print(f"Response ID: {follow_up.id}")
|
||||
|
||||
third = client.responses.create(
|
||||
input=THIRD_PROMPT,
|
||||
previous_response_id=follow_up.id,
|
||||
)
|
||||
print()
|
||||
print(f"User: {THIRD_PROMPT}")
|
||||
print(f"Agent: {third.output_text}")
|
||||
print(f"Response ID: {third.id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Agent Framework agent client for the local_responses sample.
|
||||
|
||||
Creates a local :class:`agent_framework.Agent` backed by
|
||||
:class:`agent_framework.openai.OpenAIChatClient` and points that client at the
|
||||
hosted ``/responses`` endpoint for all turns:
|
||||
|
||||
1. ``What is the weather in Tokyo?``
|
||||
2. ``And what about Amsterdam?``
|
||||
3. ``Which of the two cities we just discussed is warmer?``
|
||||
|
||||
All turns use the same :class:`agent_framework.AgentSession`; the first turn
|
||||
binds the hosted response id to the session, and later turns continue through
|
||||
that session via a chain of rotating ``previous_response_id`` values. The
|
||||
third turn only makes sense if the server still remembers the first turn, so
|
||||
it also exercises session continuity across that whole chain, not just a
|
||||
single hop.
|
||||
|
||||
Start the server first (in another shell)::
|
||||
|
||||
uv run python app.py
|
||||
|
||||
Then::
|
||||
|
||||
uv run python call_server_af.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
PROMPTS = [
|
||||
"What is the weather in Tokyo?",
|
||||
"And what about Amsterdam?",
|
||||
"Which of the two cities we just discussed is warmer?",
|
||||
]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(base_url=BASE_URL, api_key="not-needed"),
|
||||
name="HostedWeatherClient",
|
||||
)
|
||||
session = agent.create_session()
|
||||
|
||||
for prompt in PROMPTS:
|
||||
print(f"User: {prompt}")
|
||||
response = await agent.run(prompt, session=session)
|
||||
print(f"Agent: {response.text}\n")
|
||||
print(f"Response ID: {response.response_id}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
[project]
|
||||
name = "agent-framework-hosting-sample-local-responses"
|
||||
version = "0.0.1"
|
||||
description = "Minimal Responses-only local hosting sample with native FastAPI routes."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"agent-framework-foundry",
|
||||
"agent-framework-hosting",
|
||||
"agent-framework-hosting-responses",
|
||||
"azure-identity",
|
||||
"aiohttp>=3.13.5",
|
||||
"fastapi>=0.115.0,<0.138.1",
|
||||
"hypercorn>=0.17",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"agent-framework-openai",
|
||||
"openai>=1.99",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.uv.sources]
|
||||
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" }
|
||||
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-responses" }
|
||||
agent-framework-openai = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/openai" }
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,65 @@
|
||||
# local_responses_workflow — Responses helpers with a workflow target
|
||||
|
||||
This sample shows the helper-first hosting shape for a local workflow:
|
||||
|
||||
- `responses_to_run(...)` parses the Responses request body.
|
||||
- `WorkflowState` resolves the workflow target.
|
||||
- FastAPI owns the route and response construction.
|
||||
- The app owns file-based checkpoint storage and the
|
||||
`response_id -> checkpoint_id` cursor used to continue from a previous
|
||||
response.
|
||||
- Continuation is intentionally limited to `previous_response_id`; this sample
|
||||
rejects `conversation_id` continuity with HTTP 400.
|
||||
|
||||
The workflow writes a slogan with one Foundry-backed writer agent and a small
|
||||
deterministic formatter executor. That keeps the sample focused on native
|
||||
FastAPI routing, Responses helpers, `WorkflowState`, and app-owned checkpoint
|
||||
cursor storage. Both workflow checkpoints and the checkpoint cursor file are
|
||||
stored under the sample's local `storage/` root. Checkpoints are scoped into
|
||||
per-continuation buckets so a "latest checkpoint" lookup cannot cross
|
||||
conversations.
|
||||
|
||||
## Production readiness
|
||||
|
||||
This is not a full-fledged production deployment. Before exposing this pattern
|
||||
to callers, add authentication and authorization at the infrastructure layer,
|
||||
the FastAPI app layer, or inside the route body.
|
||||
|
||||
Session continuation deserves particular care: treat `previous_response_id` as
|
||||
an untrusted request value, authorize the caller before restoring or storing a
|
||||
checkpoint cursor for that id, and partition durable checkpoint/cursor storage
|
||||
by tenant/user as appropriate for your application.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
|
||||
export FOUNDRY_MODEL=gpt-5-nano
|
||||
az login
|
||||
|
||||
uv sync
|
||||
uv run hypercorn app:app --bind 0.0.0.0:8000
|
||||
```
|
||||
|
||||
Single-process for quick iteration:
|
||||
|
||||
```bash
|
||||
uv run python app.py
|
||||
```
|
||||
|
||||
## Call locally
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
uv run python call_server.py '{"topic": "electric SUV", "style": "playful", "audience": "young families"}'
|
||||
```
|
||||
|
||||
The script sends a follow-up using the first response id as
|
||||
`previous_response_id`, so the workflow restores the prior checkpoint before
|
||||
running the next turn. It deliberately does not send `conversation_id`, because
|
||||
this sample rejects `conversation_id` continuation.
|
||||
|
||||
> This sample uses local file storage under `storage/` for both workflow
|
||||
> checkpoints and checkpoint cursors. The checkpoint bucket names are hashed
|
||||
> from the continuation id before they are used as directory names. Replace this
|
||||
> with production-grade durable storage for multi-replica or transient hosting.
|
||||
@@ -0,0 +1,288 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Responses helper sample with a local workflow target and native FastAPI route.
|
||||
|
||||
This sample demonstrates the helper-first hosting shape for workflows:
|
||||
|
||||
1. ``agent-framework-hosting-responses`` converts the Responses request body to
|
||||
Agent Framework run values and renders the final response payload.
|
||||
2. ``agent-framework-hosting`` resolves the workflow target via ``WorkflowState``.
|
||||
3. FastAPI owns the route, request parsing, policy decisions, response object,
|
||||
and file-backed checkpoint cursor.
|
||||
|
||||
Production readiness
|
||||
---
|
||||
This sample is not a full-fledged production deployment. Before exposing this
|
||||
route to callers, add authentication and authorization at the infrastructure
|
||||
layer, the FastAPI app layer, or inside the route body.
|
||||
|
||||
This sample demonstrates continuation with ``previous_response_id`` only. It
|
||||
rejects ``conversation_id`` continuity with HTTP 400. Treat every
|
||||
``previous_response_id`` as an untrusted request value, authorize the caller
|
||||
before restoring or storing a checkpoint cursor for that id, and partition
|
||||
durable checkpoint/cursor storage by tenant/user as appropriate for your
|
||||
application. See ``README.md#production-readiness``.
|
||||
|
||||
Run
|
||||
---
|
||||
``app`` is a module-level FastAPI ASGI app. Recommended local launch::
|
||||
|
||||
uv sync
|
||||
az login
|
||||
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
|
||||
export FOUNDRY_MODEL=gpt-5-nano
|
||||
uv run hypercorn app:app --bind 0.0.0.0:8000
|
||||
|
||||
Or use the ``__main__`` block (single-process Hypercorn) for quick iteration::
|
||||
|
||||
uv run python app.py
|
||||
|
||||
Then call it with a structured brief::
|
||||
|
||||
uv run python call_server.py \
|
||||
'{"topic": "electric SUV", "style": "playful", "audience": "young families"}'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentExecutor,
|
||||
AgentExecutorResponse,
|
||||
AgentResponse,
|
||||
Content,
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from agent_framework_foundry import FoundryChatClient
|
||||
from agent_framework_hosting import WorkflowState
|
||||
from agent_framework_hosting_responses import (
|
||||
create_response_id,
|
||||
responses_from_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config
|
||||
|
||||
STORAGE_ROOT = Path(__file__).resolve().parent / "storage"
|
||||
CHECKPOINTS_ROOT = STORAGE_ROOT / "checkpoints"
|
||||
CHECKPOINT_CURSOR_PATH = STORAGE_ROOT / "checkpoint_cursors.json"
|
||||
CHECKPOINTS_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class CheckpointCursor(TypedDict):
|
||||
"""Stored pointer to a workflow checkpoint and its storage bucket."""
|
||||
|
||||
checkpoint_id: str
|
||||
storage_id: str
|
||||
|
||||
|
||||
class CheckpointCursorStore:
|
||||
"""File-backed mapping from Responses ids to workflow checkpoint ids."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Create a cursor store at the given path.
|
||||
|
||||
Args:
|
||||
path: JSON file containing response-id to checkpoint-id mappings.
|
||||
"""
|
||||
self._path = path
|
||||
|
||||
def get(self, key: str) -> CheckpointCursor | None:
|
||||
"""Return the checkpoint cursor for a previous response id."""
|
||||
return self._load().get(key)
|
||||
|
||||
def set_many(self, cursors: Mapping[str, CheckpointCursor]) -> None:
|
||||
"""Persist one or more checkpoint cursors."""
|
||||
data = self._load()
|
||||
data.update(cursors)
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
def _load(self) -> dict[str, CheckpointCursor]:
|
||||
if not self._path.exists():
|
||||
return {}
|
||||
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("Checkpoint cursor file must contain a JSON object.")
|
||||
|
||||
data: dict[str, CheckpointCursor] = {}
|
||||
for key, value in raw.items():
|
||||
if not isinstance(key, str) or not isinstance(value, Mapping):
|
||||
raise ValueError("Checkpoint cursor file must map string ids to checkpoint cursor objects.")
|
||||
checkpoint_id = value.get("checkpoint_id")
|
||||
storage_id = value.get("storage_id")
|
||||
if not isinstance(checkpoint_id, str) or not isinstance(storage_id, str):
|
||||
raise ValueError("Checkpoint cursor objects must contain string checkpoint_id and storage_id fields.")
|
||||
data[key] = CheckpointCursor(checkpoint_id=checkpoint_id, storage_id=storage_id)
|
||||
return data
|
||||
|
||||
|
||||
checkpoint_cursor_store = CheckpointCursorStore(CHECKPOINT_CURSOR_PATH)
|
||||
|
||||
|
||||
def checkpoint_storage_for(storage_id: str) -> FileCheckpointStorage:
|
||||
"""Return file checkpoint storage scoped to a single continuation bucket."""
|
||||
storage_key = hashlib.sha256(storage_id.encode("utf-8")).hexdigest()
|
||||
return FileCheckpointStorage(str(CHECKPOINTS_ROOT / storage_key))
|
||||
|
||||
|
||||
def workflow_prompt_from_messages(messages: Any) -> str:
|
||||
"""Prepare the workflow's initial writer prompt from Responses input."""
|
||||
|
||||
def extract_text(value: object) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, Message):
|
||||
return value.text
|
||||
if isinstance(value, list):
|
||||
return "\n".join(extract_text(item) for item in value)
|
||||
return ""
|
||||
|
||||
text = extract_text(messages).strip()
|
||||
topic = text or "a generic product"
|
||||
style = "modern"
|
||||
audience = "general"
|
||||
if topic.startswith("{"):
|
||||
try:
|
||||
data = json.loads(topic)
|
||||
except json.JSONDecodeError:
|
||||
data = None
|
||||
if isinstance(data, dict) and "topic" in data:
|
||||
topic = str(data["topic"])
|
||||
style = str(data.get("style", style))
|
||||
audience = str(data.get("audience", audience))
|
||||
|
||||
return (
|
||||
f"Topic: {topic}\n"
|
||||
f"Style: {style}\n"
|
||||
f"Audience: {audience}\n\n"
|
||||
"Write a single short slogan that fits the topic, style, and audience."
|
||||
)
|
||||
|
||||
|
||||
def response_from_workflow_result(result: Any) -> AgentResponse[Any]:
|
||||
"""Collapse workflow outputs to one assistant response for Responses rendering."""
|
||||
outputs = result.get_outputs() if hasattr(result, "get_outputs") else []
|
||||
output = outputs[-1] if outputs else "(no workflow output)"
|
||||
text = output.text if isinstance(output, AgentResponse) else str(output)
|
||||
return AgentResponse(messages=Message(role="assistant", contents=[Content.from_text(text=text)]))
|
||||
|
||||
|
||||
class TerminalFormatter(Executor):
|
||||
"""Format the writer's output as the workflow's final response."""
|
||||
|
||||
@handler
|
||||
async def handle(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any, str]) -> None:
|
||||
"""Yield one terminal-friendly slogan string.
|
||||
|
||||
Args:
|
||||
response: The writer agent's response.
|
||||
ctx: Workflow context used to yield the final output.
|
||||
"""
|
||||
slogan = response.agent_response.text.strip().strip('"')
|
||||
await ctx.yield_output(f'Slogan: "{slogan}"')
|
||||
|
||||
|
||||
client = FoundryChatClient(credential=DefaultAzureCredential())
|
||||
writer = Agent(
|
||||
client=client,
|
||||
name="writer",
|
||||
instructions="You are an excellent slogan writer. Create one short slogan from the given brief.",
|
||||
)
|
||||
writer_ex = AgentExecutor(writer, context_mode="last_agent")
|
||||
formatter_ex = TerminalFormatter(id="terminal_formatter")
|
||||
|
||||
workflow_builder = WorkflowBuilder(
|
||||
name="local_responses_slogan_workflow",
|
||||
start_executor=writer_ex,
|
||||
output_from=[formatter_ex],
|
||||
).add_edge(writer_ex, formatter_ex)
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
state = WorkflowState(workflow_builder, cache_target=False)
|
||||
|
||||
|
||||
@app.post("/responses", response_model=None)
|
||||
async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse: # noqa: B008
|
||||
"""Handle one OpenAI Responses-shaped request for the workflow."""
|
||||
try:
|
||||
run = responses_to_run(body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
# This sample demonstrates only Responses `previous_response_id`
|
||||
# continuation. `responses_session_id` also returns `conversation_id`, so
|
||||
# reject that shape here instead of treating it as a checkpoint cursor.
|
||||
previous_response_id = responses_session_id(body)
|
||||
if previous_response_id and not previous_response_id.startswith("resp_"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This server supports previous_response_id continuation only; conversation_id is not implemented.",
|
||||
)
|
||||
response_id = create_response_id()
|
||||
|
||||
target = await state.get_target()
|
||||
if previous_response_id and (checkpoint_cursor := checkpoint_cursor_store.get(previous_response_id)) is not None:
|
||||
# Restore first. Workflow.run does not allow `message` and
|
||||
# `checkpoint_id` in the same call.
|
||||
await target.run(
|
||||
checkpoint_id=checkpoint_cursor["checkpoint_id"],
|
||||
checkpoint_storage=checkpoint_storage_for(checkpoint_cursor["storage_id"]),
|
||||
)
|
||||
|
||||
storage_id = response_id
|
||||
checkpoint_storage = checkpoint_storage_for(storage_id)
|
||||
result = await target.run(
|
||||
message=workflow_prompt_from_messages(run["messages"]),
|
||||
checkpoint_storage=checkpoint_storage,
|
||||
)
|
||||
|
||||
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
|
||||
if latest is not None:
|
||||
# Responses `previous_response_id` can point to any response id. Store
|
||||
# the current response id as the cursor for this workflow continuation.
|
||||
cursor = CheckpointCursor(checkpoint_id=latest.checkpoint_id, storage_id=storage_id)
|
||||
checkpoint_cursor_store.set_many({response_id: cursor})
|
||||
|
||||
return JSONResponse(
|
||||
responses_from_run(
|
||||
response_from_workflow_result(result),
|
||||
response_id=response_id,
|
||||
session_id=previous_response_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the sample with Hypercorn for local development."""
|
||||
config = Config()
|
||||
config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"]
|
||||
await serve(cast(Any, app), config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
# Sample output:
|
||||
# User: {"topic": "electric SUV", "style": "playful", "audience": "young families"}
|
||||
# Assistant: Slogan: "Big Adventures. Tiny Emissions."
|
||||
# Response ID: resp_...
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Local client for the local_responses_workflow sample.
|
||||
|
||||
Posts to ``/responses`` using the standard ``openai`` SDK. This client
|
||||
demonstrates the sample's only supported continuation mode:
|
||||
``previous_response_id``. It deliberately does not send ``conversation_id``,
|
||||
which the sample server rejects.
|
||||
|
||||
Start the server first (in another shell)::
|
||||
|
||||
uv run python app.py
|
||||
|
||||
Then::
|
||||
|
||||
uv run python call_server.py '{"topic": "electric SUV", "style": "playful", "audience": "young families"}'
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
DEFAULT_BRIEF = '{"topic": "electric SUV", "style": "playful", "audience": "young families"}'
|
||||
FOLLOW_UP = "Make it a little more premium, but still family friendly."
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Send a two-turn workflow conversation using ``previous_response_id``."""
|
||||
client = OpenAI(base_url=BASE_URL, api_key="not-needed")
|
||||
brief = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BRIEF
|
||||
|
||||
response = client.responses.create(input=brief)
|
||||
print(f"User: {brief}")
|
||||
print(f"Workflow: {response.output_text}")
|
||||
print(f"Response ID: {response.id}")
|
||||
|
||||
# Continue with the returned response id. The server sample rejects
|
||||
# `conversation_id` continuity.
|
||||
follow_up = client.responses.create(
|
||||
input=FOLLOW_UP,
|
||||
previous_response_id=response.id,
|
||||
)
|
||||
print()
|
||||
print(f"User: {FOLLOW_UP}")
|
||||
print(f"Workflow: {follow_up.output_text}")
|
||||
print(f"Response ID: {follow_up.id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
[project]
|
||||
name = "agent-framework-hosting-sample-local-responses-workflow"
|
||||
version = "0.0.1"
|
||||
description = "Minimal Responses-only local hosting sample with a workflow target and native FastAPI routes."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"agent-framework-foundry",
|
||||
"agent-framework-hosting",
|
||||
"agent-framework-hosting-responses",
|
||||
"azure-identity",
|
||||
"aiohttp>=3.13.5",
|
||||
"fastapi>=0.115.0,<0.138.1",
|
||||
"hypercorn>=0.17",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"openai>=1.99",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.uv.sources]
|
||||
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" }
|
||||
agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-responses" }
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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 session 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 (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -i -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-d "Tell me a short joke about cloud computing."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post -Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-Body "Tell me a short joke about cloud computing."
|
||||
```
|
||||
|
||||
The agent responds with a JSON payload that includes the generated joke.
|
||||
|
||||
> [!TIP]
|
||||
> 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
|
||||
|
||||
The default plain-text response looks like the following:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
x-ms-thread-id: 4f205157170244bfbd80209df383757e
|
||||
|
||||
```
|
||||
|
||||
When you specify the `x-ms-wait-for-response` header or include `"wait_for_response": false` in the request body, the Functions host responds with an HTTP 202 and queues the request to run in the background. A typical response body looks like the following:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"message": "Tell me a short joke about cloud computing.",
|
||||
"thread_id": "<guid>",
|
||||
"correlation_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,52 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Host a single Foundry-powered agent inside Azure Functions.
|
||||
|
||||
Components used in this sample:
|
||||
- FoundryChatClient to call the Foundry deployment.
|
||||
- AgentFunctionApp to expose HTTP endpoints via the Durable Functions extension.
|
||||
|
||||
Prerequisites: set `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in
|
||||
with Azure CLI before starting the Functions host."""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# 1. Instantiate the agent with the chosen deployment and instructions.
|
||||
def _create_agent() -> Any:
|
||||
"""Create the Joker agent."""
|
||||
return Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
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%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
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 session 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": "Agent"},
|
||||
{"name": "MathAgent", "type": "Agent"}
|
||||
],
|
||||
"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,111 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Host multiple Azure OpenAI-powered agents inside a single Azure Functions app.
|
||||
|
||||
Components used in this sample:
|
||||
- OpenAIChatCompletionClient configured for Azure OpenAI.
|
||||
- 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`, `AZURE_OPENAI_MODEL`, and sign in with Azure CLI before starting the Functions host."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.openai import OpenAIChatCompletionClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
|
||||
# see samples/02-agents/tools/function_tool_with_approval.py
|
||||
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
|
||||
@tool(approval_mode="never_require")
|
||||
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
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
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.
|
||||
client = OpenAIChatCompletionClient(
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
weather_agent = Agent(
|
||||
client=client,
|
||||
name="WeatherAgent",
|
||||
instructions="You are a helpful weather assistant. Provide current weather information.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
math_agent = Agent(
|
||||
client=client,
|
||||
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%"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
@@ -0,0 +1,132 @@
|
||||
# Agent Response Callbacks with Redis Streaming
|
||||
|
||||
This sample demonstrates how to use Redis Streams with agent response callbacks to enable reliable, resumable streaming for durable agents. Clients can disconnect and reconnect without losing messages by using cursor-based pagination.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using `AgentResponseCallbackProtocol` to capture streaming agent responses
|
||||
- Persisting streaming chunks to Redis Streams for reliable delivery
|
||||
- Building a custom HTTP endpoint to read from Redis with Server-Sent Events (SSE) format
|
||||
- Supporting cursor-based resumption for disconnected clients
|
||||
- Managing Redis client lifecycle with async context managers
|
||||
|
||||
## Prerequisites
|
||||
|
||||
In addition to the common setup steps in `../README.md`, this sample requires Redis:
|
||||
|
||||
```bash
|
||||
# Start Redis
|
||||
docker run -d --name redis -p 6379:6379 redis:latest
|
||||
```
|
||||
|
||||
Update `local.settings.json` with your Redis connection string:
|
||||
|
||||
```json
|
||||
{
|
||||
"Values": {
|
||||
"REDIS_CONNECTION_STRING": "redis://localhost:6379"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Start the agent run
|
||||
|
||||
The agent executes in the background via durable orchestration. The `RedisStreamCallback` persists streaming chunks to Redis:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/TravelPlanner/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Plan a 3-day trip to Tokyo"
|
||||
```
|
||||
|
||||
Response (202 Accepted):
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"response": "Agent request accepted",
|
||||
"conversation_id": "abc-123-def-456",
|
||||
"correlation_id": "xyz-789"
|
||||
}
|
||||
```
|
||||
|
||||
### Stream the response from Redis
|
||||
|
||||
Use the custom `/api/agent/stream/{conversation_id}` endpoint to read persisted chunks:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/agent/stream/abc-123-def-456 \
|
||||
-H "Accept: text/event-stream"
|
||||
```
|
||||
|
||||
Response (SSE format):
|
||||
```
|
||||
id: 1734649123456-0
|
||||
event: message
|
||||
data: Here's a wonderful 3-day Tokyo itinerary...
|
||||
|
||||
id: 1734649123789-0
|
||||
event: message
|
||||
data: Day 1: Arrival and Shibuya...
|
||||
|
||||
id: 1734649124012-0
|
||||
event: done
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
### Resume from a cursor
|
||||
|
||||
Use a cursor ID from an SSE event to skip already-processed messages:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:7071/api/agent/stream/abc-123-def-456?cursor=1734649123456-0" \
|
||||
-H "Accept: text/event-stream"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Redis Callback
|
||||
|
||||
The `RedisStreamCallback` class implements `AgentResponseCallbackProtocol` to capture streaming updates:
|
||||
|
||||
```python
|
||||
class RedisStreamCallback(AgentResponseCallbackProtocol):
|
||||
async def on_streaming_response_update(self, update, context):
|
||||
# Write chunk to Redis Stream
|
||||
async with await get_stream_handler() as handler:
|
||||
await handler.write_chunk(thread_id, update.text, sequence)
|
||||
|
||||
async def on_agent_response(self, response, context):
|
||||
# Write end-of-stream marker
|
||||
async with await get_stream_handler() as handler:
|
||||
await handler.write_completion(thread_id, sequence)
|
||||
```
|
||||
|
||||
### 2. Custom Streaming Endpoint
|
||||
|
||||
The `/api/agent/stream/{conversation_id}` endpoint reads from Redis:
|
||||
|
||||
```python
|
||||
@app.route(route="agent/stream/{conversation_id}", methods=["GET"])
|
||||
async def stream(req):
|
||||
conversation_id = req.route_params.get("conversation_id")
|
||||
cursor = req.params.get("cursor") # Optional
|
||||
|
||||
async with await get_stream_handler() as handler:
|
||||
async for chunk in handler.read_stream(conversation_id, cursor):
|
||||
# Format and return chunks
|
||||
```
|
||||
|
||||
### 3. Redis Streams
|
||||
|
||||
Messages are stored in Redis Streams with automatic TTL (default: 10 minutes):
|
||||
|
||||
```
|
||||
Stream Key: agent-stream:{conversation_id}
|
||||
Entry: {
|
||||
"text": "chunk content",
|
||||
"sequence": "0",
|
||||
"timestamp": "1734649123456"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
### Reliable Streaming with Redis - Demo HTTP Requests
|
||||
### Use with the VS Code REST Client extension or any HTTP client
|
||||
###
|
||||
### Workflow:
|
||||
### 1. POST /api/agents/{agentName}/run -> Start durable agent (returns conversation_id)
|
||||
### 2. GET /api/agent/stream/{id} -> Read chunks from Redis (SSE or plain text)
|
||||
### 3. Add ?cursor={id} to resume from a specific point
|
||||
###
|
||||
### Prerequisites:
|
||||
### - Redis: docker run -d --name redis -p 6379:6379 redis:latest
|
||||
### - Start function app: func start
|
||||
|
||||
### Variables
|
||||
@baseUrl = http://localhost:7071
|
||||
@agentName = TravelPlanner
|
||||
|
||||
### Health Check
|
||||
GET {{baseUrl}}/api/health
|
||||
|
||||
###
|
||||
|
||||
### Start Agent Run
|
||||
# Starts the agent in the background via durable orchestration.
|
||||
# The RedisStreamCallback persists streaming chunks to Redis.
|
||||
# @name trip
|
||||
POST {{baseUrl}}/api/agents/{{agentName}}/run
|
||||
Content-Type: text/plain
|
||||
|
||||
Plan a 3-day trip to Tokyo
|
||||
|
||||
###
|
||||
|
||||
### Stream from Redis (SSE format)
|
||||
# Reads persisted chunks from Redis using cursor-based pagination.
|
||||
# The conversation_id is automatically captured from the previous request.
|
||||
@conversationId = {{trip.response.body.$.conversation_id}}
|
||||
GET {{baseUrl}}/api/agent/stream/{{conversationId}}
|
||||
Accept: text/event-stream
|
||||
|
||||
###
|
||||
|
||||
### Stream from Redis (plain text)
|
||||
# Same as above, but returns plain text instead of SSE format
|
||||
GET {{baseUrl}}/api/agent/stream/{{conversationId}}
|
||||
Accept: text/plain
|
||||
|
||||
###
|
||||
|
||||
### Resume from cursor
|
||||
# Use a cursor ID from an SSE event to skip already-processed messages
|
||||
# Replace {cursor_id} with an actual entry ID from the SSE stream
|
||||
GET {{baseUrl}}/api/agent/stream/{{conversationId}}?cursor={cursor_id}
|
||||
Accept: text/event-stream
|
||||
|
||||
###
|
||||
@@ -0,0 +1,327 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Reliable streaming for durable agents using Redis Streams.
|
||||
|
||||
This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams.
|
||||
|
||||
Components used in this sample:
|
||||
- FoundryChatClient to create the travel planner agent with tools.
|
||||
- AgentFunctionApp with a Redis-based callback for persistent streaming.
|
||||
- Custom HTTP endpoint to resume streaming from any point using cursor-based pagination.
|
||||
|
||||
Prerequisites:
|
||||
- Set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
|
||||
- Sign in with Azure CLI (`az login`) for `AzureCliCredential`
|
||||
- Redis running (docker run -d --name redis -p 6379:6379 redis:latest)
|
||||
- DTS and Azurite running (see parent README)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
import azure.functions as func
|
||||
import redis.asyncio as aioredis
|
||||
from agent_framework import Agent, AgentResponseUpdate
|
||||
from agent_framework.azure import (
|
||||
AgentCallbackContext,
|
||||
AgentFunctionApp,
|
||||
AgentResponseCallbackProtocol,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from redis_stream_response_handler import RedisStreamResponseHandler, StreamChunk # pyrefly: ignore[missing-import]
|
||||
from tools import get_local_events, get_weather_forecast # pyrefly: ignore[missing-import]
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration
|
||||
REDIS_CONNECTION_STRING = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379")
|
||||
REDIS_STREAM_TTL_MINUTES = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10"))
|
||||
|
||||
|
||||
async def get_stream_handler() -> RedisStreamResponseHandler:
|
||||
"""Create a new Redis stream handler for each request.
|
||||
|
||||
This avoids event loop conflicts in Azure Functions by creating
|
||||
a fresh Redis client in the current event loop context.
|
||||
"""
|
||||
# Create a new Redis client in the current event loop
|
||||
redis_client = aioredis.from_url(
|
||||
REDIS_CONNECTION_STRING,
|
||||
encoding="utf-8",
|
||||
decode_responses=False,
|
||||
)
|
||||
|
||||
return RedisStreamResponseHandler(
|
||||
redis_client=redis_client,
|
||||
stream_ttl=timedelta(minutes=REDIS_STREAM_TTL_MINUTES),
|
||||
)
|
||||
|
||||
|
||||
class RedisStreamCallback(AgentResponseCallbackProtocol):
|
||||
"""Callback that writes streaming updates to Redis Streams for reliable delivery.
|
||||
|
||||
This enables clients to disconnect and reconnect without losing messages.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._logger = logging.getLogger("durableagent.samples.redis_streaming")
|
||||
self._sequence_numbers = {} # Track sequence per thread
|
||||
|
||||
async def on_streaming_response_update(
|
||||
self,
|
||||
update: AgentResponseUpdate,
|
||||
context: AgentCallbackContext,
|
||||
) -> None:
|
||||
"""Write streaming update to Redis Stream.
|
||||
|
||||
Args:
|
||||
update: The streaming response update chunk.
|
||||
context: The callback context with thread_id, agent_name, etc.
|
||||
"""
|
||||
thread_id = context.thread_id
|
||||
if not thread_id:
|
||||
self._logger.warning("No thread_id available for streaming update")
|
||||
return
|
||||
|
||||
if not update.text:
|
||||
return
|
||||
|
||||
text = update.text
|
||||
|
||||
# Get or initialize sequence number for this thread
|
||||
if thread_id not in self._sequence_numbers:
|
||||
self._sequence_numbers[thread_id] = 0
|
||||
|
||||
sequence = self._sequence_numbers[thread_id]
|
||||
|
||||
try:
|
||||
# Use context manager to ensure Redis client is properly closed
|
||||
async with await get_stream_handler() as stream_handler:
|
||||
# Write chunk to Redis Stream using public API
|
||||
await stream_handler.write_chunk(thread_id, text, sequence)
|
||||
|
||||
self._sequence_numbers[thread_id] += 1
|
||||
|
||||
self._logger.info(
|
||||
"[%s][%s] Wrote chunk to Redis: seq=%d, text=%s",
|
||||
context.agent_name,
|
||||
thread_id[:8],
|
||||
sequence,
|
||||
text,
|
||||
)
|
||||
except Exception as ex:
|
||||
self._logger.error(f"Error writing to Redis stream: {ex}", exc_info=True)
|
||||
|
||||
async def on_agent_response(self, response, context: AgentCallbackContext) -> None:
|
||||
"""Write end-of-stream marker when agent completes.
|
||||
|
||||
Args:
|
||||
response: The final agent response.
|
||||
context: The callback context.
|
||||
"""
|
||||
thread_id = context.thread_id
|
||||
if not thread_id:
|
||||
return
|
||||
|
||||
sequence = self._sequence_numbers.get(thread_id, 0)
|
||||
|
||||
try:
|
||||
# Use context manager to ensure Redis client is properly closed
|
||||
async with await get_stream_handler() as stream_handler:
|
||||
# Write end-of-stream marker using public API
|
||||
await stream_handler.write_completion(thread_id, sequence)
|
||||
|
||||
self._logger.info(
|
||||
"[%s][%s] Agent completed, wrote end-of-stream marker",
|
||||
context.agent_name,
|
||||
thread_id[:8],
|
||||
)
|
||||
|
||||
# Clean up sequence tracker
|
||||
self._sequence_numbers.pop(thread_id, None)
|
||||
except Exception as ex:
|
||||
self._logger.error(f"Error writing end-of-stream marker: {ex}", exc_info=True)
|
||||
|
||||
|
||||
# Create the Redis streaming callback
|
||||
redis_callback = RedisStreamCallback()
|
||||
|
||||
|
||||
# Create the travel planner agent
|
||||
def create_travel_agent():
|
||||
"""Create the TravelPlanner agent with tools."""
|
||||
return Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
name="TravelPlanner",
|
||||
instructions="""You are an expert travel planner who creates detailed, personalized travel itineraries.
|
||||
When asked to plan a trip, you should:
|
||||
1. Create a comprehensive day-by-day itinerary
|
||||
2. Include specific recommendations for activities, restaurants, and attractions
|
||||
3. Provide practical tips for each destination
|
||||
4. Consider weather and local events when making recommendations
|
||||
5. Include estimated times and logistics between activities
|
||||
|
||||
Always use the available tools to get current weather forecasts and local events
|
||||
for the destination to make your recommendations more relevant and timely.
|
||||
|
||||
Format your response with clear headings for each day and include emoji icons
|
||||
to make the itinerary easy to scan and visually appealing.""",
|
||||
tools=[get_weather_forecast, get_local_events],
|
||||
)
|
||||
|
||||
|
||||
# Create AgentFunctionApp with the Redis callback
|
||||
app = AgentFunctionApp(
|
||||
agents=[create_travel_agent()],
|
||||
enable_health_check=True,
|
||||
default_callback=redis_callback,
|
||||
max_poll_retries=100, # Increase for longer-running agents
|
||||
)
|
||||
|
||||
|
||||
# Custom streaming endpoint for reading from Redis
|
||||
# Use the standard /api/agents/TravelPlanner/run endpoint to start agent runs
|
||||
|
||||
|
||||
@app.function_name("stream")
|
||||
@app.route(route="agent/stream/{conversation_id}", methods=["GET"])
|
||||
async def stream(req: func.HttpRequest) -> func.HttpResponse:
|
||||
"""Resume streaming from a specific cursor position for an existing session.
|
||||
|
||||
This endpoint reads all currently available chunks from Redis for the given
|
||||
conversation ID, starting from the specified cursor (or beginning if no cursor).
|
||||
|
||||
Use this endpoint to resume a stream after disconnection. Pass the conversation ID
|
||||
and optionally a cursor (Redis entry ID) to continue from where you left off.
|
||||
|
||||
Query Parameters:
|
||||
cursor (optional): Redis stream entry ID to resume from. If not provided, starts from beginning.
|
||||
|
||||
Response Headers:
|
||||
Content-Type: text/event-stream or text/plain based on Accept header
|
||||
x-conversation-id: The conversation/thread ID
|
||||
|
||||
SSE Event Fields (when Accept: text/event-stream):
|
||||
id: Redis stream entry ID (use as cursor for resumption)
|
||||
event: "message" for content, "done" for completion, "error" for errors
|
||||
data: The text content or status message
|
||||
"""
|
||||
try:
|
||||
conversation_id = req.route_params.get("conversation_id")
|
||||
if not conversation_id:
|
||||
return func.HttpResponse(
|
||||
"Conversation ID is required.",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get optional cursor from query string
|
||||
cursor = req.params.get("cursor")
|
||||
|
||||
logger.info(f"Resuming stream for conversation {conversation_id} from cursor: {cursor or '(beginning)'}")
|
||||
|
||||
# Check Accept header to determine response format
|
||||
accept_header = req.headers.get("Accept", "")
|
||||
use_sse_format = "text/plain" not in accept_header.lower()
|
||||
|
||||
# Stream chunks from Redis
|
||||
return await _stream_to_client(conversation_id, cursor, use_sse_format)
|
||||
|
||||
except Exception as ex:
|
||||
logger.error(f"Error in stream endpoint: {ex}", exc_info=True)
|
||||
return func.HttpResponse(
|
||||
f"Internal server error: {str(ex)}",
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def _stream_to_client(
|
||||
conversation_id: str,
|
||||
cursor: str | None,
|
||||
use_sse_format: bool,
|
||||
) -> func.HttpResponse:
|
||||
"""Stream chunks from Redis to the HTTP response.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to stream from.
|
||||
cursor: Optional cursor to resume from. If None, streams from the beginning.
|
||||
use_sse_format: True to use SSE format, false for plain text.
|
||||
|
||||
Returns:
|
||||
HTTP response with all currently available chunks.
|
||||
"""
|
||||
chunks = []
|
||||
|
||||
# Use context manager to ensure Redis client is properly closed
|
||||
async with await get_stream_handler() as stream_handler:
|
||||
try:
|
||||
async for chunk in stream_handler.read_stream(conversation_id, cursor):
|
||||
if chunk.error:
|
||||
logger.warning(f"Stream error for {conversation_id}: {chunk.error}")
|
||||
chunks.append(_format_error(chunk.error, use_sse_format))
|
||||
break
|
||||
|
||||
if chunk.is_done:
|
||||
chunks.append(_format_end_of_stream(chunk.entry_id, use_sse_format))
|
||||
break
|
||||
|
||||
if chunk.text:
|
||||
chunks.append(_format_chunk(chunk, use_sse_format))
|
||||
|
||||
except Exception as ex:
|
||||
logger.error(f"Error reading from Redis: {ex}", exc_info=True)
|
||||
chunks.append(_format_error(str(ex), use_sse_format))
|
||||
|
||||
# Return all chunks
|
||||
response_body = "".join(chunks)
|
||||
|
||||
return func.HttpResponse(
|
||||
body=response_body,
|
||||
mimetype="text/event-stream" if use_sse_format else "text/plain; charset=utf-8",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"x-conversation-id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _format_chunk(chunk: StreamChunk, use_sse_format: bool) -> str:
|
||||
"""Format a text chunk."""
|
||||
if use_sse_format:
|
||||
return _format_sse_event("message", chunk.text or "", chunk.entry_id)
|
||||
return chunk.text or ""
|
||||
|
||||
|
||||
def _format_end_of_stream(entry_id: str, use_sse_format: bool) -> str:
|
||||
"""Format end-of-stream marker."""
|
||||
if use_sse_format:
|
||||
return _format_sse_event("done", "[DONE]", entry_id)
|
||||
return "\n"
|
||||
|
||||
|
||||
def _format_error(error: str, use_sse_format: bool) -> str:
|
||||
"""Format error message."""
|
||||
if use_sse_format:
|
||||
return _format_sse_event("error", error, None)
|
||||
return f"\n[Error: {error}]\n"
|
||||
|
||||
|
||||
def _format_sse_event(event_type: str, data: str, event_id: str | None = None) -> str:
|
||||
"""Format a Server-Sent Event."""
|
||||
lines = []
|
||||
if event_id:
|
||||
lines.append(f"id: {event_id}")
|
||||
lines.append(f"event: {event_type}")
|
||||
lines.append(f"data: {data}")
|
||||
lines.append("")
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -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%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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",
|
||||
"REDIS_CONNECTION_STRING": "redis://localhost:6379",
|
||||
"REDIS_STREAM_TTL_MINUTES": "10",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Redis-based streaming response handler for durable agents.
|
||||
|
||||
This module provides reliable, resumable streaming of agent responses using Redis Streams
|
||||
as a message broker. It enables clients to disconnect and reconnect without losing messages.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamChunk:
|
||||
"""Represents a chunk of streamed data from Redis.
|
||||
|
||||
Attributes:
|
||||
entry_id: The Redis stream entry ID (used as cursor for resumption).
|
||||
text: The text content of the chunk, if any.
|
||||
is_done: Whether this is the final chunk in the stream.
|
||||
error: Error message if an error occurred, otherwise None.
|
||||
"""
|
||||
|
||||
entry_id: str
|
||||
text: str | None = None
|
||||
is_done: bool = False
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class RedisStreamResponseHandler:
|
||||
"""Handles agent responses by persisting them to Redis Streams.
|
||||
|
||||
This handler writes agent response updates to Redis Streams, enabling reliable,
|
||||
resumable streaming delivery to clients. Clients can disconnect and reconnect
|
||||
at any point using cursor-based pagination.
|
||||
|
||||
Attributes:
|
||||
MAX_EMPTY_READS: Maximum number of empty reads before timing out.
|
||||
POLL_INTERVAL_MS: Interval in milliseconds between polling attempts.
|
||||
"""
|
||||
|
||||
MAX_EMPTY_READS = 300
|
||||
POLL_INTERVAL_MS = 1000
|
||||
|
||||
def __init__(self, redis_client: aioredis.Redis, stream_ttl: timedelta):
|
||||
"""Initialize the Redis stream response handler.
|
||||
|
||||
Args:
|
||||
redis_client: The async Redis client instance.
|
||||
stream_ttl: Time-to-live for stream entries in Redis.
|
||||
"""
|
||||
self._redis = redis_client
|
||||
self._stream_ttl = stream_ttl
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Enter async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Exit async context manager and close Redis connection."""
|
||||
await self._redis.aclose()
|
||||
|
||||
async def write_chunk(
|
||||
self,
|
||||
conversation_id: str,
|
||||
text: str,
|
||||
sequence: int,
|
||||
) -> None:
|
||||
"""Write a single text chunk to the Redis Stream.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID for this agent run.
|
||||
text: The text content to write.
|
||||
sequence: The sequence number for ordering.
|
||||
"""
|
||||
stream_key = self._get_stream_key(conversation_id)
|
||||
await self._redis.xadd(
|
||||
stream_key,
|
||||
{
|
||||
"text": text,
|
||||
"sequence": str(sequence),
|
||||
"timestamp": str(int(time.time() * 1000)),
|
||||
},
|
||||
)
|
||||
await self._redis.expire(stream_key, self._stream_ttl)
|
||||
|
||||
async def write_completion(
|
||||
self,
|
||||
conversation_id: str,
|
||||
sequence: int,
|
||||
) -> None:
|
||||
"""Write an end-of-stream marker to the Redis Stream.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID for this agent run.
|
||||
sequence: The final sequence number.
|
||||
"""
|
||||
stream_key = self._get_stream_key(conversation_id)
|
||||
await self._redis.xadd(
|
||||
stream_key,
|
||||
{
|
||||
"text": "",
|
||||
"sequence": str(sequence),
|
||||
"timestamp": str(int(time.time() * 1000)),
|
||||
"done": "true",
|
||||
},
|
||||
)
|
||||
await self._redis.expire(stream_key, self._stream_ttl)
|
||||
|
||||
async def read_stream(
|
||||
self,
|
||||
conversation_id: str,
|
||||
cursor: str | None = None,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
"""Read entries from a Redis Stream with cursor-based pagination.
|
||||
|
||||
This method polls the Redis Stream for new entries, yielding chunks as they
|
||||
become available. Clients can resume from any point using the entry_id from
|
||||
a previous chunk.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to read from.
|
||||
cursor: Optional cursor to resume from. If None, starts from beginning.
|
||||
|
||||
Yields:
|
||||
StreamChunk instances containing text content or status markers.
|
||||
"""
|
||||
stream_key = self._get_stream_key(conversation_id)
|
||||
start_id = cursor if cursor else "0-0"
|
||||
|
||||
empty_read_count = 0
|
||||
has_seen_data = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Read up to 100 entries from the stream
|
||||
entries = await self._redis.xread(
|
||||
{stream_key: start_id},
|
||||
count=100,
|
||||
block=None,
|
||||
)
|
||||
|
||||
if not entries:
|
||||
# No entries found
|
||||
if not has_seen_data:
|
||||
empty_read_count += 1
|
||||
if empty_read_count >= self.MAX_EMPTY_READS:
|
||||
timeout_seconds = self.MAX_EMPTY_READS * self.POLL_INTERVAL_MS / 1000
|
||||
yield StreamChunk(
|
||||
entry_id=start_id,
|
||||
error=f"Stream not found or timed out after {timeout_seconds} seconds",
|
||||
)
|
||||
return
|
||||
|
||||
# Wait before polling again
|
||||
await asyncio.sleep(self.POLL_INTERVAL_MS / 1000)
|
||||
continue
|
||||
|
||||
has_seen_data = True
|
||||
|
||||
# Process entries from the stream
|
||||
for _stream_name, stream_entries in entries:
|
||||
for entry_id, entry_data in stream_entries:
|
||||
start_id = entry_id.decode() if isinstance(entry_id, bytes) else entry_id
|
||||
|
||||
# Decode entry data
|
||||
text = entry_data.get(b"text", b"").decode() if b"text" in entry_data else None
|
||||
done = entry_data.get(b"done", b"").decode() if b"done" in entry_data else None
|
||||
error = entry_data.get(b"error", b"").decode() if b"error" in entry_data else None
|
||||
|
||||
if error:
|
||||
yield StreamChunk(entry_id=start_id, error=error)
|
||||
return
|
||||
|
||||
if done == "true":
|
||||
yield StreamChunk(entry_id=start_id, is_done=True)
|
||||
return
|
||||
|
||||
if text:
|
||||
yield StreamChunk(entry_id=start_id, text=text)
|
||||
|
||||
except Exception as ex:
|
||||
yield StreamChunk(entry_id=start_id, error=str(ex))
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _get_stream_key(conversation_id: str) -> str:
|
||||
"""Generate the Redis key for a conversation's stream.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID.
|
||||
|
||||
Returns:
|
||||
The Redis stream key.
|
||||
"""
|
||||
return f"agent-stream:{conversation_id}"
|
||||
@@ -0,0 +1,18 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
|
||||
# Redis client with asyncio support (used by redis_stream_response_handler.py)
|
||||
redis[asyncio]
|
||||
@@ -0,0 +1,164 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Mock travel tools for demonstration purposes.
|
||||
|
||||
In a real application, these would call actual weather and events APIs.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
|
||||
def get_weather_forecast(
|
||||
destination: Annotated[str, "The destination city or location"],
|
||||
date: Annotated[str, 'The date for the forecast (e.g., "2025-01-15" or "next Monday")'],
|
||||
) -> str:
|
||||
"""Get the weather forecast for a destination on a specific date.
|
||||
|
||||
Use this to provide weather-aware recommendations in the itinerary.
|
||||
|
||||
Args:
|
||||
destination: The destination city or location.
|
||||
date: The date for the forecast.
|
||||
|
||||
Returns:
|
||||
A weather forecast summary.
|
||||
"""
|
||||
# Mock weather data based on destination for realistic responses
|
||||
weather_by_region = {
|
||||
"Tokyo": ("Partly cloudy with a chance of light rain", 58, 45),
|
||||
"Paris": ("Overcast with occasional drizzle", 52, 41),
|
||||
"New York": ("Clear and cold", 42, 28),
|
||||
"London": ("Foggy morning, clearing in afternoon", 48, 38),
|
||||
"Sydney": ("Sunny and warm", 82, 68),
|
||||
"Rome": ("Sunny with light breeze", 62, 48),
|
||||
"Barcelona": ("Partly sunny", 59, 47),
|
||||
"Amsterdam": ("Cloudy with light rain", 46, 38),
|
||||
"Dubai": ("Sunny and hot", 85, 72),
|
||||
"Singapore": ("Tropical thunderstorms in afternoon", 88, 77),
|
||||
"Bangkok": ("Hot and humid, afternoon showers", 91, 78),
|
||||
"Los Angeles": ("Sunny and pleasant", 72, 55),
|
||||
"San Francisco": ("Morning fog, afternoon sun", 62, 52),
|
||||
"Seattle": ("Rainy with breaks", 48, 40),
|
||||
"Miami": ("Warm and sunny", 78, 65),
|
||||
"Honolulu": ("Tropical paradise weather", 82, 72),
|
||||
}
|
||||
|
||||
# Find a matching destination or use a default
|
||||
forecast = ("Partly cloudy", 65, 50)
|
||||
for city, weather in weather_by_region.items():
|
||||
if city.lower() in destination.lower():
|
||||
forecast = weather
|
||||
break
|
||||
|
||||
condition, high_f, low_f = forecast
|
||||
high_c = (high_f - 32) * 5 // 9
|
||||
low_c = (low_f - 32) * 5 // 9
|
||||
|
||||
recommendation = _get_weather_recommendation(condition)
|
||||
|
||||
return f"""Weather forecast for {destination} on {date}:
|
||||
Conditions: {condition}
|
||||
High: {high_f}°F ({high_c}°C)
|
||||
Low: {low_f}°F ({low_c}°C)
|
||||
|
||||
Recommendation: {recommendation}"""
|
||||
|
||||
|
||||
def get_local_events(
|
||||
destination: Annotated[str, "The destination city or location"],
|
||||
date: Annotated[str, 'The date to search for events (e.g., "2025-01-15" or "next week")'],
|
||||
) -> str:
|
||||
"""Get local events and activities happening at a destination around a specific date.
|
||||
|
||||
Use this to suggest timely activities and experiences.
|
||||
|
||||
Args:
|
||||
destination: The destination city or location.
|
||||
date: The date to search for events.
|
||||
|
||||
Returns:
|
||||
A list of local events and activities.
|
||||
"""
|
||||
# Mock events data based on destination
|
||||
events_by_city = {
|
||||
"Tokyo": [
|
||||
"🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama",
|
||||
"🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays",
|
||||
"🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan",
|
||||
"🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology",
|
||||
],
|
||||
"Paris": [
|
||||
"🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours",
|
||||
"🍷 Wine Tasting Tour in Le Marais - Local sommelier guided",
|
||||
"🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club",
|
||||
"🥐 French Pastry Workshop - Learn from master pâtissiers",
|
||||
],
|
||||
"New York": [
|
||||
"🎭 Broadway Show: Hamilton - Limited engagement performances",
|
||||
"🏀 Knicks vs Lakers at Madison Square Garden",
|
||||
"🎨 Modern Art Exhibit at MoMA - New installations",
|
||||
"🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias",
|
||||
],
|
||||
"London": [
|
||||
"👑 Royal Collection Exhibition at Buckingham Palace",
|
||||
"🎭 West End Musical: The Phantom of the Opera",
|
||||
"🍺 Craft Beer Festival at Brick Lane",
|
||||
"🎪 Winter Wonderland at Hyde Park - Rides and markets",
|
||||
],
|
||||
"Sydney": [
|
||||
"🏄 Pro Surfing Competition at Bondi Beach",
|
||||
"🎵 Opera at Sydney Opera House - La Bohème",
|
||||
"🦘 Wildlife Night Safari at Taronga Zoo",
|
||||
"🍽️ Harbor Dinner Cruise with fireworks",
|
||||
],
|
||||
"Rome": [
|
||||
"🏛️ After-Hours Vatican Tour - Skip the crowds",
|
||||
"🍝 Pasta Making Class in Trastevere",
|
||||
"🎵 Classical Concert at Borghese Gallery",
|
||||
"🍷 Wine Tasting in Roman Cellars",
|
||||
],
|
||||
}
|
||||
|
||||
# Find events for the destination or use generic events
|
||||
events = [
|
||||
"🎭 Local theater performance",
|
||||
"🍽️ Food and wine festival",
|
||||
"🎨 Art gallery opening",
|
||||
"🎵 Live music at local venues",
|
||||
]
|
||||
|
||||
for city, city_events in events_by_city.items():
|
||||
if city.lower() in destination.lower():
|
||||
events = city_events
|
||||
break
|
||||
|
||||
event_list = "\n• ".join(events)
|
||||
return f"""Local events in {destination} around {date}:
|
||||
|
||||
• {event_list}
|
||||
|
||||
💡 Tip: Book popular events in advance as they may sell out quickly!"""
|
||||
|
||||
|
||||
def _get_weather_recommendation(condition: str) -> str:
|
||||
"""Get a recommendation based on weather conditions.
|
||||
|
||||
Args:
|
||||
condition: The weather condition description.
|
||||
|
||||
Returns:
|
||||
A recommendation string.
|
||||
"""
|
||||
condition_lower = condition.lower()
|
||||
|
||||
if "rain" in condition_lower or "drizzle" in condition_lower:
|
||||
return "Bring an umbrella and waterproof jacket. Consider indoor activities for backup."
|
||||
if "fog" in condition_lower:
|
||||
return "Morning visibility may be limited. Plan outdoor sightseeing for afternoon."
|
||||
if "cold" in condition_lower:
|
||||
return "Layer up with warm clothing. Hot drinks and cozy cafés recommended."
|
||||
if "hot" in condition_lower or "warm" in condition_lower:
|
||||
return "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours."
|
||||
if "thunder" in condition_lower or "storm" in condition_lower:
|
||||
return "Keep an eye on weather updates. Have indoor alternatives ready."
|
||||
return "Pleasant conditions expected. Great day for outdoor exploration!"
|
||||
+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 session
|
||||
- Reusing an agent session 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 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Chain two runs of a single agent inside a Durable Functions orchestration.
|
||||
|
||||
Components used in this sample:
|
||||
- FoundryChatClient 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 session.
|
||||
|
||||
Prerequisites: configure `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import azure.functions as func
|
||||
from agent_framework import Agent
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
|
||||
from azure.identity.aio 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."
|
||||
)
|
||||
|
||||
_client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
return Agent(
|
||||
client=_client,
|
||||
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 session for chaining behaviour.
|
||||
@app.orchestration_trigger(context_name="context")
|
||||
def single_agent_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, str]:
|
||||
"""Run the writer agent twice on the same session to mirror chaining behaviour."""
|
||||
|
||||
writer = app.get_agent(context, WRITER_AGENT_NAME)
|
||||
writer_session = writer.create_session()
|
||||
|
||||
initial = yield writer.run(
|
||||
messages="Write a concise inspirational sentence about learning.",
|
||||
session=writer_session,
|
||||
)
|
||||
|
||||
improved_prompt = f"Improve this further while keeping it under 25 words: {initial.text}"
|
||||
|
||||
refined = yield writer.run(
|
||||
messages=improved_prompt,
|
||||
session=writer_session,
|
||||
)
|
||||
|
||||
return refined.text
|
||||
|
||||
|
||||
# 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: 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: 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)
|
||||
|
||||
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%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
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}}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Fan out concurrent runs across two agents inside a Durable Functions orchestration.
|
||||
|
||||
Components used in this sample:
|
||||
- FoundryChatClient 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 `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before starting the Functions host."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
|
||||
import azure.functions as func
|
||||
from agent_framework import Agent, AgentResponse
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
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]:
|
||||
physicist = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
name=PHYSICIST_AGENT_NAME,
|
||||
instructions="You are an expert in physics. You answer questions from a physics perspective.",
|
||||
)
|
||||
|
||||
chemist = Agent(
|
||||
client=FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
),
|
||||
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) -> Generator[Any, Any, dict[str, str]]:
|
||||
"""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_session = physicist.create_session()
|
||||
chemist_session = chemist.create_session()
|
||||
|
||||
# Create tasks from agent.run() calls
|
||||
physicist_task = physicist.run(messages=str(prompt), session=physicist_session)
|
||||
chemist_task = chemist.run(messages=str(prompt), session=chemist_session)
|
||||
|
||||
# Execute both tasks concurrently using task_all
|
||||
task_results = yield context.task_all([physicist_task, chemist_task])
|
||||
|
||||
physicist_result = cast(AgentResponse, task_results[0])
|
||||
chemist_result = cast(AgentResponse, task_results[1])
|
||||
|
||||
return {
|
||||
"physicist": physicist_result.text,
|
||||
"chemist": chemist_result.text,
|
||||
}
|
||||
|
||||
|
||||
# 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: 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: 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)
|
||||
|
||||
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%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
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 '{"email_id": "email-001", "email_content": "URGENT! You'\''ve won $1,000,000! Click here now to claim your prize! Limited time offer! Don'\''t miss out!"}'
|
||||
```
|
||||
|
||||
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}}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Route email requests through conditional orchestration with two agents.
|
||||
|
||||
Components used in this sample:
|
||||
- FoundryChatClient 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 `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before running the
|
||||
Functions host."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator, Mapping
|
||||
from typing import Any
|
||||
|
||||
import azure.functions as func
|
||||
from agent_framework import Agent
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
|
||||
from azure.identity.aio 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]:
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
spam_agent = Agent(
|
||||
client=client,
|
||||
name=SPAM_AGENT_NAME,
|
||||
instructions="You are a spam detection assistant that identifies spam emails.",
|
||||
)
|
||||
|
||||
email_agent = Agent(
|
||||
client=client,
|
||||
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) -> Generator[Any, Any, str]:
|
||||
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_session = spam_agent.create_session()
|
||||
|
||||
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,
|
||||
session=spam_session,
|
||||
options={"response_format": SpamDetectionResult},
|
||||
)
|
||||
|
||||
try:
|
||||
spam_result = spam_result_raw.value
|
||||
except Exception as ex:
|
||||
raise ValueError("Failed to parse spam detection result") from ex
|
||||
|
||||
if spam_result.is_spam:
|
||||
result = yield context.call_activity("handle_spam_email", spam_result.reason) # type: ignore[misc]
|
||||
return result
|
||||
|
||||
email_session = email_agent.create_session()
|
||||
|
||||
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,
|
||||
session=email_session,
|
||||
options={"response_format": EmailResponse},
|
||||
)
|
||||
|
||||
try:
|
||||
email_result = email_result_raw.value
|
||||
except Exception as ex:
|
||||
raise ValueError("Failed to parse email response") from ex
|
||||
|
||||
result = yield context.call_activity("send_email", email_result.response) # type: ignore[misc]
|
||||
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: 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: 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)
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
"""
|
||||
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%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
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 Foundry and storage settings.
|
||||
|
||||
## What It Shows
|
||||
- Identical environment variable usage (`FOUNDRY_PROJECT_ENDPOINT`,
|
||||
`FOUNDRY_MODEL`) 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."
|
||||
}
|
||||
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Iterate on generated content with a human-in-the-loop Durable orchestration.
|
||||
|
||||
Components used in this sample:
|
||||
- FoundryChatClient 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 `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL`, and sign in with Azure CLI before running `func start`."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Generator, Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import azure.functions as func
|
||||
from agent_framework import Agent
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
|
||||
from azure.identity.aio 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."
|
||||
)
|
||||
|
||||
_client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
return Agent(
|
||||
client=_client,
|
||||
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) -> Generator[Any, Any, dict[str, str]]:
|
||||
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_session = writer.create_session()
|
||||
|
||||
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}'.",
|
||||
session=writer_session,
|
||||
options={"response_format": GeneratedContent},
|
||||
)
|
||||
|
||||
content = initial_raw.value
|
||||
|
||||
if content is None:
|
||||
raise ValueError("Agent returned no content after extraction.")
|
||||
|
||||
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()) # type: ignore[misc]
|
||||
|
||||
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] # ty: ignore[unresolved-attribute]
|
||||
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()) # type: ignore[misc]
|
||||
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...")
|
||||
|
||||
# Check if we've exhausted attempts
|
||||
if attempt >= payload.max_review_attempts:
|
||||
break
|
||||
|
||||
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,
|
||||
session=writer_session,
|
||||
options={"response_format": GeneratedContent},
|
||||
)
|
||||
|
||||
try:
|
||||
content = rewritten_raw.value
|
||||
except Exception as ex:
|
||||
raise ValueError("Agent returned no content after rewrite.") from ex
|
||||
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).")
|
||||
|
||||
# If we exit the loop without returning, max attempts were exhausted
|
||||
context.set_custom_status("Max review attempts exhausted.")
|
||||
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: 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: 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: 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 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 _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%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
@@ -0,0 +1,198 @@
|
||||
# Agent as MCP Tool Sample
|
||||
|
||||
This sample demonstrates how to configure AI agents to be accessible as both HTTP endpoints and [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools, enabling flexible integration patterns for AI agent consumption.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Multi-trigger Agent Configuration**: Configure agents to support HTTP triggers, MCP tool triggers, or both
|
||||
- **Microsoft Agent Framework Integration**: Use the framework to define AI agents with specific roles and capabilities
|
||||
- **Flexible Agent Registration**: Register agents with customizable trigger configurations
|
||||
- **MCP Server Hosting**: Expose agents as MCP tools for consumption by MCP-compatible clients
|
||||
|
||||
## Sample Architecture
|
||||
|
||||
This sample creates three agents with different trigger configurations:
|
||||
|
||||
| Agent | Role | HTTP Trigger | MCP Tool Trigger | Description |
|
||||
|-------|------|--------------|------------------|-------------|
|
||||
| **Joker** | Comedy specialist | ✅ Enabled | ❌ Disabled | Accessible only via HTTP requests |
|
||||
| **StockAdvisor** | Financial data | ❌ Disabled | ✅ Enabled | Accessible only as MCP tool |
|
||||
| **PlantAdvisor** | Indoor plant recommendations | ✅ Enabled | ✅ Enabled | Accessible via both HTTP and MCP |
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for complete setup instructions, including:
|
||||
|
||||
- Prerequisites installation
|
||||
- Azure OpenAI configuration
|
||||
- Durable Task Scheduler setup
|
||||
- Storage emulator configuration
|
||||
|
||||
## Configuration
|
||||
|
||||
Update your `local.settings.json` with your Foundry project settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"Values": {
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project",
|
||||
"FOUNDRY_MODEL": "your-deployment-name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. **Start the Function App**:
|
||||
```bash
|
||||
cd python/samples/04-hosting/azure_functions/08_mcp_server
|
||||
func start
|
||||
```
|
||||
|
||||
2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output. It will look like:
|
||||
```
|
||||
MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
|
||||
```
|
||||
|
||||
## Testing MCP Tool Integration
|
||||
|
||||
### Using MCP Inspector
|
||||
|
||||
1. Install the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector)
|
||||
2. Connect using the MCP server endpoint from your terminal output
|
||||
3. Select **"Streamable HTTP"** as the transport method
|
||||
4. Test the available MCP tools:
|
||||
- `StockAdvisor` - Available only as MCP tool
|
||||
- `PlantAdvisor` - Available as both HTTP and MCP tool
|
||||
|
||||
### Using Other MCP Clients
|
||||
|
||||
Any MCP-compatible client can connect to the server endpoint and utilize the exposed agent tools. The agents will appear as callable tools within the MCP protocol.
|
||||
|
||||
## Testing HTTP Endpoints
|
||||
|
||||
For agents with HTTP triggers enabled (Joker and PlantAdvisor), you can test them using curl:
|
||||
|
||||
```bash
|
||||
# Test Joker agent (HTTP only)
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Tell me a joke"}'
|
||||
|
||||
# Test PlantAdvisor agent (HTTP and MCP)
|
||||
curl -X POST http://localhost:7071/api/agents/PlantAdvisor/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Recommend an indoor plant"}'
|
||||
```
|
||||
|
||||
Note: StockAdvisor does not have HTTP endpoints and is only accessible via MCP tool triggers.
|
||||
|
||||
## Expected Output
|
||||
|
||||
**HTTP Responses** will be returned directly to your HTTP client.
|
||||
|
||||
**MCP Tool Responses** will be visible in:
|
||||
- The terminal where `func start` is running
|
||||
- Your MCP client interface
|
||||
- The DTS dashboard at `http://localhost:8080` (if using Durable Task Scheduler)
|
||||
|
||||
## Health Check
|
||||
|
||||
Check the health endpoint to see which agents have which triggers enabled:
|
||||
|
||||
```bash
|
||||
curl http://localhost:7071/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"agents": [
|
||||
{
|
||||
"name": "Joker",
|
||||
"type": "Agent",
|
||||
"http_endpoint_enabled": true,
|
||||
"mcp_tool_enabled": false
|
||||
},
|
||||
{
|
||||
"name": "StockAdvisor",
|
||||
"type": "Agent",
|
||||
"http_endpoint_enabled": false,
|
||||
"mcp_tool_enabled": true
|
||||
},
|
||||
{
|
||||
"name": "PlantAdvisor",
|
||||
"type": "Agent",
|
||||
"http_endpoint_enabled": true,
|
||||
"mcp_tool_enabled": true
|
||||
}
|
||||
],
|
||||
"agent_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
## Code Structure
|
||||
|
||||
The sample shows how to enable MCP tool triggers with flexible agent configuration:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
# Create Foundry chat client
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# Define agents with different roles
|
||||
joker_agent = Agent(
|
||||
client=client,
|
||||
name="Joker",
|
||||
instructions="You are good at telling jokes.",
|
||||
)
|
||||
|
||||
stock_agent = Agent(
|
||||
client=client,
|
||||
name="StockAdvisor",
|
||||
instructions="Check stock prices.",
|
||||
)
|
||||
|
||||
plant_agent = Agent(
|
||||
client=client,
|
||||
name="PlantAdvisor",
|
||||
instructions="Recommend plants.",
|
||||
description="Get plant recommendations.",
|
||||
)
|
||||
|
||||
# Create the AgentFunctionApp
|
||||
app = AgentFunctionApp(enable_health_check=True)
|
||||
|
||||
# Configure agents with different trigger combinations:
|
||||
# HTTP trigger only (default)
|
||||
app.add_agent(joker_agent)
|
||||
|
||||
# MCP tool trigger only (HTTP disabled)
|
||||
app.add_agent(stock_agent, enable_http_endpoint=False, enable_mcp_tool_trigger=True)
|
||||
|
||||
# Both HTTP and MCP tool triggers enabled
|
||||
app.add_agent(plant_agent, enable_http_endpoint=True, enable_mcp_tool_trigger=True)
|
||||
```
|
||||
|
||||
This automatically creates the following endpoints based on agent configuration:
|
||||
- `POST /api/agents/{AgentName}/run` - HTTP endpoint (when `enable_http_endpoint=True`)
|
||||
- MCP tool triggers for agents with `enable_mcp_tool_trigger=True`
|
||||
- `GET /api/health` - Health check endpoint showing agent configurations
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Model Context Protocol Documentation](https://modelcontextprotocol.io/)
|
||||
- [Microsoft Agent Framework Documentation](https://github.com/microsoft/agent-framework)
|
||||
- [Azure Functions Documentation](https://learn.microsoft.com/azure/azure-functions/)
|
||||
@@ -0,0 +1,79 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Example showing how to configure AI agents with different trigger configurations.
|
||||
|
||||
This sample demonstrates how to configure agents to be accessible as both HTTP endpoints
|
||||
and Model Context Protocol (MCP) tools, enabling flexible integration patterns for AI agent
|
||||
consumption.
|
||||
|
||||
Key concepts demonstrated:
|
||||
- Multi-trigger Agent Configuration: Configure agents to support HTTP triggers, MCP tool triggers, or both
|
||||
- Microsoft Agent Framework Integration: Use the framework to define AI agents with specific roles
|
||||
- Flexible Agent Registration: Register agents with customizable trigger configurations
|
||||
|
||||
This sample creates three agents with different trigger configurations:
|
||||
- Joker: HTTP trigger only (default)
|
||||
- StockAdvisor: MCP tool trigger only (HTTP disabled)
|
||||
- PlantAdvisor: Both HTTP and MCP tool triggers enabled
|
||||
|
||||
Required environment variables:
|
||||
- FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint
|
||||
- FOUNDRY_MODEL: Your Azure AI Foundry deployment name
|
||||
|
||||
Authentication uses AzureCliCredential (Azure Identity).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Create Foundry chat client
|
||||
# This uses AzureCliCredential for authentication (requires 'az login')
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# Define three AI agents with different roles
|
||||
# Agent 1: Joker - HTTP trigger only (default)
|
||||
agent1 = Agent(
|
||||
client=client,
|
||||
name="Joker",
|
||||
instructions="You are good at telling jokes.",
|
||||
)
|
||||
|
||||
# Agent 2: StockAdvisor - MCP tool trigger only
|
||||
agent2 = Agent(
|
||||
client=client,
|
||||
name="StockAdvisor",
|
||||
instructions="Check stock prices.",
|
||||
)
|
||||
|
||||
# Agent 3: PlantAdvisor - Both HTTP and MCP tool triggers
|
||||
agent3 = Agent(
|
||||
client=client,
|
||||
name="PlantAdvisor",
|
||||
instructions="Recommend plants.",
|
||||
description="Get plant recommendations.",
|
||||
)
|
||||
|
||||
# Create the AgentFunctionApp with selective trigger configuration
|
||||
app = AgentFunctionApp(
|
||||
enable_health_check=True,
|
||||
)
|
||||
|
||||
# Agent 1: HTTP trigger only (default)
|
||||
app.add_agent(agent1)
|
||||
|
||||
# Agent 2: Disable HTTP trigger, enable MCP tool trigger only
|
||||
app.add_agent(agent2, enable_http_endpoint=False, enable_mcp_tool_trigger=True)
|
||||
|
||||
# Agent 3: Enable both HTTP and MCP tool triggers
|
||||
app.add_agent(agent3, enable_http_endpoint=True, enable_mcp_tool_trigger=True)
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
@@ -0,0 +1,18 @@
|
||||
# Local settings
|
||||
local.settings.json
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Azure Functions
|
||||
bin/
|
||||
obj/
|
||||
.python_packages/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,99 @@
|
||||
# Workflow with SharedState Sample
|
||||
|
||||
This sample demonstrates running **Agent Framework workflows with SharedState** in Azure Durable Functions.
|
||||
|
||||
## Overview
|
||||
|
||||
This sample shows how to use `AgentFunctionApp` to execute a `WorkflowBuilder` workflow that uses SharedState to pass data between executors. SharedState is a local dictionary maintained by the orchestration that allows executors to share data across workflow steps.
|
||||
|
||||
## What This Sample Demonstrates
|
||||
|
||||
1. **Workflow Execution** - Running `WorkflowBuilder` workflows in Azure Durable Functions
|
||||
2. **State APIs** - Using `ctx.set_state()` and `ctx.get_state()` to share data
|
||||
3. **Conditional Routing** - Routing messages based on spam detection results
|
||||
4. **Agent + Executor Composition** - Combining AI agents with non-AI function executors
|
||||
|
||||
## Workflow Architecture
|
||||
|
||||
```
|
||||
store_email → spam_detector (agent) → to_detection_result → [branch]:
|
||||
├── If spam: handle_spam → yield "Email marked as spam: {reason}"
|
||||
└── If not spam: submit_to_email_assistant → email_assistant (agent) → finalize_and_send → yield "Email sent: {response}"
|
||||
```
|
||||
|
||||
### SharedState Usage by Executor
|
||||
|
||||
| Executor | SharedState Operations |
|
||||
|----------|----------------------|
|
||||
| `store_email` | `set_state("email:{id}", email)`, `set_state("current_email_id", id)` |
|
||||
| `to_detection_result` | `get_state("current_email_id")` |
|
||||
| `submit_to_email_assistant` | `get_state("email:{id}")` |
|
||||
|
||||
SharedState allows executors to pass large payloads (like email content) by reference rather than through message routing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Azure OpenAI** - Endpoint and deployment configured
|
||||
2. **Azurite** - For local storage emulation
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy `local.settings.json.sample` to `local.settings.json` and configure:
|
||||
```json
|
||||
{
|
||||
"Values": {
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project",
|
||||
"FOUNDRY_MODEL": "gpt-4o"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Start Azurite:
|
||||
```bash
|
||||
azurite --silent
|
||||
```
|
||||
|
||||
4. Run the function app:
|
||||
```bash
|
||||
func start
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use the `demo.http` file with REST Client extension or curl:
|
||||
|
||||
### Test Spam Email
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflow/email_triage_shared_state/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '"URGENT! You have won $1,000,000! Click here to claim!"'
|
||||
```
|
||||
|
||||
### Test Legitimate Email
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflow/email_triage_shared_state/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '"Hi team, reminder about our meeting tomorrow at 10 AM."'
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
**Spam email:**
|
||||
```
|
||||
Email marked as spam: This email exhibits spam characteristics including urgent language, unrealistic claims of monetary winnings, and requests to click suspicious links.
|
||||
```
|
||||
|
||||
**Legitimate email:**
|
||||
```
|
||||
Email sent: Hi, Thank you for the reminder about the sprint planning meeting tomorrow at 10 AM. I will review the agenda and come prepared with my updates. See you then!
|
||||
```
|
||||
|
||||
## Related Samples
|
||||
|
||||
- `10_workflow_no_shared_state` - Workflow execution without SharedState usage
|
||||
- `06_multi_agent_orchestration_conditionals` - Manual Durable Functions orchestration with agents
|
||||
@@ -0,0 +1,31 @@
|
||||
@endpoint = http://localhost:7071
|
||||
|
||||
### Start the workflow with a spam email
|
||||
POST {{endpoint}}/api/workflow/email_triage_shared_state/run
|
||||
Content-Type: application/json
|
||||
|
||||
"URGENT! You have won $1,000,000! Click here to claim your prize now before it expires!"
|
||||
|
||||
### Start the workflow with a legitimate email
|
||||
POST {{endpoint}}/api/workflow/email_triage_shared_state/run
|
||||
Content-Type: application/json
|
||||
|
||||
"Hi team, just a reminder about the sprint planning meeting tomorrow at 10 AM. Please review the agenda items in Jira before the call."
|
||||
|
||||
### Start the workflow with another legitimate email
|
||||
POST {{endpoint}}/api/workflow/email_triage_shared_state/run
|
||||
Content-Type: application/json
|
||||
|
||||
"Hello, I wanted to follow up on our conversation from last week regarding the project timeline. Could we schedule a brief call this afternoon to discuss the next steps?"
|
||||
|
||||
### Start the workflow with a phishing attempt
|
||||
POST {{endpoint}}/api/workflow/email_triage_shared_state/run
|
||||
Content-Type: application/json
|
||||
|
||||
"Dear Customer, Your account has been compromised! Click this link immediately to secure your account: http://totallylegit.suspicious.com/secure"
|
||||
|
||||
### Check workflow status (replace {instanceId} with actual instance ID from response)
|
||||
GET {{endpoint}}/runtime/webhooks/durabletask/instances/{instanceId}
|
||||
|
||||
### Purge all orchestration instances (use for cleanup)
|
||||
POST {{endpoint}}/runtime/webhooks/durabletask/instances/purge?createdTimeFrom=2020-01-01T00:00:00Z&createdTimeTo=2030-12-31T23:59:59Z
|
||||
@@ -0,0 +1,288 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""
|
||||
Sample: Shared state with agents and conditional routing.
|
||||
|
||||
Store an email once by id, classify it with a detector agent, then either draft a reply with an assistant
|
||||
agent or finish with a spam notice. Stream events as the workflow runs.
|
||||
|
||||
Purpose:
|
||||
Show how to:
|
||||
- Use shared state to decouple large payloads from messages and pass around lightweight references.
|
||||
- Enforce structured agent outputs with Pydantic models via response_format for robust parsing.
|
||||
- Route using conditional edges based on a typed intermediate DetectionResult.
|
||||
- Compose agent backed executors with function style executors and yield the final output when the workflow completes.
|
||||
|
||||
Prerequisites:
|
||||
- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` for FoundryChatClient.
|
||||
- Authentication uses `AzureCliCredential`; run `az login` before executing the sample.
|
||||
- Familiarity with WorkflowBuilder, executors, conditional edges, and streaming runs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
Message,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.openai import OpenAIChatOptions
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from typing_extensions import Never
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable names
|
||||
FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT"
|
||||
AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL"
|
||||
|
||||
EMAIL_STATE_PREFIX = "email:"
|
||||
CURRENT_EMAIL_ID_KEY = "current_email_id"
|
||||
|
||||
|
||||
class DetectionResultAgent(BaseModel):
|
||||
"""Structured output returned by the spam detection agent."""
|
||||
|
||||
is_spam: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class EmailResponse(BaseModel):
|
||||
"""Structured output returned by the email assistant agent."""
|
||||
|
||||
response: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionResult:
|
||||
"""Internal detection result enriched with the shared state email_id for later lookups."""
|
||||
|
||||
is_spam: bool
|
||||
reason: str
|
||||
email_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Email:
|
||||
"""In memory record stored in shared state to avoid re-sending large bodies on edges."""
|
||||
|
||||
email_id: str
|
||||
email_content: str
|
||||
|
||||
|
||||
def get_condition(expected_result: bool):
|
||||
"""Create a condition predicate for DetectionResult.is_spam.
|
||||
|
||||
Contract:
|
||||
- If the message is not a DetectionResult, allow it to pass to avoid accidental dead ends.
|
||||
- Otherwise, return True only when is_spam matches expected_result.
|
||||
"""
|
||||
|
||||
def condition(message: Any) -> bool:
|
||||
if not isinstance(message, DetectionResult):
|
||||
return True
|
||||
return message.is_spam == expected_result
|
||||
|
||||
return condition
|
||||
|
||||
|
||||
@executor(id="store_email")
|
||||
async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
"""Persist the raw email content in shared state and trigger spam detection.
|
||||
|
||||
Responsibilities:
|
||||
- Generate a unique email_id (UUID) for downstream retrieval.
|
||||
- Store the Email object under a namespaced key and set the current id pointer.
|
||||
- Emit an AgentExecutorRequest asking the detector to respond.
|
||||
"""
|
||||
new_email = Email(email_id=str(uuid4()), email_content=email_text)
|
||||
ctx.set_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email)
|
||||
ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id)
|
||||
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[Message(role="user", contents=[new_email.email_content])], should_respond=True)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="to_detection_result")
|
||||
async def to_detection_result(response: AgentExecutorResponse, ctx: WorkflowContext[DetectionResult]) -> None:
|
||||
"""Parse spam detection JSON into a structured model and enrich with email_id.
|
||||
|
||||
Steps:
|
||||
1) Validate the agent's JSON output into DetectionResultAgent.
|
||||
2) Retrieve the current email_id from shared state.
|
||||
3) Send a typed DetectionResult for conditional routing.
|
||||
"""
|
||||
try:
|
||||
parsed = DetectionResultAgent.model_validate_json(response.agent_response.text)
|
||||
except ValidationError:
|
||||
# Fallback for empty or invalid response (e.g. due to content filtering)
|
||||
parsed = DetectionResultAgent(is_spam=True, reason="Agent execution failed or yielded invalid JSON.")
|
||||
|
||||
email_id: str = ctx.get_state(CURRENT_EMAIL_ID_KEY)
|
||||
await ctx.send_message(DetectionResult(is_spam=parsed.is_spam, reason=parsed.reason, email_id=email_id))
|
||||
|
||||
|
||||
@executor(id="submit_to_email_assistant")
|
||||
async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
"""Forward non spam email content to the drafting agent.
|
||||
|
||||
Guard:
|
||||
- This path should only receive non spam. Raise if misrouted.
|
||||
"""
|
||||
if detection.is_spam:
|
||||
raise RuntimeError("This executor should only handle non-spam messages.")
|
||||
|
||||
# Load the original content by id from shared state and forward it to the assistant.
|
||||
email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[Message(role="user", contents=[email.email_content])], should_respond=True)
|
||||
)
|
||||
|
||||
|
||||
@executor(id="finalize_and_send")
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Validate the drafted reply and yield the final output."""
|
||||
parsed = EmailResponse.model_validate_json(response.agent_response.text)
|
||||
await ctx.yield_output(f"Email sent: {parsed.response}")
|
||||
|
||||
|
||||
@executor(id="handle_spam")
|
||||
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Yield output describing why the email was marked as spam."""
|
||||
if detection.is_spam:
|
||||
await ctx.yield_output(f"Email marked as spam: {detection.reason}")
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle spam messages.")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Workflow Creation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _build_client_kwargs() -> dict[str, Any]:
|
||||
"""Build Foundry chat client configuration from environment variables."""
|
||||
project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV)
|
||||
if not project_endpoint:
|
||||
raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.")
|
||||
|
||||
model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV)
|
||||
if not model:
|
||||
raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.")
|
||||
|
||||
return {
|
||||
"project_endpoint": project_endpoint,
|
||||
"model": model,
|
||||
"credential": AzureCliCredential(),
|
||||
}
|
||||
|
||||
|
||||
def _create_workflow() -> Workflow:
|
||||
"""Create the email classification workflow with conditional routing."""
|
||||
client_kwargs = _build_client_kwargs()
|
||||
chat_client = FoundryChatClient(**client_kwargs)
|
||||
|
||||
spam_detection_agent = Agent(
|
||||
client=chat_client,
|
||||
instructions=(
|
||||
"You are a spam detection assistant that identifies spam emails. "
|
||||
"Always return JSON with fields is_spam (bool) and reason (string)."
|
||||
),
|
||||
default_options=OpenAIChatOptions[Any](response_format=DetectionResultAgent),
|
||||
name="spam_detection_agent",
|
||||
)
|
||||
|
||||
email_assistant_agent = Agent(
|
||||
client=chat_client,
|
||||
instructions=(
|
||||
"You are an email assistant that helps users draft responses to emails with professionalism. "
|
||||
"Return JSON with a single field 'response' containing the drafted reply."
|
||||
),
|
||||
default_options=OpenAIChatOptions[Any](response_format=EmailResponse),
|
||||
name="email_assistant_agent",
|
||||
)
|
||||
|
||||
# Build the workflow graph with conditional edges.
|
||||
# Flow:
|
||||
# store_email -> spam_detection_agent -> to_detection_result -> branch:
|
||||
# False -> submit_to_email_assistant -> email_assistant_agent -> finalize_and_send
|
||||
# True -> handle_spam
|
||||
return (
|
||||
WorkflowBuilder(name="email_triage_shared_state", start_executor=store_email)
|
||||
.add_edge(store_email, spam_detection_agent)
|
||||
.add_edge(spam_detection_agent, to_detection_result)
|
||||
.add_edge(to_detection_result, submit_to_email_assistant, condition=get_condition(False))
|
||||
.add_edge(to_detection_result, handle_spam, condition=get_condition(True))
|
||||
.add_edge(submit_to_email_assistant, email_assistant_agent)
|
||||
.add_edge(email_assistant_agent, finalize_and_send)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Application Entry Point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def launch(durable: bool = True) -> AgentFunctionApp | None:
|
||||
"""Launch the function app or DevUI.
|
||||
|
||||
Args:
|
||||
durable: If True, returns AgentFunctionApp for Azure Functions.
|
||||
If False, launches DevUI for local MAF development.
|
||||
"""
|
||||
if durable:
|
||||
# Azure Functions mode with Durable Functions
|
||||
# SharedState is enabled by default, which this sample requires for storing emails
|
||||
workflow = _create_workflow()
|
||||
return AgentFunctionApp(workflow=workflow, enable_health_check=True)
|
||||
# Pure MAF mode with DevUI for local development
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.devui import serve
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_path = Path(__file__).parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
logger.info("Starting Workflow Shared State Sample in MAF mode")
|
||||
logger.info("Available at: http://localhost:8096")
|
||||
logger.info("\nThis workflow demonstrates:")
|
||||
logger.info("- Shared state to decouple large payloads from messages")
|
||||
logger.info("- Structured agent outputs with Pydantic models")
|
||||
logger.info("- Conditional routing based on detection results")
|
||||
logger.info("\nFlow: store_email -> spam_detection -> branch (spam/not spam)")
|
||||
|
||||
workflow = _create_workflow()
|
||||
serve(entities=[workflow], port=8096, auto_open=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Default: Azure Functions mode
|
||||
# Run with `python function_app.py --maf` for pure MAF mode with DevUI
|
||||
app = launch(durable=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if "--maf" in sys.argv:
|
||||
# Run in pure MAF mode with DevUI
|
||||
launch(durable=False)
|
||||
else:
|
||||
print("Usage: python function_app.py --maf")
|
||||
print(" --maf Run in pure MAF mode with DevUI (http://localhost:8096)")
|
||||
print("\nFor Azure Functions mode, use: func start")
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
@@ -0,0 +1,3 @@
|
||||
# Foundry Configuration
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://your-project.services.ai.azure.com/api/projects/your-project
|
||||
FOUNDRY_MODEL=<your-deployment-name>
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
local.settings.json
|
||||
@@ -0,0 +1,159 @@
|
||||
# Workflow Execution Sample
|
||||
|
||||
This sample demonstrates running **Agent Framework workflows** in Azure Durable Functions without using SharedState.
|
||||
|
||||
## Overview
|
||||
|
||||
This sample shows how to use `AgentFunctionApp` with a `WorkflowBuilder` workflow. The workflow is passed directly to `AgentFunctionApp`, which orchestrates execution using Durable Functions:
|
||||
|
||||
```python
|
||||
workflow = _create_workflow() # Build the workflow graph
|
||||
app = AgentFunctionApp(workflow=workflow)
|
||||
```
|
||||
|
||||
This approach provides durable, fault-tolerant workflow execution with minimal code.
|
||||
|
||||
## What This Sample Demonstrates
|
||||
|
||||
1. **Workflow Registration** - Pass a `Workflow` directly to `AgentFunctionApp`
|
||||
2. **Durable Execution** - Workflow executes with Durable Functions durability and scalability
|
||||
3. **Conditional Routing** - Route messages based on spam detection (is_spam → spam handler, not spam → email assistant)
|
||||
4. **Agent + Executor Composition** - Combine AI agents with non-AI executor classes
|
||||
|
||||
## Workflow Architecture
|
||||
|
||||
```
|
||||
SpamDetectionAgent → [branch based on is_spam]:
|
||||
├── If spam: SpamHandlerExecutor → yield "Email marked as spam: {reason}"
|
||||
└── If not spam: EmailAssistantAgent → EmailSenderExecutor → yield "Email sent: {response}"
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `SpamDetectionAgent` | AI Agent | Analyzes emails for spam indicators |
|
||||
| `EmailAssistantAgent` | AI Agent | Drafts professional email responses |
|
||||
| `SpamHandlerExecutor` | Executor | Handles spam emails (non-AI) |
|
||||
| `EmailSenderExecutor` | Executor | Sends email responses (non-AI) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Azure OpenAI** - Endpoint and deployment configured
|
||||
2. **Azurite** - For local storage emulation
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy configuration files:
|
||||
```bash
|
||||
cp local.settings.json.sample local.settings.json
|
||||
```
|
||||
|
||||
2. Configure `local.settings.json`:
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Start Azurite:
|
||||
```bash
|
||||
azurite --silent
|
||||
```
|
||||
|
||||
5. Run the function app:
|
||||
```bash
|
||||
func start
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use the `demo.http` file with REST Client extension or curl:
|
||||
|
||||
### Test Spam Email
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflow/email_triage/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email_id": "test-001", "email_content": "URGENT! You have won $1,000,000! Click here!"}'
|
||||
```
|
||||
|
||||
### Test Legitimate Email
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflow/email_triage/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email_id": "test-002", "email_content": "Hi team, reminder about our meeting tomorrow at 10 AM."}'
|
||||
```
|
||||
|
||||
### Check Status
|
||||
```bash
|
||||
curl http://localhost:7071/api/workflow/email_triage/status/{instanceId}
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
**Spam email:**
|
||||
```
|
||||
Email marked as spam: This email exhibits spam characteristics including urgent language, unrealistic claims of monetary winnings, and requests to click suspicious links.
|
||||
```
|
||||
|
||||
**Legitimate email:**
|
||||
```
|
||||
Email sent: Hi, Thank you for the reminder about the sprint planning meeting tomorrow at 10 AM. I will be there.
|
||||
```
|
||||
|
||||
## Code Highlights
|
||||
|
||||
### Creating the Workflow
|
||||
|
||||
```python
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(spam_agent)
|
||||
.add_switch_case_edge_group(
|
||||
spam_agent,
|
||||
[
|
||||
Case(condition=is_spam_detected, target=spam_handler),
|
||||
Default(target=email_agent),
|
||||
],
|
||||
)
|
||||
.add_edge(email_agent, email_sender)
|
||||
.build()
|
||||
)
|
||||
```
|
||||
|
||||
### Registering with AgentFunctionApp
|
||||
|
||||
```python
|
||||
app = AgentFunctionApp(workflow=workflow, enable_health_check=True)
|
||||
```
|
||||
|
||||
### Executor Classes
|
||||
|
||||
```python
|
||||
class SpamHandlerExecutor(Executor):
|
||||
@handler
|
||||
async def handle_spam_result(
|
||||
self,
|
||||
agent_response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
spam_result = SpamDetectionResult.model_validate_json(agent_response.agent_run_response.text)
|
||||
await ctx.yield_output(f"Email marked as spam: {spam_result.reason}")
|
||||
```
|
||||
|
||||
## Standalone Mode (DevUI)
|
||||
|
||||
This sample also supports running standalone for local development:
|
||||
|
||||
```python
|
||||
# Change launch(durable=True) to launch(durable=False) in function_app.py
|
||||
# Then run:
|
||||
python function_app.py
|
||||
```
|
||||
|
||||
This starts the DevUI at `http://localhost:8094` for interactive testing.
|
||||
|
||||
## Related Samples
|
||||
|
||||
- `09_workflow_shared_state` - Workflow with SharedState for passing data between executors
|
||||
- `06_multi_agent_orchestration_conditionals` - Manual Durable Functions orchestration with agents
|
||||
@@ -0,0 +1,32 @@
|
||||
### Start Workflow Orchestration - Spam Email
|
||||
POST http://localhost:7071/api/workflow/email_triage/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email_id": "email-001",
|
||||
"email_content": "URGENT! You've won $1,000,000! Click here immediately to claim your prize! Limited time offer - act now!"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Start Workflow Orchestration - Legitimate Email
|
||||
POST http://localhost:7071/api/workflow/email_triage/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email_id": "email-002",
|
||||
"email_content": "Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. Please review the agenda in Jira."
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Get Workflow Status
|
||||
# Replace {instanceId} with the actual instance ID from the start response
|
||||
GET http://localhost:7071/api/workflow/email_triage/status/{instanceId}
|
||||
|
||||
###
|
||||
|
||||
### Health Check
|
||||
GET http://localhost:7071/api/health
|
||||
|
||||
###
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Workflow Execution within Durable Functions Orchestrator.
|
||||
|
||||
This sample demonstrates running agent framework WorkflowBuilder workflows inside
|
||||
a Durable Functions orchestrator by manually traversing the workflow graph and
|
||||
delegating execution to Durable Entities (for agents) and Activities (for other logic).
|
||||
|
||||
Key architectural points:
|
||||
- AgentFunctionApp registers agents as DurableAIAgents.
|
||||
- WorkflowBuilder uses `DurableAgentDefinition` (a placeholder) to define the graph.
|
||||
- The orchestrator (`workflow_orchestration`) iterates through the workflow graph.
|
||||
- When an agent node is encountered, it calls the corresponding `DurableAIAgent` entity.
|
||||
- When a standard executor node is encountered, it calls an Activity (`ExecuteExecutor`).
|
||||
|
||||
This approach allows using the rich structure of `WorkflowBuilder` while leveraging
|
||||
the statefulness and durability of `DurableAIAgent`s.
|
||||
|
||||
Prerequisites:
|
||||
- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL`
|
||||
- Sign in with Azure CLI (`az login`) for `AzureCliCredential`
|
||||
- Ensure Azurite and the Durable Task Scheduler emulator are running
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentExecutorResponse,
|
||||
Case,
|
||||
Default,
|
||||
Executor,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.openai import OpenAIChatOptions
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from typing_extensions import Never
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT"
|
||||
AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL"
|
||||
SPAM_AGENT_NAME = "SpamDetectionAgent"
|
||||
EMAIL_AGENT_NAME = "EmailAssistantAgent"
|
||||
|
||||
SPAM_DETECTION_INSTRUCTIONS = (
|
||||
"You are a spam detection assistant that identifies spam emails.\n\n"
|
||||
"Analyze the email content for spam indicators including:\n"
|
||||
"1. Suspicious language (urgent, limited time, act now, free money, etc.)\n"
|
||||
"2. Suspicious links or requests for personal information\n"
|
||||
"3. Poor grammar or spelling\n"
|
||||
"4. Requests for money or financial information\n"
|
||||
"5. Impersonation attempts\n\n"
|
||||
"Return a JSON response with:\n"
|
||||
"- is_spam: boolean indicating if it's spam\n"
|
||||
"- confidence: float between 0.0 and 1.0\n"
|
||||
"- reason: detailed explanation of your classification"
|
||||
)
|
||||
|
||||
EMAIL_ASSISTANT_INSTRUCTIONS = (
|
||||
"You are an email assistant that helps users draft responses to legitimate emails.\n\n"
|
||||
"When you receive an email that has been verified as legitimate:\n"
|
||||
"1. Draft a professional and appropriate response\n"
|
||||
"2. Match the tone and formality of the original email\n"
|
||||
"3. Be helpful and courteous\n"
|
||||
"4. Keep the response concise but complete\n\n"
|
||||
"Return a JSON response with:\n"
|
||||
"- response: the drafted email response"
|
||||
)
|
||||
|
||||
|
||||
class SpamDetectionResult(BaseModel):
|
||||
is_spam: bool
|
||||
confidence: float
|
||||
reason: str
|
||||
|
||||
|
||||
class EmailResponse(BaseModel):
|
||||
response: str
|
||||
|
||||
|
||||
class EmailPayload(BaseModel):
|
||||
email_id: str
|
||||
email_content: str
|
||||
|
||||
|
||||
def _build_client_kwargs() -> dict[str, Any]:
|
||||
"""Build Foundry chat client configuration from environment variables."""
|
||||
project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV)
|
||||
if not project_endpoint:
|
||||
raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.")
|
||||
|
||||
model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV)
|
||||
if not model:
|
||||
raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.")
|
||||
|
||||
return {
|
||||
"project_endpoint": project_endpoint,
|
||||
"model": model,
|
||||
"credential": AzureCliCredential(),
|
||||
}
|
||||
|
||||
|
||||
# Executors for non-AI activities (defined at module level)
|
||||
class SpamHandlerExecutor(Executor):
|
||||
"""Executor that handles spam emails (non-AI activity)."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_result(
|
||||
self,
|
||||
agent_response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Mark email as spam and log the reason."""
|
||||
text = agent_response.agent_response.text
|
||||
try:
|
||||
spam_result = SpamDetectionResult.model_validate_json(text)
|
||||
except ValidationError:
|
||||
spam_result = SpamDetectionResult(is_spam=True, reason="Invalid JSON from agent", confidence=0.0)
|
||||
|
||||
message = f"Email marked as spam: {spam_result.reason}"
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
class EmailSenderExecutor(Executor):
|
||||
"""Executor that sends email responses (non-AI activity)."""
|
||||
|
||||
@handler
|
||||
async def handle_email_response(
|
||||
self,
|
||||
agent_response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Send the drafted email response."""
|
||||
text = agent_response.agent_response.text
|
||||
try:
|
||||
email_response = EmailResponse.model_validate_json(text)
|
||||
except ValidationError:
|
||||
email_response = EmailResponse(response="Error generating response.")
|
||||
|
||||
message = f"Email sent: {email_response.response}"
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
# Condition function for routing
|
||||
def is_spam_detected(message: Any) -> bool:
|
||||
"""Check if spam was detected in the email."""
|
||||
if not isinstance(message, AgentExecutorResponse):
|
||||
return False
|
||||
try:
|
||||
result = SpamDetectionResult.model_validate_json(message.agent_response.text)
|
||||
return result.is_spam
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _create_workflow() -> Workflow:
|
||||
"""Create the workflow definition."""
|
||||
client_kwargs = _build_client_kwargs()
|
||||
chat_client = FoundryChatClient(**client_kwargs)
|
||||
|
||||
spam_agent = Agent(
|
||||
client=chat_client,
|
||||
name=SPAM_AGENT_NAME,
|
||||
instructions=SPAM_DETECTION_INSTRUCTIONS,
|
||||
default_options=OpenAIChatOptions[Any](response_format=SpamDetectionResult),
|
||||
)
|
||||
|
||||
email_agent = Agent(
|
||||
client=chat_client,
|
||||
name=EMAIL_AGENT_NAME,
|
||||
instructions=EMAIL_ASSISTANT_INSTRUCTIONS,
|
||||
default_options=OpenAIChatOptions[Any](response_format=EmailResponse),
|
||||
)
|
||||
|
||||
# Executors
|
||||
spam_handler = SpamHandlerExecutor(id="spam_handler")
|
||||
email_sender = EmailSenderExecutor(id="email_sender")
|
||||
|
||||
# Build workflow
|
||||
return (
|
||||
WorkflowBuilder(name="email_triage", start_executor=spam_agent)
|
||||
.add_switch_case_edge_group(
|
||||
spam_agent,
|
||||
[
|
||||
Case(condition=is_spam_detected, target=spam_handler),
|
||||
Default(target=email_agent),
|
||||
],
|
||||
)
|
||||
.add_edge(email_agent, email_sender)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def launch(durable: bool = True) -> AgentFunctionApp | None:
|
||||
workflow: Workflow | None = None
|
||||
|
||||
if durable:
|
||||
# Initialize app
|
||||
workflow = _create_workflow()
|
||||
return AgentFunctionApp(workflow=workflow)
|
||||
# Launch the spam detection workflow in DevUI
|
||||
from agent_framework.devui import serve
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
env_path = Path(__file__).parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
logger.info("Starting Multi-Agent Spam Detection Workflow")
|
||||
logger.info("Available at: http://localhost:8094")
|
||||
logger.info("\nThis workflow demonstrates:")
|
||||
logger.info("- Conditional routing based on spam detection")
|
||||
logger.info("- Mixing AI agents with non-AI executors (like activity functions)")
|
||||
logger.info("- Path 1 (spam): SpamDetector Agent → SpamHandler Executor")
|
||||
logger.info("- Path 2 (legitimate): SpamDetector Agent → EmailAssistant Agent → EmailSender Executor")
|
||||
|
||||
workflow = _create_workflow()
|
||||
serve(entities=[workflow], port=8094, auto_open=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Default: Azure Functions mode
|
||||
# Run with `python function_app.py --maf` for pure MAF mode with DevUI
|
||||
app = launch(durable=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if "--maf" in sys.argv:
|
||||
# Run in pure MAF mode with DevUI
|
||||
launch(durable=False)
|
||||
else:
|
||||
print("Usage: python function_app.py --maf")
|
||||
print(" --maf Run in pure MAF mode with DevUI (http://localhost:8094)")
|
||||
print("\nFor Azure Functions mode, use: func start")
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
@@ -0,0 +1,13 @@
|
||||
# Azure Functions Runtime Configuration
|
||||
FUNCTIONS_WORKER_RUNTIME=python
|
||||
AzureWebJobsStorage=UseDevelopmentStorage=true
|
||||
|
||||
# Durable Task Scheduler Configuration
|
||||
# For local development with DTS emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None
|
||||
# For Azure: Get connection string from Azure portal
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=http://localhost:8080;TaskHub=default;Authentication=None
|
||||
TASKHUB_NAME=default
|
||||
|
||||
# Azure OpenAI Configuration
|
||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
||||
AZURE_OPENAI_MODEL=your-deployment-name
|
||||
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
local.settings.json
|
||||
.env
|
||||
@@ -0,0 +1,194 @@
|
||||
# Parallel Workflow Execution Sample
|
||||
|
||||
This sample demonstrates **parallel execution** of executors and agents in Azure Durable Functions workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
This sample showcases three different parallel execution patterns:
|
||||
|
||||
1. **Two Executors in Parallel** - Fan-out to multiple activities
|
||||
2. **Two Agents in Parallel** - Fan-out to multiple entities
|
||||
3. **Mixed Execution** - Agents and executors can run concurrently
|
||||
|
||||
## Workflow Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ PARALLEL WORKFLOW │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Pattern 1: Two Executors in Parallel (Activities) │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ input_router ──┬──> [word_count_processor] ────┐ │
|
||||
│ │ │ │
|
||||
│ └──> [format_analyzer_processor]┴──> [aggregator] │
|
||||
│ │
|
||||
│ Pattern 2: Two Agents in Parallel (Entities) │
|
||||
│ ───────────────────────────────────────────── │
|
||||
│ │
|
||||
│ [prepare_for_agents] ──┬──> [SentimentAgent] ──────┐ │
|
||||
│ │ │ │
|
||||
│ └──> [KeywordAgent] ────────┴──> [prepare_for_│
|
||||
│ mixed] │
|
||||
│ │
|
||||
│ Pattern 3: Mixed Agent + Executor in Parallel │
|
||||
│ ──────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ [prepare_for_mixed] ──┬──> [SummaryAgent] ─────────┐ │
|
||||
│ │ │ │
|
||||
│ └──> [statistics_processor] ─┴──> [final_report│
|
||||
│ _executor] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## How Parallel Execution Works
|
||||
|
||||
### Activities (Executors)
|
||||
When multiple executors are pending in the same iteration (e.g., after a fan-out edge), they are batched and executed using `task_all()`:
|
||||
|
||||
```python
|
||||
# In _workflow.py - activities execute in parallel
|
||||
activity_tasks = [context.call_activity("ExecuteExecutor", input) for ...]
|
||||
results = yield context.task_all(activity_tasks) # All run concurrently!
|
||||
```
|
||||
|
||||
### Agents (Entities)
|
||||
Different agents can also run in parallel when they're pending in the same iteration:
|
||||
|
||||
```python
|
||||
# Different agents run in parallel
|
||||
agent_tasks = [agent_a.run(...), agent_b.run(...)]
|
||||
responses = yield context.task_all(agent_tasks) # Both agents run concurrently!
|
||||
```
|
||||
|
||||
**Note:** Multiple messages to the *same* agent are processed sequentially to maintain conversation coherence.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `input_router` | Executor | Routes input JSON to parallel processors |
|
||||
| `word_count_processor` | Executor | Counts words and characters |
|
||||
| `format_analyzer_processor` | Executor | Analyzes document format |
|
||||
| `aggregator` | Executor | Combines results from parallel processors |
|
||||
| `prepare_for_agents` | Executor | Prepares content for agent analysis |
|
||||
| `SentimentAnalysisAgent` | AI Agent | Analyzes text sentiment |
|
||||
| `KeywordExtractionAgent` | AI Agent | Extracts keywords and categories |
|
||||
| `prepare_for_mixed` | Executor | Prepares content for mixed parallel execution |
|
||||
| `SummaryAgent` | AI Agent | Summarizes the document |
|
||||
| `statistics_processor` | Executor | Computes document statistics |
|
||||
| `FinalReportExecutor` | Executor | Compiles final report from all analyses |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Azure OpenAI** - Endpoint and deployment configured
|
||||
2. **DTS Emulator** - For durable task scheduling (recommended)
|
||||
3. **Azurite** - For Azure Functions internal storage
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1: DevUI Mode (Local Development - No Durable Functions)
|
||||
|
||||
The sample can run locally without Azure Functions infrastructure using DevUI:
|
||||
|
||||
1. Copy the environment template:
|
||||
```bash
|
||||
cp .env.template .env
|
||||
```
|
||||
|
||||
2. Configure `.env` with your Azure OpenAI credentials (`AZURE_OPENAI_ENDPOINT` and
|
||||
`AZURE_OPENAI_MODEL`)
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Run in DevUI mode (set `durable=False` in `function_app.py`):
|
||||
```bash
|
||||
python function_app.py
|
||||
```
|
||||
|
||||
5. Open `http://localhost:8095` and provide input:
|
||||
```json
|
||||
{
|
||||
"document_id": "doc-001",
|
||||
"content": "Your document text here..."
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Durable Functions Mode (Full Azure Functions)
|
||||
|
||||
1. Copy configuration files:
|
||||
```bash
|
||||
cp .env.template .env
|
||||
cp local.settings.json.sample local.settings.json
|
||||
```
|
||||
|
||||
2. Configure `local.settings.json` with your Azure OpenAI credentials
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Start DTS Emulator:
|
||||
```bash
|
||||
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
|
||||
```
|
||||
|
||||
5. Start Azurite (or use VS Code extension):
|
||||
```bash
|
||||
azurite --silent
|
||||
```
|
||||
|
||||
6. Run the function app (ensure `durable=True` in `function_app.py`):
|
||||
```bash
|
||||
func start
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use the `demo.http` file with REST Client extension or curl:
|
||||
|
||||
### Analyze a Document
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/workflow/parallel_review/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"document_id": "doc-001",
|
||||
"content": "The quarterly earnings report shows strong growth in cloud services. Revenue increased by 25%."
|
||||
}'
|
||||
```
|
||||
|
||||
### Check Status
|
||||
```bash
|
||||
curl http://localhost:7071/api/workflow/parallel_review/status/{instanceId}
|
||||
```
|
||||
|
||||
## Observing Parallel Execution
|
||||
|
||||
Open the DTS Dashboard at `http://localhost:8082` to observe:
|
||||
|
||||
1. **Activity Execution Timeline** - You'll see `word_count_processor` and `format_analyzer_processor` starting at approximately the same time
|
||||
2. **Agent Execution Timeline** - `SentimentAnalysisAgent` and `KeywordExtractionAgent` also start concurrently
|
||||
3. **Sequential vs Parallel** - Compare with non-parallel samples to see the time savings
|
||||
|
||||
## Expected Output
|
||||
|
||||
```json
|
||||
{
|
||||
"output": [
|
||||
"=== Document Analysis Report ===\n\n--- SentimentAnalysisAgent ---\n{\"sentiment\": \"positive\", \"confidence\": 0.85, \"explanation\": \"...\"}\n\n--- KeywordExtractionAgent ---\n{\"keywords\": [\"earnings\", \"growth\", \"cloud\"], \"categories\": [\"finance\", \"technology\"]}"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Parallel execution is automatic** - When multiple executors/agents are pending in the same iteration, they run in parallel
|
||||
2. **Workflow graph determines parallelism** - Fan-out edges create parallel execution opportunities
|
||||
3. **Mixed parallelism** - Agents and executors can run concurrently if they're in the same iteration
|
||||
4. **Same-agent messages are sequential** - To maintain conversation coherence
|
||||
@@ -0,0 +1,29 @@
|
||||
### Analyze a document (triggers parallel workflow)
|
||||
POST http://localhost:7071/api/workflow/parallel_review/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"document_id": "doc-001",
|
||||
"content": "The quarterly earnings report shows strong growth in our cloud services division. Revenue increased by 25% compared to last year, driven by enterprise adoption. Customer satisfaction remains high at 92%. However, we face challenges in the mobile segment where competition is intense. Overall, the outlook is positive with expected continued growth in the coming quarters."
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Short document test
|
||||
POST http://localhost:7071/api/workflow/parallel_review/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"document_id": "doc-002",
|
||||
"content": "Quick update: Project completed successfully. Team performance exceeded expectations."
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
### Check workflow status
|
||||
GET http://localhost:7071/api/workflow/parallel_review/status/{{instanceId}}
|
||||
|
||||
###
|
||||
|
||||
### Health check
|
||||
GET http://localhost:7071/api/health
|
||||
@@ -0,0 +1,491 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Parallel Workflow Execution Sample.
|
||||
|
||||
This sample demonstrates parallel execution of executors and agents in Azure Durable Functions.
|
||||
It showcases three different parallel execution patterns:
|
||||
|
||||
1. Two executors running concurrently (fan-out to activities)
|
||||
2. Two agents running concurrently (fan-out to entities)
|
||||
3. One executor and one agent running concurrently (mixed fan-out)
|
||||
|
||||
The workflow simulates a document processing pipeline where:
|
||||
- A document is analyzed by multiple processors in parallel
|
||||
- Results are aggregated and then processed by agents
|
||||
- A summary agent and statistics executor run in parallel
|
||||
- Finally, combined into a single output
|
||||
|
||||
Key architectural points:
|
||||
- FanOut edges enable parallel execution
|
||||
- Different agents run in parallel when they're in the same iteration
|
||||
- Activities (executors) also run in parallel when pending together
|
||||
- Mixed agent/executor fan-outs execute concurrently
|
||||
|
||||
Prerequisites:
|
||||
- Configure `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_MODEL`
|
||||
- Sign in with Azure CLI (`az login`) for `AzureCliCredential`
|
||||
- Ensure Azurite and the Durable Task Scheduler emulator are running
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentExecutorResponse,
|
||||
Executor,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.azure import AgentFunctionApp
|
||||
from agent_framework.openai import OpenAIChatCompletionClient, OpenAIChatCompletionOptions
|
||||
from azure.identity.aio import AzureCliCredential, get_bearer_token_provider
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import Never
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Agent names
|
||||
SENTIMENT_AGENT_NAME = "SentimentAnalysisAgent"
|
||||
KEYWORD_AGENT_NAME = "KeywordExtractionAgent"
|
||||
SUMMARY_AGENT_NAME = "SummaryAgent"
|
||||
RECOMMENDATION_AGENT_NAME = "RecommendationAgent"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pydantic Models for structured outputs
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SentimentResult(BaseModel):
|
||||
"""Result from sentiment analysis."""
|
||||
|
||||
sentiment: str # positive, negative, neutral
|
||||
confidence: float
|
||||
explanation: str
|
||||
|
||||
|
||||
class KeywordResult(BaseModel):
|
||||
"""Result from keyword extraction."""
|
||||
|
||||
keywords: list[str]
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class SummaryResult(BaseModel):
|
||||
"""Result from summarization."""
|
||||
|
||||
summary: str
|
||||
key_points: list[str]
|
||||
|
||||
|
||||
class RecommendationResult(BaseModel):
|
||||
"""Result from recommendation engine."""
|
||||
|
||||
recommendations: list[str]
|
||||
priority: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentInput:
|
||||
"""Input document to be processed."""
|
||||
|
||||
document_id: str
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessorResult:
|
||||
"""Result from a document processor (executor)."""
|
||||
|
||||
processor_name: str
|
||||
document_id: str
|
||||
content: str
|
||||
word_count: int
|
||||
char_count: int
|
||||
has_numbers: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class AggregatedResults:
|
||||
"""Aggregated results from parallel processors."""
|
||||
|
||||
document_id: str
|
||||
content: str
|
||||
processor_results: list[ProcessorResult]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentAnalysis:
|
||||
"""Analysis result from an agent."""
|
||||
|
||||
agent_name: str
|
||||
result: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FinalReport:
|
||||
"""Final combined report."""
|
||||
|
||||
document_id: str
|
||||
analyses: list[AgentAnalysis]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Executor Definitions (Activities - run in parallel when pending together)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@executor(id="input_router")
|
||||
async def input_router(document: DocumentInput, ctx: WorkflowContext[DocumentInput]) -> None:
|
||||
"""Route the input document to the parallel processors.
|
||||
|
||||
The durable engine reconstructs ``DocumentInput`` from the client's JSON
|
||||
payload before delivery, mirroring in-process execution.
|
||||
"""
|
||||
logger.info("[input_router] Routing document: %s", document.document_id)
|
||||
await ctx.send_message(document)
|
||||
|
||||
|
||||
@executor(id="word_count_processor")
|
||||
async def word_count_processor(doc: DocumentInput, ctx: WorkflowContext[ProcessorResult]) -> None:
|
||||
"""Process document and count words - runs as an activity."""
|
||||
logger.info("[word_count_processor] Processing document: %s", doc.document_id)
|
||||
|
||||
word_count = len(doc.content.split())
|
||||
char_count = len(doc.content)
|
||||
has_numbers = any(c.isdigit() for c in doc.content)
|
||||
|
||||
result = ProcessorResult(
|
||||
processor_name="word_count",
|
||||
document_id=doc.document_id,
|
||||
content=doc.content,
|
||||
word_count=word_count,
|
||||
char_count=char_count,
|
||||
has_numbers=has_numbers,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
@executor(id="format_analyzer_processor")
|
||||
async def format_analyzer_processor(doc: DocumentInput, ctx: WorkflowContext[ProcessorResult]) -> None:
|
||||
"""Analyze document format - runs as an activity in parallel with word_count."""
|
||||
logger.info("[format_analyzer_processor] Processing document: %s", doc.document_id)
|
||||
|
||||
# Simple format analysis
|
||||
lines = doc.content.split("\n")
|
||||
word_count = len(lines) # Using line count as "word count" for this processor
|
||||
char_count = sum(len(line) for line in lines)
|
||||
has_numbers = doc.content.count(".") > 0 # Check for sentences
|
||||
|
||||
result = ProcessorResult(
|
||||
processor_name="format_analyzer",
|
||||
document_id=doc.document_id,
|
||||
content=doc.content,
|
||||
word_count=word_count,
|
||||
char_count=char_count,
|
||||
has_numbers=has_numbers,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
@executor(id="aggregator")
|
||||
async def aggregator(results: list[ProcessorResult], ctx: WorkflowContext[AggregatedResults]) -> None:
|
||||
"""Aggregate results from parallel processors - receives fan-in input."""
|
||||
logger.info("[aggregator] Aggregating %d results", len(results))
|
||||
|
||||
# Extract document info from the first result (all have the same content)
|
||||
document_id = results[0].document_id if results else "unknown"
|
||||
content = results[0].content if results else ""
|
||||
|
||||
aggregated = AggregatedResults(
|
||||
document_id=document_id,
|
||||
content=content,
|
||||
processor_results=results,
|
||||
)
|
||||
|
||||
await ctx.send_message(aggregated)
|
||||
|
||||
|
||||
@executor(id="prepare_for_agents")
|
||||
async def prepare_for_agents(aggregated: AggregatedResults, ctx: WorkflowContext[str]) -> None:
|
||||
"""Prepare content for agent analysis - broadcasts to multiple agents."""
|
||||
logger.info("[prepare_for_agents] Preparing content for agents")
|
||||
|
||||
# Send the original content to agents for analysis
|
||||
await ctx.send_message(aggregated.content)
|
||||
|
||||
|
||||
@executor(id="prepare_for_mixed")
|
||||
async def prepare_for_mixed(analyses: list[AgentExecutorResponse], ctx: WorkflowContext[str]) -> None:
|
||||
"""Prepare results for mixed agent+executor parallel processing.
|
||||
|
||||
Combines agent analysis results into a string that can be consumed by
|
||||
both the SummaryAgent and the statistics_processor in parallel.
|
||||
"""
|
||||
logger.info("[prepare_for_mixed] Preparing for mixed parallel pattern")
|
||||
|
||||
sentiment_text = ""
|
||||
keyword_text = ""
|
||||
|
||||
for analysis in analyses:
|
||||
executor_id = analysis.executor_id
|
||||
text = analysis.agent_response.text if analysis.agent_response else ""
|
||||
|
||||
if executor_id == SENTIMENT_AGENT_NAME:
|
||||
sentiment_text = text
|
||||
elif executor_id == KEYWORD_AGENT_NAME:
|
||||
keyword_text = text
|
||||
|
||||
# Combine into a string that both agent and executor can process
|
||||
combined = f"Sentiment Analysis: {sentiment_text}\n\nKeyword Extraction: {keyword_text}"
|
||||
await ctx.send_message(combined)
|
||||
|
||||
|
||||
@executor(id="statistics_processor")
|
||||
async def statistics_processor(analysis_text: str, ctx: WorkflowContext[ProcessorResult]) -> None:
|
||||
"""Calculate statistics from the analysis - runs in parallel with SummaryAgent."""
|
||||
logger.info("[statistics_processor] Calculating statistics")
|
||||
|
||||
# Calculate some statistics from the combined analysis
|
||||
word_count = len(analysis_text.split())
|
||||
char_count = len(analysis_text)
|
||||
has_numbers = any(c.isdigit() for c in analysis_text)
|
||||
|
||||
result = ProcessorResult(
|
||||
processor_name="statistics",
|
||||
document_id="analysis",
|
||||
content=analysis_text,
|
||||
word_count=word_count,
|
||||
char_count=char_count,
|
||||
has_numbers=has_numbers,
|
||||
)
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class FinalReportExecutor(Executor):
|
||||
"""Executor that compiles the final report from agent analyses."""
|
||||
|
||||
@handler
|
||||
async def compile_report(
|
||||
self,
|
||||
analyses: list[AgentExecutorResponse | ProcessorResult],
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Compile final report from mixed agent + processor results."""
|
||||
logger.info("[final_report] Compiling report from %d analyses", len(analyses))
|
||||
|
||||
report_parts = ["=== Document Analysis Report ===\n"]
|
||||
|
||||
for analysis in analyses:
|
||||
if isinstance(analysis, AgentExecutorResponse):
|
||||
agent_name = analysis.executor_id
|
||||
text = analysis.agent_response.text if analysis.agent_response else "No response"
|
||||
elif isinstance(analysis, ProcessorResult):
|
||||
agent_name = f"Processor: {analysis.processor_name}"
|
||||
text = f"Words: {analysis.word_count}, Chars: {analysis.char_count}"
|
||||
else:
|
||||
continue
|
||||
|
||||
report_parts.append(f"\n--- {agent_name} ---")
|
||||
report_parts.append(text)
|
||||
|
||||
final_report = "\n".join(report_parts)
|
||||
await ctx.yield_output(final_report)
|
||||
|
||||
|
||||
class MixedResultCollector(Executor):
|
||||
"""Collector for mixed agent/executor results."""
|
||||
|
||||
@handler
|
||||
async def collect_mixed_results(
|
||||
self,
|
||||
results: list[Any],
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Collect and format results from mixed parallel execution."""
|
||||
logger.info("[mixed_collector] Collecting %d mixed results", len(results))
|
||||
|
||||
output_parts = ["=== Mixed Parallel Execution Results ===\n"]
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, AgentExecutorResponse):
|
||||
output_parts.append(f"[Agent: {result.executor_id}]")
|
||||
output_parts.append(result.agent_response.text if result.agent_response else "No response")
|
||||
elif isinstance(result, ProcessorResult):
|
||||
output_parts.append(f"[Processor: {result.processor_name}]")
|
||||
output_parts.append(f" Words: {result.word_count}, Chars: {result.char_count}")
|
||||
|
||||
await ctx.yield_output("\n".join(output_parts))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Workflow Construction
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _create_workflow() -> Workflow:
|
||||
"""Create the parallel workflow definition.
|
||||
|
||||
Workflow structure demonstrating three parallel patterns:
|
||||
|
||||
Pattern 1: Two Executors in Parallel (Fan-out/Fan-in to activities)
|
||||
────────────────────────────────────────────────────────────────────
|
||||
┌─> word_count_processor ─────┐
|
||||
input_router ──┤ ├──> aggregator
|
||||
└─> format_analyzer_processor ─┘
|
||||
|
||||
Pattern 2: Two Agents in Parallel (Fan-out to entities)
|
||||
────────────────────────────────────────────────────────
|
||||
prepare_for_agents ─┬─> SentimentAgent ──┐
|
||||
└─> KeywordAgent ────┤
|
||||
└──> prepare_for_mixed
|
||||
|
||||
Pattern 3: Mixed Agent + Executor in Parallel
|
||||
──────────────────────────────────────────────
|
||||
prepare_for_mixed ─┬─> SummaryAgent ────────┐
|
||||
└─> statistics_processor ─┤
|
||||
└──> final_report
|
||||
"""
|
||||
credential = AzureCliCredential()
|
||||
|
||||
chat_client = OpenAIChatCompletionClient(
|
||||
model=os.environ["AZURE_OPENAI_MODEL"],
|
||||
credential=get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default"),
|
||||
)
|
||||
|
||||
# Create agents for parallel analysis
|
||||
sentiment_agent = Agent(
|
||||
client=chat_client,
|
||||
name=SENTIMENT_AGENT_NAME,
|
||||
instructions=(
|
||||
"You are a sentiment analysis expert. Analyze the sentiment of the given text. "
|
||||
"Return JSON with fields: sentiment (positive/negative/neutral), "
|
||||
"confidence (0.0-1.0), and explanation (brief reasoning)."
|
||||
),
|
||||
default_options=OpenAIChatCompletionOptions[Any](response_format=SentimentResult),
|
||||
)
|
||||
|
||||
keyword_agent = Agent(
|
||||
client=chat_client,
|
||||
name=KEYWORD_AGENT_NAME,
|
||||
instructions=(
|
||||
"You are a keyword extraction expert. Extract important keywords and categories "
|
||||
"from the given text. Return JSON with fields: keywords (list of strings), "
|
||||
"and categories (list of topic categories)."
|
||||
),
|
||||
default_options=OpenAIChatCompletionOptions[Any](response_format=KeywordResult),
|
||||
)
|
||||
|
||||
# Create summary agent for Pattern 3 (mixed parallel)
|
||||
summary_agent = Agent(
|
||||
client=chat_client,
|
||||
name=SUMMARY_AGENT_NAME,
|
||||
instructions=(
|
||||
"You are a summarization expert. Given analysis results (sentiment and keywords), "
|
||||
"provide a concise summary. Return JSON with fields: summary (brief text), "
|
||||
"and key_points (list of main takeaways)."
|
||||
),
|
||||
default_options=OpenAIChatCompletionOptions[Any](response_format=SummaryResult),
|
||||
)
|
||||
|
||||
# Create executor instances
|
||||
final_report_executor = FinalReportExecutor(id="final_report")
|
||||
|
||||
# Build workflow with parallel patterns
|
||||
return (
|
||||
WorkflowBuilder(name="parallel_review", start_executor=input_router)
|
||||
# Pattern 1: Fan-out to two executors (run in parallel)
|
||||
.add_fan_out_edges(
|
||||
source=input_router,
|
||||
targets=[word_count_processor, format_analyzer_processor],
|
||||
)
|
||||
# Fan-in: Both processors send results to aggregator
|
||||
.add_fan_in_edges(
|
||||
sources=[word_count_processor, format_analyzer_processor],
|
||||
target=aggregator,
|
||||
)
|
||||
# Prepare content for agent analysis
|
||||
.add_edge(aggregator, prepare_for_agents)
|
||||
# Pattern 2: Fan-out to two agents (run in parallel)
|
||||
.add_fan_out_edges(
|
||||
source=prepare_for_agents,
|
||||
targets=[sentiment_agent, keyword_agent],
|
||||
)
|
||||
# Fan-in: Collect agent results into prepare_for_mixed
|
||||
.add_fan_in_edges(
|
||||
sources=[sentiment_agent, keyword_agent],
|
||||
target=prepare_for_mixed,
|
||||
)
|
||||
# Pattern 3: Fan-out to one agent + one executor (mixed parallel)
|
||||
.add_fan_out_edges(
|
||||
source=prepare_for_mixed,
|
||||
targets=[summary_agent, statistics_processor],
|
||||
)
|
||||
# Final fan-in: Collect mixed results
|
||||
.add_fan_in_edges(
|
||||
sources=[summary_agent, statistics_processor],
|
||||
target=final_report_executor,
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Application Entry Point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def launch(durable: bool = True) -> AgentFunctionApp | None:
|
||||
"""Launch the function app or DevUI."""
|
||||
workflow: Workflow | None = None
|
||||
|
||||
if durable:
|
||||
workflow = _create_workflow()
|
||||
return AgentFunctionApp(
|
||||
workflow=workflow,
|
||||
enable_health_check=True,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.devui import serve
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_path = Path(__file__).parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
logger.info("Starting Parallel Workflow Sample")
|
||||
logger.info("Available at: http://localhost:8095")
|
||||
logger.info("\nThis workflow demonstrates:")
|
||||
logger.info("- Pattern 1: Two executors running in parallel")
|
||||
logger.info("- Pattern 2: Two agents running in parallel")
|
||||
logger.info("- Pattern 3: Mixed agent + executor running in parallel")
|
||||
logger.info("- Fan-in aggregation of parallel results")
|
||||
|
||||
workflow = _create_workflow()
|
||||
serve(entities=[workflow], port=8095, auto_open=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Default: Azure Functions mode
|
||||
# Run with `python function_app.py --maf` for pure MAF mode with DevUI
|
||||
app = launch(durable=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if "--maf" in sys.argv:
|
||||
# Run in pure MAF mode with DevUI
|
||||
launch(durable=False)
|
||||
else:
|
||||
print("Usage: python function_app.py --maf")
|
||||
print(" --maf Run in pure MAF mode with DevUI (http://localhost:8095)")
|
||||
print("\nFor Azure Functions mode, use: func start")
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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_MODEL": "<AZURE_OPENAI_MODEL>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-openai
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/openai # OpenAI support - dependency for Azure OpenAI chat samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
@@ -0,0 +1,5 @@
|
||||
# Local settings - copy from local.settings.json.sample and fill in your values
|
||||
local.settings.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -0,0 +1,145 @@
|
||||
# 12. Workflow with Human-in-the-Loop (HITL)
|
||||
|
||||
This sample demonstrates how to integrate human approval into a MAF workflow running on Azure Durable Functions using the MAF `request_info` and `@response_handler` pattern.
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements a content moderation pipeline:
|
||||
|
||||
1. **User starts workflow** with content for publication via HTTP endpoint
|
||||
2. **AI Agent analyzes** the content for policy compliance
|
||||
3. **Workflow pauses** and requests human reviewer approval
|
||||
4. **Human responds** via HTTP endpoint with approval/rejection
|
||||
5. **Workflow resumes** and publishes or rejects the content
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### MAF HITL Pattern
|
||||
|
||||
This sample uses MAF's built-in human-in-the-loop pattern:
|
||||
|
||||
```python
|
||||
# In an executor, request human input
|
||||
await ctx.request_info(
|
||||
request_data=HumanApprovalRequest(...),
|
||||
response_type=HumanApprovalResponse,
|
||||
)
|
||||
|
||||
# Handle the response in a separate method
|
||||
@response_handler
|
||||
async def handle_approval_response(
|
||||
self,
|
||||
original_request: HumanApprovalRequest,
|
||||
response: HumanApprovalResponse,
|
||||
ctx: WorkflowContext,
|
||||
) -> None:
|
||||
# Process the human's decision
|
||||
...
|
||||
```
|
||||
|
||||
### Automatic HITL Endpoints
|
||||
|
||||
`AgentFunctionApp` automatically provides all the HTTP endpoints needed for HITL:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `POST /api/workflow/content_moderation/run` | Start the workflow |
|
||||
| `GET /api/workflow/content_moderation/status/{instanceId}` | Check status and pending HITL requests |
|
||||
| `POST /api/workflow/content_moderation/respond/{instanceId}/{requestId}` | Send human response |
|
||||
| `GET /api/health` | Health check |
|
||||
|
||||
These routes expose workflow status and human-response operations. In production, put them behind your application's authentication and authorization layer and verify that the caller is allowed to inspect or resume the targeted workflow before returning status or accepting a response.
|
||||
|
||||
Treat `instanceId` and `requestId` as correlation handles only. They help locate workflow state, but they are not secrets or proof that a caller is authorized to act on that workflow.
|
||||
|
||||
### Durable Functions Integration
|
||||
|
||||
When running on Durable Functions, the HITL pattern maps to:
|
||||
|
||||
| MAF Concept | Durable Functions |
|
||||
|-------------|-------------------|
|
||||
| `ctx.request_info()` | Workflow pauses, custom status updated |
|
||||
| `RequestInfoEvent` | Exposed via status endpoint |
|
||||
| HTTP response | `client.raise_event(instance_id, request_id, data)` |
|
||||
| `@response_handler` | Workflow resumes, handler invoked |
|
||||
|
||||
## Workflow Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────────┐
|
||||
│ Input Router │ ──► │ Content Analyzer │ ──► │ Content Analyzer │
|
||||
│ Executor │ │ Agent (AI) │ │ Executor (Parse JSON) │
|
||||
└─────────────────┘ └──────────────────────┘ └────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌──────────────────────┐
|
||||
│ Publish │ ◄── │ Human Review │ ◄── HITL PAUSE
|
||||
│ Executor │ │ Executor │ (wait for external event)
|
||||
└─────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Azure OpenAI** - Access to Azure OpenAI with a deployed chat model
|
||||
2. **Durable Task Scheduler** - Local emulator or Azure deployment
|
||||
3. **Azurite** - Local Azure Storage emulator
|
||||
4. **Azure CLI** - For authentication (`az login`)
|
||||
|
||||
## Setup
|
||||
|
||||
1. Copy the sample settings file:
|
||||
```bash
|
||||
cp local.settings.json.sample local.settings.json
|
||||
```
|
||||
|
||||
2. Update `local.settings.json` with your Foundry project settings:
|
||||
```json
|
||||
{
|
||||
"Values": {
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "https://your-project.services.ai.azure.com/api/projects/your-project",
|
||||
"FOUNDRY_MODEL": "gpt-4o"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Start the local emulators:
|
||||
```bash
|
||||
# Terminal 1: Start Azurite
|
||||
azurite --silent --location .
|
||||
|
||||
# Terminal 2: Start Durable Task Scheduler (if using local emulator)
|
||||
# Follow Durable Task Scheduler setup instructions
|
||||
```
|
||||
|
||||
4. Start the Function App:
|
||||
```bash
|
||||
func start
|
||||
```
|
||||
|
||||
## Running in Pure MAF Mode
|
||||
|
||||
You can also run this sample in pure MAF mode (without Durable Functions) using the DevUI:
|
||||
|
||||
```bash
|
||||
python function_app.py --maf
|
||||
```
|
||||
|
||||
This launches the DevUI at http://localhost:8096 where you can interact with the workflow directly. This is useful for:
|
||||
- Local development and debugging
|
||||
- Testing the HITL pattern without Durable Functions infrastructure
|
||||
- Comparing behavior between MAF and Durable modes
|
||||
|
||||
## Testing
|
||||
|
||||
Use the `demo.http` file with the VS Code REST Client extension:
|
||||
|
||||
1. **Start workflow** - `POST /api/workflow/content_moderation/run` with content payload
|
||||
2. **Check status** - `GET /api/workflow/content_moderation/status/{instanceId}` to see pending HITL requests
|
||||
3. **Send response** - `POST /api/workflow/content_moderation/respond/{instanceId}/{requestId}` with approval
|
||||
4. **Check result** - `GET /api/workflow/content_moderation/status/{instanceId}` to see final output
|
||||
|
||||
## Related Samples
|
||||
|
||||
- [07_single_agent_orchestration_hitl](../07_single_agent_orchestration_hitl/) - HITL at orchestrator level (not using MAF pattern)
|
||||
- [09_workflow_shared_state](../09_workflow_shared_state/) - Workflow with shared state
|
||||
- [guessing_game_with_human_input](../../../03-workflows/human-in-the-loop/guessing_game_with_human_input.py) - MAF HITL pattern (non-durable)
|
||||
@@ -0,0 +1,123 @@
|
||||
### ============================================================================
|
||||
### Workflow HITL Sample - Content Moderation with Human Approval
|
||||
### ============================================================================
|
||||
### This sample demonstrates MAF workflows with human-in-the-loop using the
|
||||
### request_info / @response_handler pattern on Azure Durable Functions.
|
||||
###
|
||||
### The AgentFunctionApp automatically provides all HITL endpoints.
|
||||
###
|
||||
### Prerequisites:
|
||||
### 1. Start Azurite: azurite --silent --location .
|
||||
### 2. Start Durable Task Scheduler emulator
|
||||
### 3. Configure local.settings.json with Azure OpenAI credentials
|
||||
### 4. Run: func start
|
||||
### ============================================================================
|
||||
|
||||
|
||||
### ============================================================================
|
||||
### 1. Start the Workflow with Content for Moderation
|
||||
### ============================================================================
|
||||
### This starts the workflow. The AI will analyze the content, then the workflow
|
||||
### will pause waiting for human approval.
|
||||
|
||||
POST http://localhost:7071/api/workflow/content_moderation/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"content_id": "article-001",
|
||||
"title": "Introduction to AI in Healthcare",
|
||||
"body": "Artificial intelligence is revolutionizing healthcare by enabling faster diagnosis, personalized treatment plans, and improved patient outcomes. Machine learning algorithms can analyze medical images with remarkable accuracy, often detecting issues that human radiologists might miss.",
|
||||
"author": "Dr. Jane Smith"
|
||||
}
|
||||
|
||||
|
||||
### ============================================================================
|
||||
### 2. Start Workflow with Potentially Problematic Content
|
||||
### ============================================================================
|
||||
### This content should trigger higher risk assessment from the AI analyzer.
|
||||
|
||||
POST http://localhost:7071/api/workflow/content_moderation/run
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"content_id": "article-002",
|
||||
"title": "Get Rich Quick Scheme",
|
||||
"body": "Click here NOW to make $10,000 overnight! This SECRET method is GUARANTEED to work! Limited time offer - act NOW before it's too late! Send your bank details immediately!",
|
||||
"author": "Definitely Not Spam"
|
||||
}
|
||||
|
||||
|
||||
### ============================================================================
|
||||
### 3. Check Workflow Status
|
||||
### ============================================================================
|
||||
### Replace INSTANCE_ID with the value returned from the run call.
|
||||
### The status will show pending HITL requests if waiting for human approval.
|
||||
|
||||
@instanceId = 3130c486c9374e4e87125cbd9a238dfc
|
||||
|
||||
GET http://localhost:7071/api/workflow/content_moderation/status/{{instanceId}}
|
||||
|
||||
|
||||
### ============================================================================
|
||||
### 4. Send Human Approval
|
||||
### ============================================================================
|
||||
### Approve the content for publication.
|
||||
### Replace INSTANCE_ID and REQUEST_ID with values from the status response.
|
||||
|
||||
@requestId = 1682e5f8-0917-4b68-aa04-d4688cfa2e69
|
||||
|
||||
POST http://localhost:7071/api/workflow/content_moderation/respond/{{instanceId}}/{{requestId}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"approved": true,
|
||||
"reviewer_notes": "Content is appropriate and well-written. Approved for publication."
|
||||
}
|
||||
|
||||
|
||||
### ============================================================================
|
||||
### 5. Send Human Rejection
|
||||
### ============================================================================
|
||||
### Reject the content with feedback.
|
||||
|
||||
POST http://localhost:7071/api/workflow/content_moderation/respond/{{instanceId}}/{{requestId}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"approved": false,
|
||||
"reviewer_notes": "Content appears to be spam. Contains multiple spam indicators including urgency language, promises of easy money, and requests for personal information."
|
||||
}
|
||||
|
||||
|
||||
### ============================================================================
|
||||
### Example Workflow - Complete Happy Path
|
||||
### ============================================================================
|
||||
###
|
||||
### Step 1: Start workflow with content
|
||||
### POST http://localhost:7071/api/workflow/content_moderation/run
|
||||
### -> Returns instanceId: "abc123..."
|
||||
###
|
||||
### Step 2: Check status (workflow is waiting for human input)
|
||||
### GET http://localhost:7071/api/workflow/content_moderation/status/abc123
|
||||
### -> Returns pendingHumanInputRequests with requestId: "req-456..."
|
||||
###
|
||||
### Step 3: Approve content
|
||||
### POST http://localhost:7071/api/workflow/content_moderation/respond/abc123/req-456
|
||||
### {
|
||||
### "approved": true,
|
||||
### "reviewer_notes": "Looks good!"
|
||||
### }
|
||||
### -> Returns success
|
||||
###
|
||||
### Step 4: Check final status
|
||||
### GET http://localhost:7071/api/workflow/content_moderation/status/abc123
|
||||
### -> Returns runtimeStatus: "Completed", output: "✅ Content approved..."
|
||||
###
|
||||
### ============================================================================
|
||||
|
||||
|
||||
### ============================================================================
|
||||
### Health Check
|
||||
### ============================================================================
|
||||
|
||||
GET http://localhost:7071/api/health
|
||||
@@ -0,0 +1,459 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
"""Workflow with Human-in-the-Loop (HITL) using MAF request_info Pattern.
|
||||
|
||||
This sample demonstrates how to integrate human approval into a MAF workflow
|
||||
running on Azure Durable Functions. It uses the MAF `request_info` and
|
||||
`@response_handler` pattern for structured HITL interactions.
|
||||
|
||||
The workflow simulates a content moderation pipeline:
|
||||
1. User submits content for publication
|
||||
2. An AI agent analyzes the content for policy compliance
|
||||
3. A human reviewer is prompted to approve/reject the content
|
||||
4. Based on approval, content is either published or rejected
|
||||
|
||||
Key architectural points:
|
||||
- Uses MAF's `ctx.request_info()` to pause workflow and request human input
|
||||
- Uses `@response_handler` decorator to handle the human's response
|
||||
- AgentFunctionApp automatically provides HITL endpoints for status and response
|
||||
- Durable Functions provides durability while waiting for human input
|
||||
|
||||
Prerequisites:
|
||||
- Configure `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL`
|
||||
- Durable Task Scheduler connection string
|
||||
- Authentication via Azure CLI (az login)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
Executor,
|
||||
Message,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.openai import OpenAIChatOptions
|
||||
from agent_framework_azurefunctions import AgentFunctionApp
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from typing_extensions import Never
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable names
|
||||
FOUNDRY_PROJECT_ENDPOINT_ENV = "FOUNDRY_PROJECT_ENDPOINT"
|
||||
AZURE_OPENAI_DEPLOYMENT_ENV = "FOUNDRY_MODEL"
|
||||
|
||||
# Agent names
|
||||
CONTENT_ANALYZER_AGENT_NAME = "ContentAnalyzerAgent"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ContentAnalysisResult(BaseModel):
|
||||
"""Structured output from the content analysis agent."""
|
||||
|
||||
is_appropriate: bool
|
||||
risk_level: str # low, medium, high
|
||||
concerns: list[str]
|
||||
recommendation: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentSubmission:
|
||||
"""Content submitted for moderation."""
|
||||
|
||||
content_id: str
|
||||
title: str
|
||||
body: str
|
||||
author: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HumanApprovalRequest:
|
||||
"""Request sent to human reviewer for approval.
|
||||
|
||||
This is the payload passed to ctx.request_info() and will be
|
||||
exposed via the orchestration status for external systems to retrieve.
|
||||
"""
|
||||
|
||||
content_id: str
|
||||
title: str
|
||||
body: str
|
||||
author: str
|
||||
ai_analysis: ContentAnalysisResult
|
||||
prompt: str
|
||||
|
||||
|
||||
class HumanApprovalResponse(BaseModel):
|
||||
"""Response from human reviewer.
|
||||
|
||||
This is what the external system must send back via the HITL response endpoint.
|
||||
"""
|
||||
|
||||
approved: bool
|
||||
reviewer_notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModerationResult:
|
||||
"""Final result of the moderation workflow."""
|
||||
|
||||
content_id: str
|
||||
status: str # "approved", "rejected"
|
||||
ai_analysis: ContentAnalysisResult | None
|
||||
reviewer_notes: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Agent Instructions
|
||||
# ============================================================================
|
||||
|
||||
CONTENT_ANALYZER_INSTRUCTIONS = """You are a content moderation assistant that analyzes user-submitted content
|
||||
for policy compliance. Evaluate the content for:
|
||||
|
||||
1. Appropriateness - Is the content suitable for a general audience?
|
||||
2. Risk level - Rate as 'low', 'medium', or 'high' based on potential issues
|
||||
3. Concerns - List any specific issues found (empty list if none)
|
||||
4. Recommendation - Provide a brief recommendation for human reviewers
|
||||
|
||||
Return a JSON response with:
|
||||
- is_appropriate: boolean
|
||||
- risk_level: string ('low', 'medium', 'high')
|
||||
- concerns: list of strings
|
||||
- recommendation: string
|
||||
|
||||
Be thorough but fair in your analysis."""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Executors
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisWithSubmission:
|
||||
"""Combines the AI analysis with the original submission for downstream processing."""
|
||||
|
||||
submission: ContentSubmission
|
||||
analysis: ContentAnalysisResult
|
||||
|
||||
|
||||
class ContentAnalyzerExecutor(Executor):
|
||||
"""Parses the AI agent's response and prepares for human review."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="content_analyzer_executor")
|
||||
|
||||
@handler
|
||||
async def handle_analysis(
|
||||
self,
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[AnalysisWithSubmission],
|
||||
) -> None:
|
||||
"""Parse the AI analysis and forward with submission context."""
|
||||
try:
|
||||
analysis = ContentAnalysisResult.model_validate_json(response.agent_response.text)
|
||||
except ValidationError:
|
||||
analysis = ContentAnalysisResult(
|
||||
is_appropriate=False,
|
||||
risk_level="high",
|
||||
concerns=["Agent execution failed or yielded invalid JSON (possible content filter)."],
|
||||
recommendation="Manual review required",
|
||||
)
|
||||
|
||||
# Retrieve the original submission from shared state
|
||||
submission: ContentSubmission = ctx.get_state("current_submission")
|
||||
|
||||
await ctx.send_message(AnalysisWithSubmission(submission=submission, analysis=analysis))
|
||||
|
||||
|
||||
class HumanReviewExecutor(Executor):
|
||||
"""Requests human approval using MAF's request_info pattern.
|
||||
|
||||
This executor demonstrates the core HITL pattern:
|
||||
1. Receives the AI analysis result
|
||||
2. Calls ctx.request_info() to pause and request human input
|
||||
3. The @response_handler method processes the human's response
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="human_review_executor")
|
||||
|
||||
@handler
|
||||
async def request_review(
|
||||
self,
|
||||
data: AnalysisWithSubmission,
|
||||
ctx: WorkflowContext,
|
||||
) -> None:
|
||||
"""Request human review for the content.
|
||||
|
||||
This method:
|
||||
1. Constructs the approval request with all context
|
||||
2. Calls request_info to pause the workflow
|
||||
3. The workflow will resume when a response is provided via the HITL endpoint
|
||||
"""
|
||||
submission = data.submission
|
||||
analysis = data.analysis
|
||||
|
||||
# Construct the human-readable prompt
|
||||
prompt = (
|
||||
f"Please review the following content for publication:\n\n"
|
||||
f"Title: {submission.title}\n"
|
||||
f"Author: {submission.author}\n"
|
||||
f"Content: {submission.body}\n\n"
|
||||
f"AI Analysis:\n"
|
||||
f"- Appropriate: {analysis.is_appropriate}\n"
|
||||
f"- Risk Level: {analysis.risk_level}\n"
|
||||
f"- Concerns: {', '.join(analysis.concerns) if analysis.concerns else 'None'}\n"
|
||||
f"- Recommendation: {analysis.recommendation}\n\n"
|
||||
f"Please approve or reject this content."
|
||||
)
|
||||
|
||||
approval_request = HumanApprovalRequest(
|
||||
content_id=submission.content_id,
|
||||
title=submission.title,
|
||||
body=submission.body,
|
||||
author=submission.author,
|
||||
ai_analysis=analysis,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
# Store analysis in shared state for the response handler
|
||||
ctx.set_state("pending_analysis", data)
|
||||
|
||||
# Request human input - workflow will pause here
|
||||
# The response_type specifies what we expect back
|
||||
await ctx.request_info(
|
||||
request_data=approval_request,
|
||||
response_type=HumanApprovalResponse,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_approval_response(
|
||||
self,
|
||||
original_request: HumanApprovalRequest,
|
||||
response: HumanApprovalResponse,
|
||||
ctx: WorkflowContext[ModerationResult],
|
||||
) -> None:
|
||||
"""Process the human reviewer's decision.
|
||||
|
||||
This method is called automatically when a response to request_info is received.
|
||||
The original_request contains the HumanApprovalRequest we sent.
|
||||
The response contains the HumanApprovalResponse from the reviewer.
|
||||
"""
|
||||
logger.info(
|
||||
"Human review received for content %s: approved=%s, notes=%s",
|
||||
original_request.content_id,
|
||||
response.approved,
|
||||
response.reviewer_notes,
|
||||
)
|
||||
|
||||
# Create the final moderation result
|
||||
status = "approved" if response.approved else "rejected"
|
||||
result = ModerationResult(
|
||||
content_id=original_request.content_id,
|
||||
status=status,
|
||||
ai_analysis=original_request.ai_analysis,
|
||||
reviewer_notes=response.reviewer_notes,
|
||||
)
|
||||
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class PublishExecutor(Executor):
|
||||
"""Handles the final publication or rejection of content."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="publish_executor")
|
||||
|
||||
@handler
|
||||
async def handle_result(
|
||||
self,
|
||||
result: ModerationResult,
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Finalize the moderation and yield output."""
|
||||
if result.status == "approved":
|
||||
message = (
|
||||
f"✅ Content '{result.content_id}' has been APPROVED and published.\n"
|
||||
f"Reviewer notes: {result.reviewer_notes or 'None'}"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"❌ Content '{result.content_id}' has been REJECTED.\n"
|
||||
f"Reviewer notes: {result.reviewer_notes or 'None'}"
|
||||
)
|
||||
|
||||
logger.info(message)
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Input Router Executor
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _build_client_kwargs() -> dict[str, Any]:
|
||||
"""Build Foundry chat client configuration from environment variables."""
|
||||
project_endpoint = os.getenv(FOUNDRY_PROJECT_ENDPOINT_ENV)
|
||||
if not project_endpoint:
|
||||
raise RuntimeError(f"{FOUNDRY_PROJECT_ENDPOINT_ENV} environment variable is required.")
|
||||
|
||||
model = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV)
|
||||
if not model:
|
||||
raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.")
|
||||
|
||||
return {
|
||||
"project_endpoint": project_endpoint,
|
||||
"model": model,
|
||||
"credential": AzureCliCredential(),
|
||||
}
|
||||
|
||||
|
||||
class InputRouterExecutor(Executor):
|
||||
"""Routes incoming content submission to the analysis agent."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="input_router")
|
||||
|
||||
@handler
|
||||
async def route_input(
|
||||
self,
|
||||
submission: ContentSubmission,
|
||||
ctx: WorkflowContext[AgentExecutorRequest],
|
||||
) -> None:
|
||||
"""Create the agent request from the submitted content.
|
||||
|
||||
The durable engine reconstructs this ``ContentSubmission`` from the
|
||||
client's JSON payload before delivery, mirroring in-process execution.
|
||||
"""
|
||||
# Store submission in shared state for later retrieval
|
||||
ctx.set_state("current_submission", submission)
|
||||
|
||||
# Create the agent request
|
||||
message = (
|
||||
f"Please analyze the following content for policy compliance:\n\n"
|
||||
f"Title: {submission.title}\n"
|
||||
f"Author: {submission.author}\n"
|
||||
f"Content:\n{submission.body}"
|
||||
)
|
||||
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(
|
||||
messages=[Message(role="user", contents=[message])],
|
||||
should_respond=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Workflow Creation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _create_workflow() -> Workflow:
|
||||
"""Create the content moderation workflow with HITL."""
|
||||
client_kwargs = _build_client_kwargs()
|
||||
chat_client = FoundryChatClient(**client_kwargs)
|
||||
|
||||
# Create the content analysis agent
|
||||
content_analyzer_agent = Agent(
|
||||
client=chat_client,
|
||||
name=CONTENT_ANALYZER_AGENT_NAME,
|
||||
instructions=CONTENT_ANALYZER_INSTRUCTIONS,
|
||||
default_options=OpenAIChatOptions[Any](response_format=ContentAnalysisResult),
|
||||
)
|
||||
|
||||
# Create executors
|
||||
input_router = InputRouterExecutor()
|
||||
content_analyzer_executor = ContentAnalyzerExecutor()
|
||||
human_review_executor = HumanReviewExecutor()
|
||||
publish_executor = PublishExecutor()
|
||||
|
||||
# Build the workflow graph
|
||||
# Flow:
|
||||
# input_router -> content_analyzer_agent -> content_analyzer_executor
|
||||
# -> human_review_executor (HITL pause here) -> publish_executor
|
||||
return (
|
||||
WorkflowBuilder(name="content_moderation", start_executor=input_router)
|
||||
.add_edge(input_router, content_analyzer_agent)
|
||||
.add_edge(content_analyzer_agent, content_analyzer_executor)
|
||||
.add_edge(content_analyzer_executor, human_review_executor)
|
||||
.add_edge(human_review_executor, publish_executor)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Application Entry Point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def launch(durable: bool = True) -> AgentFunctionApp | None:
|
||||
"""Launch the function app or DevUI.
|
||||
|
||||
Args:
|
||||
durable: If True, returns AgentFunctionApp for Azure Functions.
|
||||
If False, launches DevUI for local MAF development.
|
||||
"""
|
||||
if durable:
|
||||
# Azure Functions mode with Durable Functions
|
||||
# The app automatically provides per-workflow HITL endpoints (workflow name
|
||||
# "content_moderation"):
|
||||
# - POST /api/workflow/content_moderation/run - Start the workflow
|
||||
# - GET /api/workflow/content_moderation/status/{instanceId} - Status + pending HITL requests
|
||||
# - POST /api/workflow/content_moderation/respond/{instanceId}/{requestId} - Send HITL response
|
||||
# - GET /api/health - Health check
|
||||
workflow = _create_workflow()
|
||||
return AgentFunctionApp(workflow=workflow, enable_health_check=True)
|
||||
# Pure MAF mode with DevUI for local development
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.devui import serve
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_path = Path(__file__).parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
logger.info("Starting Workflow HITL Sample in MAF mode")
|
||||
logger.info("Available at: http://localhost:8096")
|
||||
logger.info("\nThis workflow demonstrates:")
|
||||
logger.info("- Human-in-the-loop using request_info / @response_handler pattern")
|
||||
logger.info("- AI content analysis with structured output")
|
||||
logger.info("- Human approval workflow integration")
|
||||
logger.info("\nFlow: InputRouter -> ContentAnalyzer Agent -> HumanReview -> Publish")
|
||||
|
||||
workflow = _create_workflow()
|
||||
serve(entities=[workflow], port=8096, auto_open=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Default: Azure Functions mode
|
||||
# Run with `python function_app.py --maf` for pure MAF mode with DevUI
|
||||
app = launch(durable=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if "--maf" in sys.argv:
|
||||
# Run in pure MAF mode with DevUI
|
||||
launch(durable=False)
|
||||
else:
|
||||
print("Usage: python function_app.py --maf")
|
||||
print(" --maf Run in pure MAF mode with DevUI (http://localhost:8096)")
|
||||
print("\nFor Azure Functions mode, use: func start")
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "%TASKHUB_NAME%"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"IsEncrypted": false,
|
||||
"Values": {
|
||||
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
||||
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
|
||||
"TASKHUB_NAME": "default",
|
||||
"FUNCTIONS_WORKER_RUNTIME": "python",
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "<FOUNDRY_PROJECT_ENDPOINT>",
|
||||
"FOUNDRY_MODEL": "<FOUNDRY_MODEL>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Agent Framework packages
|
||||
# To use the deployed version, uncomment the lines below and comment out the local installation lines
|
||||
# agent-framework-foundry
|
||||
# agent-framework-azurefunctions
|
||||
|
||||
# Local installation (for development and testing)
|
||||
# Each package must be listed explicitly because pip doesn't resolve uv workspace sources.
|
||||
# Without explicit entries, pip would fetch transitive dependencies from PyPI instead of local source.
|
||||
-e ../../../../packages/core # Core framework - base dependency for all packages
|
||||
-e ../../../../packages/foundry # Foundry support - dependency for hosted chat/agent samples
|
||||
-e ../../../../packages/durabletask # Durable Task support - dependency of azurefunctions
|
||||
-e ../../../../packages/azurefunctions # Azure Functions integration - the main package for this sample
|
||||
|
||||
# Azure authentication
|
||||
azure-identity
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user