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,206 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
Sample: Sub-Workflows (Basics)
|
||||
|
||||
What it does:
|
||||
- Shows how a parent workflow invokes a sub-workflow via `WorkflowExecutor` and collects results.
|
||||
- Example: parent orchestrates multiple text processors that count words/characters.
|
||||
- Demonstrates how sub-workflows complete by yielding outputs when processing is done.
|
||||
|
||||
Prerequisites:
|
||||
- No external services required.
|
||||
"""
|
||||
|
||||
|
||||
# Message types
|
||||
@dataclass
|
||||
class TextProcessingRequest:
|
||||
"""Request to process a text string."""
|
||||
|
||||
text: str
|
||||
task_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextProcessingResult:
|
||||
"""Result of text processing."""
|
||||
|
||||
task_id: str
|
||||
text: str
|
||||
word_count: int
|
||||
char_count: int
|
||||
|
||||
|
||||
# Sub-workflow executor
|
||||
class TextProcessor(Executor):
|
||||
"""Processes text strings - counts words and characters."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="text_processor")
|
||||
|
||||
@handler
|
||||
async def process_text(
|
||||
self, request: TextProcessingRequest, ctx: WorkflowContext[Never, TextProcessingResult]
|
||||
) -> None:
|
||||
"""Process a text string and return statistics."""
|
||||
text_preview = f"'{request.text[:50]}{'...' if len(request.text) > 50 else ''}'"
|
||||
print(f"Sub-workflow processing text (Task {request.task_id}): {text_preview}")
|
||||
|
||||
# Simple text processing
|
||||
word_count = len(request.text.split()) if request.text.strip() else 0
|
||||
char_count = len(request.text)
|
||||
|
||||
print(f"Task {request.task_id}: {word_count} words, {char_count} characters")
|
||||
|
||||
# Create result
|
||||
result = TextProcessingResult(
|
||||
task_id=request.task_id,
|
||||
text=request.text,
|
||||
word_count=word_count,
|
||||
char_count=char_count,
|
||||
)
|
||||
|
||||
print(f"Sub-workflow completed task {request.task_id}")
|
||||
# Signal completion by yielding the result
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
# Parent workflow
|
||||
class TextProcessingOrchestrator(Executor):
|
||||
"""Orchestrates multiple text processing tasks using sub-workflows."""
|
||||
|
||||
results: list[TextProcessingResult] = []
|
||||
expected_count: int = 0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="text_orchestrator")
|
||||
|
||||
@handler
|
||||
async def start_processing(self, texts: list[str], ctx: WorkflowContext[TextProcessingRequest]) -> None:
|
||||
"""Start processing multiple text strings."""
|
||||
print(f"Starting processing of {len(texts)} text strings")
|
||||
print("=" * 60)
|
||||
|
||||
self.expected_count = len(texts)
|
||||
|
||||
# Send each text to a sub-workflow
|
||||
for i, text in enumerate(texts):
|
||||
task_id = f"task_{i + 1}"
|
||||
request = TextProcessingRequest(text=text, task_id=task_id)
|
||||
print(f"Dispatching {task_id} to sub-workflow")
|
||||
await ctx.send_message(request, target_id="text_processor_workflow")
|
||||
|
||||
@handler
|
||||
async def collect_result(
|
||||
self,
|
||||
result: TextProcessingResult,
|
||||
ctx: WorkflowContext[Never, list[TextProcessingResult]],
|
||||
) -> None:
|
||||
"""Collect results from sub-workflows."""
|
||||
print(f"Collected result from {result.task_id}")
|
||||
self.results.append(result)
|
||||
|
||||
# Check if all results are collected
|
||||
if len(self.results) == self.expected_count:
|
||||
print("\nAll tasks completed!")
|
||||
await ctx.yield_output(self.results)
|
||||
|
||||
|
||||
def get_result_summary(results: list[TextProcessingResult]) -> dict[str, Any]:
|
||||
"""Get a summary of all processing results."""
|
||||
total_words = sum(result.word_count for result in results)
|
||||
total_chars = sum(result.char_count for result in results)
|
||||
avg_words = total_words / len(results) if results else 0
|
||||
avg_chars = total_chars / len(results) if results else 0
|
||||
|
||||
return {
|
||||
"total_texts": len(results),
|
||||
"total_words": total_words,
|
||||
"total_characters": total_chars,
|
||||
"average_words_per_text": round(avg_words, 2),
|
||||
"average_characters_per_text": round(avg_chars, 2),
|
||||
}
|
||||
|
||||
|
||||
def create_sub_workflow() -> WorkflowExecutor:
|
||||
"""Create the text processing sub-workflow."""
|
||||
print("Setting up sub-workflow...")
|
||||
|
||||
text_processor = TextProcessor()
|
||||
processing_workflow = WorkflowBuilder(start_executor=text_processor).build()
|
||||
|
||||
return WorkflowExecutor(processing_workflow, id="text_processor_workflow")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main function to run the basic sub-workflow example."""
|
||||
print("Setting up parent workflow...")
|
||||
# Step 1: Create the parent workflow
|
||||
orchestrator = TextProcessingOrchestrator()
|
||||
sub_workflow_executor = create_sub_workflow()
|
||||
main_workflow = (
|
||||
WorkflowBuilder(start_executor=orchestrator)
|
||||
.add_edge(orchestrator, sub_workflow_executor)
|
||||
.add_edge(sub_workflow_executor, orchestrator)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Step 2: Test data - various text strings
|
||||
test_texts = [
|
||||
"Hello world! This is a simple test.",
|
||||
"Python is a powerful programming language used for many applications.",
|
||||
"Short text.",
|
||||
"This is a longer text with multiple sentences. It contains more words and characters. We use it to test our text processing workflow.", # noqa: E501
|
||||
"", # Empty string
|
||||
" Spaces around text ",
|
||||
]
|
||||
|
||||
print(f"\nTesting with {len(test_texts)} text strings")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 3: Run the workflow
|
||||
result = await main_workflow.run(test_texts)
|
||||
|
||||
# Step 4: Display results
|
||||
print("\nProcessing Results:")
|
||||
print("=" * 60)
|
||||
|
||||
# Sort results by task_id for consistent display
|
||||
task_results = result.get_outputs()
|
||||
assert len(task_results) == 1
|
||||
sorted_results = sorted(task_results[0], key=lambda r: r.task_id)
|
||||
|
||||
for result in sorted_results:
|
||||
preview = result.text[:30] + "..." if len(result.text) > 30 else result.text
|
||||
preview = preview.replace("\n", " ").strip() or "(empty)"
|
||||
print(f"{result.task_id}: '{preview}' -> {result.word_count} words, {result.char_count} chars")
|
||||
|
||||
# Step 6: Display summary
|
||||
summary = get_result_summary(sorted_results)
|
||||
print("\nSummary:")
|
||||
print("=" * 60)
|
||||
print(f"Total texts processed: {summary['total_texts']}")
|
||||
print(f"Total words: {summary['total_words']}")
|
||||
print(f"Total characters: {summary['total_characters']}")
|
||||
print(f"Average words per text: {summary['average_words_per_text']}")
|
||||
print(f"Average characters per text: {summary['average_characters_per_text']}")
|
||||
|
||||
print("\nProcessing complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,202 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Annotated, Any
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
Message,
|
||||
WorkflowExecutor,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.orchestrations import SequentialBuilder
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Sample: Sub-Workflow kwargs Propagation
|
||||
|
||||
This sample demonstrates how custom context (kwargs) flows from a parent workflow
|
||||
through to agents in sub-workflows. When you pass kwargs to the parent workflow's
|
||||
run(), they automatically propagate to nested sub-workflows.
|
||||
|
||||
Key Concepts:
|
||||
- kwargs passed to parent workflow.run() propagate to sub-workflows
|
||||
- Sub-workflow agents receive the same kwargs as the parent workflow
|
||||
- Works with nested WorkflowExecutor compositions at any depth
|
||||
- Useful for passing authentication tokens, configuration, or request context
|
||||
|
||||
Prerequisites:
|
||||
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
|
||||
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
|
||||
"""
|
||||
|
||||
|
||||
# Define tools that access custom context via **kwargs
|
||||
# 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_authenticated_data(
|
||||
resource: Annotated[str, "The resource to fetch"],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Fetch data using the authenticated user context from kwargs."""
|
||||
user_token = kwargs.get("user_token", {})
|
||||
user_name = user_token.get("user_name", "anonymous")
|
||||
access_level = user_token.get("access_level", "none")
|
||||
|
||||
print(f"\n[get_authenticated_data] kwargs keys: {list(kwargs.keys())}")
|
||||
print(f"[get_authenticated_data] User: {user_name}, Access: {access_level}")
|
||||
|
||||
return f"Fetched '{resource}' for user {user_name} ({access_level} access)"
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def call_configured_service(
|
||||
service_name: Annotated[str, "Name of the service to call"],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Call a service using configuration from kwargs."""
|
||||
config = kwargs.get("service_config", {})
|
||||
services = config.get("services", {})
|
||||
|
||||
print(f"\n[call_configured_service] kwargs keys: {list(kwargs.keys())}")
|
||||
print(f"[call_configured_service] Available services: {list(services.keys())}")
|
||||
|
||||
if service_name in services:
|
||||
endpoint = services[service_name]
|
||||
return f"Called service '{service_name}' at {endpoint}"
|
||||
return f"Service '{service_name}' not found in configuration"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("=" * 70)
|
||||
print("Sub-Workflow kwargs Propagation Demo")
|
||||
print("=" * 70)
|
||||
|
||||
# Create chat client
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# Create an agent with tools that use kwargs
|
||||
inner_agent = Agent(
|
||||
client=client,
|
||||
name="data_agent",
|
||||
instructions=(
|
||||
"You are a data access agent. Use the available tools to help users. "
|
||||
"When asked to fetch data, use get_authenticated_data. "
|
||||
"When asked to call a service, use call_configured_service."
|
||||
),
|
||||
tools=[get_authenticated_data, call_configured_service],
|
||||
)
|
||||
|
||||
# Build the inner (sub) workflow with the agent
|
||||
inner_workflow = SequentialBuilder(participants=[inner_agent]).build()
|
||||
|
||||
# Wrap the inner workflow in a WorkflowExecutor to use it as a sub-workflow
|
||||
subworkflow_executor = WorkflowExecutor(
|
||||
workflow=inner_workflow,
|
||||
id="data_subworkflow",
|
||||
)
|
||||
|
||||
# Build the outer (parent) workflow containing the sub-workflow
|
||||
outer_workflow = SequentialBuilder(participants=[subworkflow_executor]).build()
|
||||
|
||||
# Define custom context that will flow through to the sub-workflow's agent
|
||||
user_token = {
|
||||
"user_name": "alice@contoso.com",
|
||||
"access_level": "admin",
|
||||
"session_id": "sess_12345",
|
||||
}
|
||||
|
||||
service_config = {
|
||||
"services": {
|
||||
"users": "https://api.example.com/v1/users",
|
||||
"orders": "https://api.example.com/v1/orders",
|
||||
"inventory": "https://api.example.com/v1/inventory",
|
||||
},
|
||||
"timeout": 30,
|
||||
}
|
||||
|
||||
print("\nContext being passed to parent workflow:")
|
||||
print(f" user_token: {json.dumps(user_token, indent=4)}")
|
||||
print(f" service_config: {json.dumps(service_config, indent=4)}")
|
||||
print("\n" + "-" * 70)
|
||||
print("Workflow Execution (kwargs flow: parent -> sub-workflow -> agent -> tool):")
|
||||
print("-" * 70)
|
||||
|
||||
# Run the OUTER workflow with kwargs
|
||||
# These kwargs will automatically propagate to the inner sub-workflow
|
||||
async for event in outer_workflow.run(
|
||||
"Please fetch my profile data and then call the users service.",
|
||||
stream=True,
|
||||
function_invocation_kwargs={"user_token": user_token, "service_config": service_config},
|
||||
):
|
||||
if event.type == "output":
|
||||
output_data = event.data
|
||||
if isinstance(output_data, list):
|
||||
for item in output_data: # type: ignore
|
||||
if isinstance(item, Message) and item.text:
|
||||
print(f"\n[Final Answer]: {item.text}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("Sample Complete - kwargs successfully flowed through sub-workflow!")
|
||||
print("=" * 70)
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
======================================================================
|
||||
Sub-Workflow kwargs Propagation Demo
|
||||
======================================================================
|
||||
|
||||
Context being passed to parent workflow:
|
||||
user_token: {
|
||||
"user_name": "alice@contoso.com",
|
||||
"access_level": "admin",
|
||||
"session_id": "sess_12345"
|
||||
}
|
||||
service_config: {
|
||||
"services": {
|
||||
"users": "https://api.example.com/v1/users",
|
||||
"orders": "https://api.example.com/v1/orders",
|
||||
"inventory": "https://api.example.com/v1/inventory"
|
||||
},
|
||||
"timeout": 30
|
||||
}
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Workflow Execution (kwargs flow: parent -> sub-workflow -> agent -> tool):
|
||||
----------------------------------------------------------------------
|
||||
|
||||
[get_authenticated_data] kwargs keys: ['user_token', 'service_config']
|
||||
[get_authenticated_data] User: alice@contoso.com, Access: admin
|
||||
|
||||
[call_configured_service] kwargs keys: ['user_token', 'service_config']
|
||||
[call_configured_service] Available services: ['users', 'orders', 'inventory']
|
||||
|
||||
[Final Answer]: Please fetch my profile data and then call the users service.
|
||||
|
||||
[Final Answer]: - Your profile data has been fetched.
|
||||
- The users service has been called.
|
||||
|
||||
Would you like details from either the profile data or the users service response?
|
||||
|
||||
======================================================================
|
||||
Sample Complete - kwargs successfully flowed through sub-workflow!
|
||||
======================================================================
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,358 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
This sample demonstrates how to handle multiple parallel requests from a sub-workflow to
|
||||
different executors in the main workflow.
|
||||
|
||||
Prerequisite:
|
||||
- Understanding of sub-workflows.
|
||||
- Understanding of requests and responses.
|
||||
|
||||
This pattern is useful when a sub-workflow needs to interact with multiple external systems
|
||||
or services.
|
||||
|
||||
This sample implements a resource request distribution system where:
|
||||
1. A sub-workflow generates requests for computing resources and policy checks.
|
||||
2. The main workflow has executors that handle resource allocation and policy checking.
|
||||
3. Responses are routed back to the sub-workflow, which collects and processes them.
|
||||
|
||||
The sub-workflow sends two types of requests:
|
||||
- ResourceRequest: Requests for computing resources (e.g., CPU, memory).
|
||||
- PolicyRequest: Requests to check resource allocation policies.
|
||||
|
||||
The main workflow contains:
|
||||
- ResourceAllocator: Simulates a system that allocates computing resources.
|
||||
- PolicyEngine: Simulates a policy engine that approves or denies resource requests.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComputingResourceRequest:
|
||||
"""Request for computing resources."""
|
||||
|
||||
request_type: Literal["resource", "policy"]
|
||||
resource_type: Literal["cpu", "memory", "disk", "gpu"]
|
||||
amount: int
|
||||
priority: Literal["low", "normal", "high"] | None = None
|
||||
policy_type: Literal["quota", "security"] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceResponse:
|
||||
"""Response with allocated resources."""
|
||||
|
||||
resource_type: str
|
||||
allocated: int
|
||||
source: str # Which system provided the resources
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyResponse:
|
||||
"""Response from policy check."""
|
||||
|
||||
approved: bool
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceRequest:
|
||||
"""Request for computing resources."""
|
||||
|
||||
resource_type: Literal["cpu", "memory", "disk", "gpu"]
|
||||
amount: int
|
||||
priority: Literal["low", "normal", "high"]
|
||||
id: str = str(uuid.uuid4())
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyRequest:
|
||||
"""Request to check resource allocation policy."""
|
||||
|
||||
policy_type: Literal["quota", "security"]
|
||||
resource_type: Literal["cpu", "memory", "disk", "gpu"]
|
||||
amount: int
|
||||
id: str = str(uuid.uuid4())
|
||||
|
||||
|
||||
def build_resource_request_distribution_workflow() -> Workflow:
|
||||
class RequestDistribution(Executor):
|
||||
"""Distributes computing resource requests to appropriate executors."""
|
||||
|
||||
@handler
|
||||
async def distribute_requests(
|
||||
self,
|
||||
requests: list[ComputingResourceRequest],
|
||||
ctx: WorkflowContext[ResourceRequest | PolicyRequest | int],
|
||||
) -> None:
|
||||
for req in requests:
|
||||
if req.request_type == "resource":
|
||||
if req.priority is None:
|
||||
raise ValueError("Priority must be set for resource requests")
|
||||
await ctx.send_message(ResourceRequest(req.resource_type, req.amount, req.priority))
|
||||
elif req.request_type == "policy":
|
||||
if req.policy_type is None:
|
||||
raise ValueError("Policy type must be set for policy requests")
|
||||
await ctx.send_message(PolicyRequest(req.policy_type, req.resource_type, req.amount))
|
||||
else:
|
||||
raise ValueError(f"Unknown request type: {req.request_type}")
|
||||
# Notify the collector about the number of requests sent
|
||||
await ctx.send_message(len(requests))
|
||||
|
||||
class ResourceRequester(Executor):
|
||||
"""Handles resource allocation requests."""
|
||||
|
||||
@handler
|
||||
async def run(self, request: ResourceRequest, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(request_data=request, response_type=ResourceResponse)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self, original_request: ResourceRequest, response: ResourceResponse, ctx: WorkflowContext[ResourceResponse]
|
||||
) -> None:
|
||||
print(f"Resource allocated: {response.allocated} {response.resource_type} from {response.source}")
|
||||
await ctx.send_message(response)
|
||||
|
||||
class PolicyChecker(Executor):
|
||||
"""Handles policy check requests."""
|
||||
|
||||
@handler
|
||||
async def run(self, request: PolicyRequest, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(request_data=request, response_type=PolicyResponse)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self, original_request: PolicyRequest, response: PolicyResponse, ctx: WorkflowContext[PolicyResponse]
|
||||
) -> None:
|
||||
print(f"Policy check result: {response.approved} - {response.reason}")
|
||||
await ctx.send_message(response)
|
||||
|
||||
class ResultCollector(Executor):
|
||||
"""Collects and processes all responses."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id)
|
||||
self._request_count = 0
|
||||
self._responses: list[ResourceResponse | PolicyResponse] = []
|
||||
|
||||
@handler
|
||||
async def set_request_count(self, count: int, ctx: WorkflowContext) -> None:
|
||||
if count <= 0:
|
||||
raise ValueError("Request count must be positive")
|
||||
self._request_count = count
|
||||
|
||||
@handler
|
||||
async def collect(self, response: ResourceResponse | PolicyResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
self._responses.append(response)
|
||||
print(f"Collected {len(self._responses)}/{self._request_count} responses")
|
||||
if len(self._responses) == self._request_count:
|
||||
# All responses received, process them
|
||||
await ctx.yield_output(f"All {self._request_count} requests processed.")
|
||||
elif len(self._responses) > self._request_count:
|
||||
raise ValueError("Received more responses than expected")
|
||||
|
||||
orchestrator = RequestDistribution("orchestrator")
|
||||
resource_requester = ResourceRequester("resource_requester")
|
||||
policy_checker = PolicyChecker("policy_checker")
|
||||
result_collector = ResultCollector("result_collector")
|
||||
|
||||
return (
|
||||
WorkflowBuilder(start_executor=orchestrator)
|
||||
.add_edge(orchestrator, resource_requester)
|
||||
.add_edge(orchestrator, policy_checker)
|
||||
.add_edge(resource_requester, result_collector)
|
||||
.add_edge(policy_checker, result_collector)
|
||||
.add_edge(orchestrator, result_collector) # For request count
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
class ResourceAllocator(Executor):
|
||||
"""Simulates a system that allocates computing resources."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id)
|
||||
self._cache: dict[str, int] = {"cpu": 10, "memory": 50, "disk": 100}
|
||||
# Record pending requests to match responses
|
||||
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
|
||||
|
||||
async def _handle_resource_request(self, request: ResourceRequest) -> ResourceResponse | None:
|
||||
"""Allocates resources based on request and available cache."""
|
||||
available = self._cache.get(request.resource_type, 0)
|
||||
if available >= request.amount:
|
||||
self._cache[request.resource_type] -= request.amount
|
||||
return ResourceResponse(request.resource_type, request.amount, "cache")
|
||||
return None
|
||||
|
||||
@handler
|
||||
async def handle_subworkflow_request(
|
||||
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
|
||||
) -> None:
|
||||
"""Handles requests from sub-workflows."""
|
||||
source_event: WorkflowEvent[Any] = request.source_event
|
||||
if not isinstance(source_event.data, ResourceRequest):
|
||||
return
|
||||
|
||||
request_payload: ResourceRequest = source_event.data
|
||||
response = await self._handle_resource_request(request_payload)
|
||||
if response:
|
||||
await ctx.send_message(request.create_response(response))
|
||||
else:
|
||||
# Request cannot be fulfilled via cache, forward the request to external
|
||||
self._pending_requests[request_payload.id] = source_event
|
||||
await ctx.request_info(request_data=request_payload, response_type=ResourceResponse)
|
||||
|
||||
@response_handler
|
||||
async def handle_external_response(
|
||||
self,
|
||||
original_request: ResourceRequest,
|
||||
response: ResourceResponse,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Handles responses from external systems and routes them to the sub-workflow."""
|
||||
print(f"External resource allocated: {response.allocated} {response.resource_type} from {response.source}")
|
||||
source_event = self._pending_requests.pop(original_request.id, None)
|
||||
if source_event is None:
|
||||
raise ValueError("No matching pending request found for the resource response")
|
||||
await ctx.send_message(SubWorkflowResponseMessage(data=response, source_event=source_event))
|
||||
|
||||
|
||||
class PolicyEngine(Executor):
|
||||
"""Simulates a policy engine that approves or denies resource requests."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id)
|
||||
self._quota: dict[str, int] = {
|
||||
"cpu": 5, # Only allow up to 5 CPU units
|
||||
"memory": 20, # Only allow up to 20 memory units
|
||||
"disk": 1000, # Liberal disk policy
|
||||
}
|
||||
# Record pending requests to match responses
|
||||
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
|
||||
|
||||
@handler
|
||||
async def handle_subworkflow_request(
|
||||
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
|
||||
) -> None:
|
||||
"""Handles requests from sub-workflows."""
|
||||
source_event: WorkflowEvent[Any] = request.source_event
|
||||
if not isinstance(source_event.data, PolicyRequest):
|
||||
return
|
||||
|
||||
request_payload: PolicyRequest = source_event.data
|
||||
# Simple policy logic for demonstration
|
||||
if request_payload.policy_type == "quota":
|
||||
allowed_amount = self._quota.get(request_payload.resource_type, 0)
|
||||
if request_payload.amount <= allowed_amount:
|
||||
response = PolicyResponse(True, "Within quota limits")
|
||||
else:
|
||||
response = PolicyResponse(False, "Exceeds quota limits")
|
||||
await ctx.send_message(request.create_response(response))
|
||||
else:
|
||||
# For other policy types, forward to external system
|
||||
self._pending_requests[request_payload.id] = source_event
|
||||
await ctx.request_info(request_data=request_payload, response_type=PolicyResponse)
|
||||
|
||||
@response_handler
|
||||
async def handle_external_response(
|
||||
self,
|
||||
original_request: PolicyRequest,
|
||||
response: PolicyResponse,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Handles responses from external systems and routes them to the sub-workflow."""
|
||||
print(f"External policy check result: {response.approved} - {response.reason}")
|
||||
source_event = self._pending_requests.pop(original_request.id, None)
|
||||
if source_event is None:
|
||||
raise ValueError("No matching pending request found for the policy response")
|
||||
await ctx.send_message(SubWorkflowResponseMessage(data=response, source_event=source_event))
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# Build the main workflow
|
||||
resource_allocator = ResourceAllocator("resource_allocator")
|
||||
policy_engine = PolicyEngine("policy_engine")
|
||||
sub_workflow_executor = WorkflowExecutor(
|
||||
build_resource_request_distribution_workflow(),
|
||||
"sub_workflow_executor",
|
||||
# Setting allow_direct_output=True to let the sub-workflow output directly.
|
||||
# This is because the sub-workflow is the both the entry point and the exit
|
||||
# point of the main workflow.
|
||||
allow_direct_output=True,
|
||||
)
|
||||
main_workflow = (
|
||||
WorkflowBuilder(start_executor=sub_workflow_executor)
|
||||
.add_edge(sub_workflow_executor, resource_allocator)
|
||||
.add_edge(resource_allocator, sub_workflow_executor)
|
||||
.add_edge(sub_workflow_executor, policy_engine)
|
||||
.add_edge(policy_engine, sub_workflow_executor)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Test requests
|
||||
test_requests = [
|
||||
ComputingResourceRequest("resource", "cpu", 2, priority="normal"), # cache hit
|
||||
ComputingResourceRequest("policy", "cpu", 3, policy_type="quota"), # policy hit
|
||||
ComputingResourceRequest("resource", "memory", 15, priority="normal"), # cache hit
|
||||
ComputingResourceRequest("policy", "memory", 100, policy_type="quota"), # policy miss -> external
|
||||
ComputingResourceRequest("resource", "gpu", 1, priority="high"), # cache miss -> external
|
||||
ComputingResourceRequest("policy", "disk", 500, policy_type="quota"), # policy hit
|
||||
ComputingResourceRequest("policy", "cpu", 1, policy_type="security"), # unknown policy -> external
|
||||
]
|
||||
|
||||
# Run the workflow
|
||||
print(f"Testing with {len(test_requests)} mixed requests.")
|
||||
print("Starting main workflow...")
|
||||
run_result = await main_workflow.run(test_requests)
|
||||
|
||||
# Handle request info events
|
||||
request_info_events = run_result.get_request_info_events()
|
||||
if request_info_events:
|
||||
print(f"\nHandling {len(request_info_events)} request info events...\n")
|
||||
|
||||
responses: dict[str, ResourceResponse | PolicyResponse] = {}
|
||||
for event in request_info_events:
|
||||
if isinstance(event.data, ResourceRequest):
|
||||
# Simulate external resource allocation
|
||||
resource_response = ResourceResponse(
|
||||
resource_type=event.data.resource_type, allocated=event.data.amount, source="external_provider"
|
||||
)
|
||||
responses[event.request_id] = resource_response
|
||||
elif isinstance(event.data, PolicyRequest):
|
||||
# Simulate external policy check
|
||||
response = PolicyResponse(True, "External system approved")
|
||||
responses[event.request_id] = response
|
||||
else:
|
||||
print(f"Unknown request info event data type: {type(event.data)}")
|
||||
|
||||
run_result = await main_workflow.run(responses=responses)
|
||||
|
||||
outputs = run_result.get_outputs()
|
||||
if outputs:
|
||||
print("\nWorkflow completed with outputs:")
|
||||
for output in outputs:
|
||||
print(f"- {output}")
|
||||
else:
|
||||
raise RuntimeError("Workflow did not produce an output.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,306 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
This sample demonstrates how to handle request from the sub-workflow in the main workflow.
|
||||
|
||||
Prerequisite:
|
||||
- Understanding of sub-workflows.
|
||||
- Understanding of requests and responses.
|
||||
|
||||
This pattern is useful when you want to reuse a workflow that makes requests to an external system,
|
||||
but you want to intercept those requests in the main workflow and handle them without further propagation
|
||||
to the external system.
|
||||
|
||||
This sample implements a smart email delivery system that validates email addresses before sending emails.
|
||||
1. We will start by creating a workflow that validates email addresses in a sequential manner. The validation
|
||||
consists of three steps: sanitization, format validation, and domain validation. The domain validation
|
||||
step will involve checking if the email domain is valid by making a request to an external system.
|
||||
2. Then we will create a main workflow that uses the email validation workflow as a sub-workflow. The main
|
||||
workflow will intercept the domain validation requests from the sub-workflow and handle them internally
|
||||
without propagating them to an external system.
|
||||
3. Once the email address is validated, the main workflow will proceed to send the email if the address is valid,
|
||||
or block the email if the address is invalid.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SanitizedEmailResult:
|
||||
"""Result of email sanitization and validation.
|
||||
|
||||
The properties get built up as the email address goes through
|
||||
the validation steps in the workflow.
|
||||
"""
|
||||
|
||||
original: str
|
||||
sanitized: str
|
||||
is_valid: bool
|
||||
|
||||
|
||||
def build_email_address_validation_workflow() -> Workflow:
|
||||
"""Build an email address validation workflow.
|
||||
|
||||
This workflow consists of three steps (each is represented by an executor):
|
||||
1. Sanitize the email address, such as removing leading/trailing spaces.
|
||||
2. Validate the email address format, such as checking for "@" and domain.
|
||||
3. Extract the domain from the email address and request domain validation,
|
||||
after which it completes with the final result.
|
||||
"""
|
||||
|
||||
class EmailSanitizer(Executor):
|
||||
"""Sanitize email address by trimming spaces."""
|
||||
|
||||
@handler
|
||||
async def handle(self, email_address: str, ctx: WorkflowContext[SanitizedEmailResult]) -> None:
|
||||
"""Trim leading and trailing spaces from the email address.
|
||||
|
||||
This executor doesn't produce any workflow output, but sends the sanitized
|
||||
email address to the next executor in the workflow.
|
||||
"""
|
||||
sanitized = email_address.strip()
|
||||
print(f"Sanitized email address: '{sanitized}'")
|
||||
await ctx.send_message(SanitizedEmailResult(original=email_address, sanitized=sanitized, is_valid=False))
|
||||
|
||||
class EmailFormatValidator(Executor):
|
||||
"""Validate email address format."""
|
||||
|
||||
@handler
|
||||
async def handle(
|
||||
self,
|
||||
partial_result: SanitizedEmailResult,
|
||||
ctx: WorkflowContext[SanitizedEmailResult, SanitizedEmailResult],
|
||||
) -> None:
|
||||
"""Validate the email address format.
|
||||
|
||||
This executor can potentially produce a workflow output (False if the format is invalid).
|
||||
When the format is valid, it sends the validated email address to the next executor in the workflow.
|
||||
"""
|
||||
if "@" not in partial_result.sanitized or "." not in partial_result.sanitized.split("@")[-1]:
|
||||
print(f"Invalid email format: '{partial_result.sanitized}'")
|
||||
await ctx.yield_output(
|
||||
SanitizedEmailResult(
|
||||
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
|
||||
)
|
||||
)
|
||||
return
|
||||
print(f"Validated email format: '{partial_result.sanitized}'")
|
||||
await ctx.send_message(
|
||||
SanitizedEmailResult(
|
||||
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
|
||||
)
|
||||
)
|
||||
|
||||
class DomainValidator(Executor):
|
||||
"""Validate email domain."""
|
||||
|
||||
def __init__(self, id: str):
|
||||
super().__init__(id=id)
|
||||
self._pending_domains: dict[str, SanitizedEmailResult] = {}
|
||||
|
||||
@handler
|
||||
async def handle(self, partial_result: SanitizedEmailResult, ctx: WorkflowContext) -> None:
|
||||
"""Extract the domain from the email address and request domain validation.
|
||||
|
||||
This executor doesn't produce any workflow output, but sends a domain validation request
|
||||
to an external system to user for validation.
|
||||
"""
|
||||
domain = partial_result.sanitized.split("@")[-1]
|
||||
print(f"Validating domain: '{domain}'")
|
||||
self._pending_domains[domain] = partial_result
|
||||
# Send a request to the external system via the request_info mechanism
|
||||
await ctx.request_info(request_data=domain, response_type=bool)
|
||||
|
||||
@response_handler
|
||||
async def handle_domain_validation_response(
|
||||
self, original_request: str, is_valid: bool, ctx: WorkflowContext[Never, SanitizedEmailResult]
|
||||
) -> None:
|
||||
"""Handle the domain validation response.
|
||||
|
||||
This method receives the response from the external system and yields the final
|
||||
validation result (True if both format and domain are valid, False otherwise).
|
||||
"""
|
||||
if original_request not in self._pending_domains:
|
||||
raise ValueError(f"Received response for unknown domain: '{original_request}'")
|
||||
partial_result = self._pending_domains.pop(original_request)
|
||||
if is_valid:
|
||||
print(f"Domain '{original_request}' is valid.")
|
||||
await ctx.yield_output(
|
||||
SanitizedEmailResult(
|
||||
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=True
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"Domain '{original_request}' is invalid.")
|
||||
await ctx.yield_output(
|
||||
SanitizedEmailResult(
|
||||
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
|
||||
)
|
||||
)
|
||||
|
||||
# Build the workflow
|
||||
email_sanitizer = EmailSanitizer(id="email_sanitizer")
|
||||
email_format_validator = EmailFormatValidator(id="email_format_validator")
|
||||
domain_validator = DomainValidator(id="domain_validator")
|
||||
|
||||
return (
|
||||
WorkflowBuilder(start_executor=email_sanitizer)
|
||||
.add_edge(email_sanitizer, email_format_validator)
|
||||
.add_edge(email_format_validator, domain_validator)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Email:
|
||||
recipient: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class SmartEmailOrchestrator(Executor):
|
||||
"""Orchestrates email address validation using a sub-workflow."""
|
||||
|
||||
def __init__(self, id: str, approved_domains: set[str]):
|
||||
"""Initialize the orchestrator with a set of approved domains.
|
||||
|
||||
Args:
|
||||
id: The executor ID.
|
||||
approved_domains: A set of domains that are considered valid.
|
||||
"""
|
||||
super().__init__(id=id)
|
||||
self._approved_domains = approved_domains
|
||||
# Keep track of previously approved and disapproved recipients
|
||||
self._approved_recipients: set[str] = set()
|
||||
self._disapproved_recipients: set[str] = set()
|
||||
# Record pending emails waiting for validation results
|
||||
self._pending_emails: dict[str, Email] = {}
|
||||
|
||||
@handler
|
||||
async def run(self, email: Email, ctx: WorkflowContext[Email | str, bool]) -> None:
|
||||
"""Start the email delivery process.
|
||||
|
||||
This handler receives an Email object. If the recipient has been previously approved,
|
||||
it sends the email object to the next executor to handle delivery. If the recipient
|
||||
has been previously disapproved, it yields False as the final result. Otherwise,
|
||||
it sends the recipient email address to the sub-workflow for validation.
|
||||
"""
|
||||
recipient = email.recipient
|
||||
if recipient in self._approved_recipients:
|
||||
print(f"Recipient '{recipient}' has been previously approved.")
|
||||
await ctx.send_message(email)
|
||||
return
|
||||
if recipient in self._disapproved_recipients:
|
||||
print(f"Blocking email to previously disapproved recipient: '{recipient}'")
|
||||
await ctx.yield_output(False)
|
||||
return
|
||||
|
||||
print(f"Validating new recipient email address: '{recipient}'")
|
||||
self._pending_emails[recipient] = email
|
||||
await ctx.send_message(recipient)
|
||||
|
||||
@handler
|
||||
async def handler_domain_validation_request(
|
||||
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
|
||||
) -> None:
|
||||
"""Handle requests from the sub-workflow for domain validation.
|
||||
|
||||
Note that the message type must be SubWorkflowRequestMessage to intercept the request. And
|
||||
the response must be sent back using SubWorkflowResponseMessage to route the response
|
||||
back to the sub-workflow.
|
||||
"""
|
||||
if not isinstance(request.source_event.data, str):
|
||||
raise TypeError(f"Expected domain string, got {type(request.source_event.data)}")
|
||||
domain = request.source_event.data
|
||||
is_valid = domain in self._approved_domains
|
||||
print(f"External domain validation for '{domain}': {'valid' if is_valid else 'invalid'}")
|
||||
await ctx.send_message(request.create_response(is_valid), target_id=request.executor_id)
|
||||
|
||||
@handler
|
||||
async def handle_validation_result(self, result: SanitizedEmailResult, ctx: WorkflowContext[Email, bool]) -> None:
|
||||
"""Handle the email address validation result.
|
||||
|
||||
This handler receives the validation result from the sub-workflow.
|
||||
If the email address is valid, it adds the recipient to the approved list
|
||||
and sends the email object to the next executor to handle delivery.
|
||||
If the email address is invalid, it adds the recipient to the disapproved list
|
||||
and yields False as the final result.
|
||||
"""
|
||||
email = self._pending_emails.pop(result.original)
|
||||
email.recipient = result.sanitized # Use the sanitized email address
|
||||
if result.is_valid:
|
||||
print(f"Email address '{result.original}' is valid.")
|
||||
self._approved_recipients.add(result.original)
|
||||
await ctx.send_message(email)
|
||||
else:
|
||||
print(f"Email address '{result.original}' is invalid. Blocking email.")
|
||||
self._disapproved_recipients.add(result.original)
|
||||
await ctx.yield_output(False)
|
||||
|
||||
|
||||
class EmailDelivery(Executor):
|
||||
"""Simulates email delivery."""
|
||||
|
||||
@handler
|
||||
async def handle(self, email: Email, ctx: WorkflowContext[Never, bool]) -> None:
|
||||
"""Simulate sending the email and yield True as the final result."""
|
||||
print(f"Sending email to '{email.recipient}' with subject '{email.subject}'")
|
||||
await asyncio.sleep(1) # Simulate network delay
|
||||
print(f"Email sent to '{email.recipient}' successfully.")
|
||||
await ctx.yield_output(True)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# A list of approved domains
|
||||
approved_domains = {"example.com", "company.com"}
|
||||
|
||||
# Build the main workflow
|
||||
smart_email_orchestrator = SmartEmailOrchestrator(id="smart_email_orchestrator", approved_domains=approved_domains)
|
||||
email_delivery = EmailDelivery(id="email_delivery")
|
||||
email_validation_workflow = WorkflowExecutor(
|
||||
build_email_address_validation_workflow(), id="email_validation_workflow"
|
||||
)
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=smart_email_orchestrator)
|
||||
.add_edge(smart_email_orchestrator, email_validation_workflow)
|
||||
.add_edge(email_validation_workflow, smart_email_orchestrator)
|
||||
.add_edge(smart_email_orchestrator, email_delivery)
|
||||
.build()
|
||||
)
|
||||
|
||||
test_emails = [
|
||||
Email(recipient="user1@example.com", subject="Hello User1", body="This is a test email."),
|
||||
Email(recipient=" user2@invalid", subject="Hello User2", body="This is a test email."),
|
||||
Email(recipient=" user3@company.com ", subject="Hello User3", body="This is a test email."),
|
||||
Email(recipient="user4@unknown.com", subject="Hello User4", body="This is a test email."),
|
||||
# Re-send to an approved recipient
|
||||
Email(recipient="user1@example.com", subject="Hello User1", body="This is a test email."),
|
||||
# Re-send to a disapproved recipient
|
||||
Email(recipient=" user2@invalid", subject="Hello User2", body="This is a test email."),
|
||||
]
|
||||
|
||||
# Execute the workflow
|
||||
for email in test_emails:
|
||||
print(f"\nProcessing email to '{email.recipient}'")
|
||||
async for event in workflow.run(email, stream=True):
|
||||
if event.type == "output":
|
||||
print(f"Final result for '{email.recipient}': {'Delivered' if event.data else 'Blocked'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user