chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Workflow on a Standalone Durable Task Worker
|
||||
|
||||
This sample demonstrates running an agent-framework `Workflow` as a durable
|
||||
orchestration on a **standalone Durable Task worker** — no Azure Functions
|
||||
required. It is the durabletask counterpart to the Azure Functions workflow
|
||||
samples (`samples/04-hosting/azure_functions/10_workflow_no_shared_state`).
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Hosting a MAF `Workflow` outside Azure Functions via
|
||||
`DurableAIAgentWorker.configure_workflow(workflow)`, which auto-registers:
|
||||
- a durable **entity** for each agent executor,
|
||||
- a durable **activity** for each non-agent executor, and
|
||||
- the **workflow orchestrator** (registered as `WORKFLOW_ORCHESTRATOR_NAME`).
|
||||
- Conditional routing with `add_switch_case_edge_group` (spam vs. legitimate email).
|
||||
- Mixing AI agents with non-agent executors in one workflow graph.
|
||||
- Starting the workflow from a client with
|
||||
`DurableWorkflowClient.start_workflow(input=...)` and reading its result with
|
||||
`await_workflow_output(instance_id)`.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) in the parent directory for environment setup.
|
||||
|
||||
This sample uses Azure AI Foundry credentials:
|
||||
|
||||
- `FOUNDRY_PROJECT_ENDPOINT`
|
||||
- `FOUNDRY_MODEL`
|
||||
|
||||
It also needs a Durable Task Scheduler. For local development, start the
|
||||
emulator (defaults to `http://localhost:8080`):
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
Start the worker in one terminal:
|
||||
|
||||
```bash
|
||||
cd samples/04-hosting/durabletask/08_workflow
|
||||
python worker.py
|
||||
```
|
||||
|
||||
In a second terminal, run the client:
|
||||
|
||||
```bash
|
||||
python client.py
|
||||
```
|
||||
|
||||
The client runs two cases:
|
||||
|
||||
- **Legitimate email** → `SpamDetectionAgent` → `EmailAssistantAgent` →
|
||||
`email_sender` → `"Email sent: ..."`.
|
||||
- **Spam email** → `SpamDetectionAgent` → `spam_handler` →
|
||||
`"Email marked as spam: ..."`.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Client that starts the standalone workflow orchestration and prints the result.
|
||||
|
||||
The worker (``worker.py``) must be running first. The workflow is started via
|
||||
``DurableWorkflowClient.start_workflow`` - which schedules the ``dafx-{name}``
|
||||
orchestration that ``DurableAIAgentWorker.configure_workflow`` auto-registers for
|
||||
the workflow named ``email_triage``.
|
||||
|
||||
Prerequisites:
|
||||
- ``worker.py`` running and connected to the same Durable Task Scheduler.
|
||||
- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from agent_framework.azure import DurableWorkflowClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WORKFLOW_NAME = "email_triage"
|
||||
|
||||
|
||||
def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient:
|
||||
"""Create a configured DurableTaskSchedulerClient."""
|
||||
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
|
||||
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
|
||||
|
||||
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
|
||||
|
||||
return DurableTaskSchedulerClient(
|
||||
host_address=endpoint_url,
|
||||
secure_channel=endpoint_url != "http://localhost:8080",
|
||||
taskhub=taskhub_name,
|
||||
token_credential=credential,
|
||||
)
|
||||
|
||||
|
||||
def run_workflow(client: DurableWorkflowClient, email_content: str) -> None:
|
||||
"""Start the workflow with an email and wait for the result."""
|
||||
instance_id = client.start_workflow(input=email_content)
|
||||
logger.info("Started workflow instance: %s", instance_id)
|
||||
|
||||
output = client.await_workflow_output(instance_id)
|
||||
logger.info("Workflow output: %s", output)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the workflow against a legitimate email and a spam email."""
|
||||
client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME)
|
||||
|
||||
logger.info("TEST 1: Legitimate email")
|
||||
run_workflow(
|
||||
client,
|
||||
"Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. "
|
||||
"Please review the agenda in Jira.",
|
||||
)
|
||||
|
||||
logger.info("TEST 2: Spam email")
|
||||
run_workflow(
|
||||
client,
|
||||
"URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer!",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,213 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Worker that hosts a MAF Workflow as a durable orchestration (no Azure Functions).
|
||||
|
||||
This sample shows how to run an agent-framework ``Workflow`` on a standalone
|
||||
Durable Task worker using ``DurableAIAgentWorker.configure_workflow``. The worker
|
||||
auto-registers:
|
||||
|
||||
- a durable entity for each agent executor,
|
||||
- a durable activity for each non-agent executor, and
|
||||
- the workflow orchestrator (named ``WORKFLOW_ORCHESTRATOR_NAME``).
|
||||
|
||||
The workflow classifies an email and conditionally routes it: spam is handled by
|
||||
a non-agent executor, while legitimate email is drafted by a second agent and
|
||||
"sent" by another non-agent executor.
|
||||
|
||||
Prerequisites:
|
||||
- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``.
|
||||
- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``.
|
||||
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
|
||||
|
||||
Run the worker (this process), then run ``client.py`` in another process.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentExecutorResponse,
|
||||
Case,
|
||||
Default,
|
||||
Executor,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.azure import DurableAIAgentWorker
|
||||
from agent_framework.foundry import FoundryChatClient, FoundryChatOptions
|
||||
from azure.identity import AzureCliCredential
|
||||
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from typing_extensions import Never
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SPAM_AGENT_NAME = "SpamDetectionAgent"
|
||||
EMAIL_AGENT_NAME = "EmailAssistantAgent"
|
||||
WORKFLOW_NAME = "email_triage"
|
||||
|
||||
SPAM_DETECTION_INSTRUCTIONS = (
|
||||
"You are a spam detection assistant that identifies spam emails. "
|
||||
"Return JSON with fields is_spam (bool) and reason (string)."
|
||||
)
|
||||
EMAIL_ASSISTANT_INSTRUCTIONS = (
|
||||
"You are an email assistant that drafts professional replies to legitimate emails. "
|
||||
"Return JSON with a single field 'response' containing the drafted reply."
|
||||
)
|
||||
|
||||
|
||||
class SpamDetectionResult(BaseModel):
|
||||
"""Structured output from the spam detection agent."""
|
||||
|
||||
is_spam: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class EmailResponse(BaseModel):
|
||||
"""Structured output from the email assistant agent."""
|
||||
|
||||
response: str
|
||||
|
||||
|
||||
class SpamHandlerExecutor(Executor):
|
||||
"""Non-agent executor that finalizes spam emails."""
|
||||
|
||||
@handler
|
||||
async def handle_spam_result(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
text = agent_response.agent_response.text
|
||||
try:
|
||||
result = SpamDetectionResult.model_validate_json(text)
|
||||
reason = result.reason
|
||||
except ValidationError:
|
||||
reason = "Invalid JSON from agent"
|
||||
await ctx.yield_output(f"Email marked as spam: {reason}")
|
||||
|
||||
|
||||
class EmailSenderExecutor(Executor):
|
||||
"""Non-agent executor that 'sends' the drafted reply."""
|
||||
|
||||
@handler
|
||||
async def handle_email_response(
|
||||
self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]
|
||||
) -> None:
|
||||
text = agent_response.agent_response.text
|
||||
try:
|
||||
email = EmailResponse.model_validate_json(text)
|
||||
reply = email.response
|
||||
except ValidationError:
|
||||
reply = "Error generating response."
|
||||
await ctx.yield_output(f"Email sent: {reply}")
|
||||
|
||||
|
||||
def is_spam_detected(message: Any) -> bool:
|
||||
"""Routing condition: True when the spam agent flagged the email as spam."""
|
||||
if not isinstance(message, AgentExecutorResponse):
|
||||
return False
|
||||
try:
|
||||
return SpamDetectionResult.model_validate_json(message.agent_response.text).is_spam
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _create_chat_client() -> FoundryChatClient:
|
||||
"""Create an Azure AI Foundry chat client using AzureCliCredential."""
|
||||
return FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AsyncAzureCliCredential(),
|
||||
)
|
||||
|
||||
|
||||
def create_workflow() -> Workflow:
|
||||
"""Build the conditional spam-detection workflow."""
|
||||
chat_client = _create_chat_client()
|
||||
|
||||
spam_agent = Agent(
|
||||
client=chat_client,
|
||||
name=SPAM_AGENT_NAME,
|
||||
instructions=SPAM_DETECTION_INSTRUCTIONS,
|
||||
default_options=FoundryChatOptions[Any](response_format=SpamDetectionResult),
|
||||
)
|
||||
email_agent = Agent(
|
||||
client=chat_client,
|
||||
name=EMAIL_AGENT_NAME,
|
||||
instructions=EMAIL_ASSISTANT_INSTRUCTIONS,
|
||||
default_options=FoundryChatOptions[Any](response_format=EmailResponse),
|
||||
)
|
||||
|
||||
spam_handler = SpamHandlerExecutor(id="spam_handler")
|
||||
email_sender = EmailSenderExecutor(id="email_sender")
|
||||
|
||||
return (
|
||||
WorkflowBuilder(name=WORKFLOW_NAME, 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 get_worker(
|
||||
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
|
||||
) -> DurableTaskSchedulerWorker:
|
||||
"""Create a configured DurableTaskSchedulerWorker."""
|
||||
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
|
||||
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
|
||||
|
||||
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
|
||||
|
||||
return DurableTaskSchedulerWorker(
|
||||
host_address=endpoint_url,
|
||||
secure_channel=endpoint_url != "http://localhost:8080",
|
||||
taskhub=taskhub_name,
|
||||
token_credential=credential,
|
||||
log_handler=log_handler,
|
||||
)
|
||||
|
||||
|
||||
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
|
||||
"""Register the workflow (agents + activities + orchestrator) on the worker."""
|
||||
agent_worker = DurableAIAgentWorker(worker)
|
||||
|
||||
workflow = create_workflow()
|
||||
# One call wires up: agent entities, non-agent executor activities, and the
|
||||
# workflow orchestrator (registered as WORKFLOW_ORCHESTRATOR_NAME).
|
||||
agent_worker.configure_workflow(workflow)
|
||||
logger.info("✓ Configured workflow with %d executors", len(workflow.executors))
|
||||
|
||||
return agent_worker
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Start the worker and block until interrupted."""
|
||||
worker = get_worker()
|
||||
setup_worker(worker)
|
||||
|
||||
logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.")
|
||||
try:
|
||||
worker.start()
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Worker shutdown initiated")
|
||||
|
||||
logger.info("Worker stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user