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,69 @@
|
||||
# Composed Workflow (Sub-Workflow) on a Standalone Durable Task Worker
|
||||
|
||||
This sample demonstrates **workflow composition** on a standalone Durable Task
|
||||
worker: an inner agent-framework `Workflow` is embedded as a node inside an outer
|
||||
`Workflow` using `WorkflowExecutor`. On the durable host, the inner workflow runs
|
||||
as its own durable **child orchestration**.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Embedding one `Workflow` inside another with
|
||||
`WorkflowExecutor(inner_workflow, id=...)`.
|
||||
- A single `DurableAIAgentWorker.configure_workflow(outer_workflow)` call walks the
|
||||
composition and auto-registers a durable orchestration for **each** workflow:
|
||||
- `dafx-review_pipeline` — the outer workflow.
|
||||
- `dafx-sentiment_analysis` — the inner workflow, run as a durable **child
|
||||
orchestration** when the outer workflow reaches the `WorkflowExecutor` node.
|
||||
- Per-workflow scoping: each workflow's agent executors become durable entities and
|
||||
its non-agent executors become durable activities, named per workflow so the same
|
||||
executor id in two workflows never collides.
|
||||
- Output forwarding: the inner workflow yields a string and, because
|
||||
`allow_direct_output` is left at its default (`False`), that output is forwarded to
|
||||
the outer workflow as a message delivered to the `reporter` executor.
|
||||
|
||||
## Composition Layout
|
||||
|
||||
```text
|
||||
review_pipeline (outer)
|
||||
intake (executor)
|
||||
-> sentiment_sub = WorkflowExecutor(sentiment_analysis)
|
||||
sentiment_agent (agent) -> sentiment_formatter (executor)
|
||||
-> reporter (executor)
|
||||
```
|
||||
|
||||
## 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/11_subworkflow
|
||||
python worker.py
|
||||
```
|
||||
|
||||
In a second terminal, run the client:
|
||||
|
||||
```bash
|
||||
python client.py
|
||||
```
|
||||
|
||||
The client targets only the outer workflow (`review_pipeline`); the sub-workflow
|
||||
runs automatically as a child orchestration. Each review flows:
|
||||
|
||||
`intake` → `sentiment_sub` (child orchestration: `sentiment_agent` →
|
||||
`sentiment_formatter`) → `reporter` → `"Review analysis complete -> sentiment: ..."`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Client that starts the composed workflow orchestration and prints the result.
|
||||
|
||||
The worker (``worker.py``) must be running first. Only the *outer* workflow is
|
||||
started by the client; its embedded sub-workflow runs automatically as a durable
|
||||
child orchestration when the outer workflow reaches the ``WorkflowExecutor`` node.
|
||||
|
||||
The workflow is started via ``DurableWorkflowClient.start_workflow`` - which
|
||||
schedules the ``dafx-review_pipeline`` orchestration that
|
||||
``DurableAIAgentWorker.configure_workflow`` auto-registers for the outer workflow.
|
||||
|
||||
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__)
|
||||
|
||||
# The client targets the outer workflow; the sub-workflow runs as a child orchestration.
|
||||
WORKFLOW_NAME = "review_pipeline"
|
||||
|
||||
|
||||
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, review: str) -> None:
|
||||
"""Start the outer workflow with a review and wait for the result."""
|
||||
instance_id = client.start_workflow(input=review)
|
||||
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 composed workflow against a couple of product reviews."""
|
||||
client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME)
|
||||
|
||||
logger.info("TEST 1: Positive review")
|
||||
run_workflow(
|
||||
client,
|
||||
"Absolutely love this espresso machine - it heats up fast and the coffee is consistently great.",
|
||||
)
|
||||
|
||||
logger.info("TEST 2: Negative review")
|
||||
run_workflow(
|
||||
client,
|
||||
"Disappointed. The device stopped working after two weeks and support never replied.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,211 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Worker that hosts a MAF Workflow composed of a nested sub-workflow.
|
||||
|
||||
This sample shows workflow *composition* on the Durable Task host. A
|
||||
``WorkflowExecutor`` embeds an inner workflow as a node inside an outer workflow.
|
||||
``DurableAIAgentWorker.configure_workflow`` walks the composition and
|
||||
auto-registers a durable orchestration for *each* workflow:
|
||||
|
||||
- ``dafx-sentiment_analysis`` - the inner workflow, run as a durable **child
|
||||
orchestration** whenever the outer workflow reaches the ``WorkflowExecutor`` node.
|
||||
- ``dafx-review_pipeline`` - the outer workflow.
|
||||
|
||||
Each workflow's agent executors become durable entities and its non-agent
|
||||
executors become durable activities, scoped per workflow so the same executor id
|
||||
in two workflows never collides.
|
||||
|
||||
Composition layout::
|
||||
|
||||
review_pipeline (outer)
|
||||
intake (executor)
|
||||
-> sentiment_sub = WorkflowExecutor(sentiment_analysis)
|
||||
sentiment_agent (agent) -> sentiment_formatter (executor)
|
||||
-> reporter (executor)
|
||||
|
||||
The inner workflow yields a string; because ``allow_direct_output`` is left at its
|
||||
default (``False``), that output is forwarded to the outer workflow as a message
|
||||
delivered to ``reporter``, which produces the final result.
|
||||
|
||||
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,
|
||||
Executor,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
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__)
|
||||
|
||||
SENTIMENT_AGENT_NAME = "SentimentAgent"
|
||||
INNER_WORKFLOW_NAME = "sentiment_analysis"
|
||||
OUTER_WORKFLOW_NAME = "review_pipeline"
|
||||
|
||||
SENTIMENT_INSTRUCTIONS = (
|
||||
"You classify the sentiment of a customer product review. "
|
||||
"Return JSON with fields sentiment (one of 'positive', 'neutral', 'negative') "
|
||||
"and confidence (a number between 0 and 1)."
|
||||
)
|
||||
|
||||
|
||||
class SentimentResult(BaseModel):
|
||||
"""Structured output from the sentiment agent."""
|
||||
|
||||
sentiment: str
|
||||
confidence: float
|
||||
|
||||
|
||||
class SentimentFormatterExecutor(Executor):
|
||||
"""Inner-workflow executor that turns the agent's JSON into a summary line."""
|
||||
|
||||
@handler
|
||||
async def format_sentiment(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
text = agent_response.agent_response.text
|
||||
try:
|
||||
result = SentimentResult.model_validate_json(text)
|
||||
summary = f"{result.sentiment} (confidence {result.confidence:.0%})"
|
||||
except ValidationError:
|
||||
summary = "unknown (could not parse sentiment)"
|
||||
await ctx.yield_output(summary)
|
||||
|
||||
|
||||
class IntakeExecutor(Executor):
|
||||
"""Outer-workflow entry point that normalizes the review before analysis."""
|
||||
|
||||
@handler
|
||||
async def intake(self, review: str, ctx: WorkflowContext[str]) -> None:
|
||||
normalized = review.strip()
|
||||
logger.info("Intake received review (%d chars)", len(normalized))
|
||||
await ctx.send_message(normalized)
|
||||
|
||||
|
||||
class ReporterExecutor(Executor):
|
||||
"""Outer-workflow executor that consumes the sub-workflow's forwarded output."""
|
||||
|
||||
@handler
|
||||
async def report(self, sentiment_summary: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(f"Review analysis complete -> sentiment: {sentiment_summary}")
|
||||
|
||||
|
||||
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_inner_workflow(chat_client: FoundryChatClient) -> Workflow:
|
||||
"""Build the inner ``sentiment_analysis`` workflow (agent -> formatter)."""
|
||||
sentiment_agent = Agent(
|
||||
client=chat_client,
|
||||
name=SENTIMENT_AGENT_NAME,
|
||||
instructions=SENTIMENT_INSTRUCTIONS,
|
||||
default_options=FoundryChatOptions[Any](response_format=SentimentResult),
|
||||
)
|
||||
sentiment_formatter = SentimentFormatterExecutor(id="sentiment_formatter")
|
||||
|
||||
return (
|
||||
WorkflowBuilder(name=INNER_WORKFLOW_NAME, start_executor=sentiment_agent)
|
||||
.add_edge(sentiment_agent, sentiment_formatter)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def create_workflow() -> Workflow:
|
||||
"""Build the outer ``review_pipeline`` workflow that embeds the inner workflow."""
|
||||
chat_client = _create_chat_client()
|
||||
inner_workflow = create_inner_workflow(chat_client)
|
||||
|
||||
intake = IntakeExecutor(id="intake")
|
||||
# WorkflowExecutor embeds the inner workflow as a single node in the outer
|
||||
# workflow. On the durable host this node runs as a child orchestration.
|
||||
sentiment_sub = WorkflowExecutor(inner_workflow, id="sentiment_sub")
|
||||
reporter = ReporterExecutor(id="reporter")
|
||||
|
||||
return (
|
||||
WorkflowBuilder(name=OUTER_WORKFLOW_NAME, start_executor=intake)
|
||||
.add_edge(intake, sentiment_sub)
|
||||
.add_edge(sentiment_sub, reporter)
|
||||
.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 outer workflow and its nested sub-workflow on the worker."""
|
||||
agent_worker = DurableAIAgentWorker(worker)
|
||||
|
||||
workflow = create_workflow()
|
||||
# A single call walks the composition: it registers the outer workflow plus
|
||||
# every nested sub-workflow (here, sentiment_analysis) as its own durable
|
||||
# orchestration, deduped by workflow name.
|
||||
agent_worker.configure_workflow(workflow)
|
||||
logger.info("✓ Configured workflow '%s' with embedded sub-workflow '%s'", OUTER_WORKFLOW_NAME, INNER_WORKFLOW_NAME)
|
||||
|
||||
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