mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [Breaking] Remove WorkflowCompletedEvent, introduce workflow output and migrate to ctx.yield_output() + a huge refactoring (#845)
* Introduce input and output types for executor and workflow * WorkflowOutputContext handles two types * Remove can_handle_types from Executor * Update validation * Move workflow executor * Move workflow executor * Fix issues in WorkflowExecutor * refactor executor * update execute signature to create workflow context within Executor * fix simple sub workflow test; fix validation * fix output types in WorkflowExecutor * fix issue in Executor handling of SubWorkflowRequestInfo * update tests to use proper workflow output * update orchestration patterns to use output * Update sample -- not finished * Update python/packages/main/tests/workflow/test_workflow_states.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/main/tests/workflow/test_concurrent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address comments * WorkflowOutputContext --> WorkflowContext * remove WorkflowCompletedEvent * update samples * Update doc string for important classes; update WorkflowExecutor to support concurrent execution * use Never instead of None for default type * Update usage of WorkflowContext[None to WorkflowContext[Never * address comments * remove filter for None * address comments, minor fixes * quality of life improvement on interceptor types --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
0f913bcdeb
commit
2133043f11
@@ -4,10 +4,11 @@ import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
@@ -20,6 +21,7 @@ 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.
|
||||
@@ -60,7 +62,9 @@ class TextProcessor(Executor):
|
||||
super().__init__(id="text_processor")
|
||||
|
||||
@handler
|
||||
async def process_text(self, request: TextProcessingRequest, ctx: WorkflowContext[TextProcessingResult]) -> None:
|
||||
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}")
|
||||
@@ -80,8 +84,8 @@ class TextProcessor(Executor):
|
||||
)
|
||||
|
||||
print(f"✅ Sub-workflow completed task {request.task_id}")
|
||||
# Signal completion
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=result))
|
||||
# Signal completion by yielding the result
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
# Parent workflow
|
||||
@@ -110,7 +114,7 @@ class TextProcessingOrchestrator(Executor):
|
||||
await ctx.send_message(request, target_id="text_processor_workflow")
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: TextProcessingResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def collect_result(self, result: TextProcessingResult, ctx: WorkflowContext) -> None:
|
||||
"""Collect results from sub-workflows."""
|
||||
print(f"📥 Collected result from {result.task_id}")
|
||||
self.results.append(result)
|
||||
@@ -173,7 +177,7 @@ async def main():
|
||||
print("=" * 60)
|
||||
|
||||
# Step 4: Run the workflow
|
||||
result = await main_workflow.run(test_texts)
|
||||
await main_workflow.run(test_texts)
|
||||
|
||||
# Step 5: Display results
|
||||
print("\n📊 Processing Results:")
|
||||
|
||||
+18
-16
@@ -4,11 +4,12 @@ import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -138,6 +139,7 @@ class ResourceRequester(Executor):
|
||||
for req_data in requests:
|
||||
req_type = req_data.get("request_type", "resource")
|
||||
|
||||
request: ResourceRequest | PolicyCheckRequest
|
||||
if req_type == "resource":
|
||||
print(f" 📦 Requesting resource: {req_data.get('type', 'cpu')} x{req_data.get('amount', 1)}")
|
||||
request = ResourceRequest(
|
||||
@@ -164,7 +166,7 @@ class ResourceRequester(Executor):
|
||||
async def handle_resource_response(
|
||||
self,
|
||||
response: RequestResponse[ResourceRequest, ResourceResponse],
|
||||
ctx: WorkflowContext[None],
|
||||
ctx: WorkflowContext[Never, RequestFinished],
|
||||
) -> None:
|
||||
"""Handle resource allocation response."""
|
||||
if response.data:
|
||||
@@ -174,12 +176,12 @@ class ResourceRequester(Executor):
|
||||
f"from {response.data.source}"
|
||||
)
|
||||
if self._collect_results():
|
||||
# Emit completion event and send RequestFinished to the parent workflow.
|
||||
await ctx.add_event(WorkflowCompletedEvent(RequestFinished()))
|
||||
# Yield completion result to the parent workflow.
|
||||
await ctx.yield_output(RequestFinished())
|
||||
|
||||
@handler
|
||||
async def handle_policy_response(
|
||||
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext[None]
|
||||
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext[Never, RequestFinished]
|
||||
) -> None:
|
||||
"""Handle policy check response."""
|
||||
if response.data:
|
||||
@@ -189,8 +191,8 @@ class ResourceRequester(Executor):
|
||||
f"{response.data.approved} - {response.data.reason}"
|
||||
)
|
||||
if self._collect_results():
|
||||
# Emit completion event and send RequestFinished to the parent workflow.
|
||||
await ctx.add_event(WorkflowCompletedEvent(RequestFinished()))
|
||||
# Yield completion result to the parent workflow.
|
||||
await ctx.yield_output(RequestFinished())
|
||||
|
||||
def _collect_results(self) -> bool:
|
||||
"""Collect and summarize results."""
|
||||
@@ -213,7 +215,7 @@ class ResourceCache(Executor):
|
||||
|
||||
@intercepts_request
|
||||
async def check_cache(
|
||||
self, request: ResourceRequest, ctx: WorkflowContext[None]
|
||||
self, request: ResourceRequest, ctx: WorkflowContext
|
||||
) -> RequestResponse[ResourceRequest, ResourceResponse]:
|
||||
"""Intercept RESOURCE requests and check cache first."""
|
||||
print(f"🏪 CACHE interceptor checking: {request.amount} {request.resource_type}")
|
||||
@@ -234,7 +236,7 @@ class ResourceCache(Executor):
|
||||
|
||||
@handler
|
||||
async def collect_result(
|
||||
self, response: RequestResponse[ResourceRequest, ResourceResponse], ctx: WorkflowContext[None]
|
||||
self, response: RequestResponse[ResourceRequest, ResourceResponse], ctx: WorkflowContext
|
||||
) -> None:
|
||||
"""Collect results from external requests that were forwarded."""
|
||||
if response.data and response.data.source != "cache": # Don't double-count our own results
|
||||
@@ -263,7 +265,7 @@ class PolicyEngine(Executor):
|
||||
|
||||
@intercepts_request
|
||||
async def check_policy(
|
||||
self, request: PolicyCheckRequest, ctx: WorkflowContext[None]
|
||||
self, request: PolicyCheckRequest, ctx: WorkflowContext
|
||||
) -> RequestResponse[PolicyCheckRequest, PolicyResponse]:
|
||||
"""Intercept POLICY requests and apply rules."""
|
||||
print(f"🛡️ POLICY interceptor checking: {request.amount} {request.resource_type}, policy={request.policy_type}")
|
||||
@@ -286,7 +288,7 @@ class PolicyEngine(Executor):
|
||||
|
||||
@handler
|
||||
async def collect_policy_result(
|
||||
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext[None]
|
||||
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext
|
||||
) -> None:
|
||||
"""Collect policy results from external requests that were forwarded."""
|
||||
if response.data:
|
||||
@@ -299,15 +301,15 @@ class Coordinator(Executor):
|
||||
super().__init__(id="coordinator")
|
||||
|
||||
@handler
|
||||
async def start(self, requests: list[dict[str, Any]], ctx: WorkflowContext[object]) -> None:
|
||||
async def start(self, requests: list[dict[str, Any]], ctx: WorkflowContext[list[dict[str, Any]]]) -> None:
|
||||
"""Start the resource allocation process."""
|
||||
await ctx.send_message(requests, target_id="resource_workflow")
|
||||
|
||||
@handler
|
||||
async def handle_completion(self, completion: RequestFinished, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_completion(self, completion: RequestFinished, ctx: WorkflowContext) -> None:
|
||||
"""Handle sub-workflow completion.
|
||||
|
||||
It comes from the sub-workflow emitted WorkflowCompletionEvent's data field.
|
||||
It comes from the sub-workflow yielded output.
|
||||
"""
|
||||
print("🎯 Main workflow received completion.")
|
||||
|
||||
@@ -377,10 +379,10 @@ async def main() -> None:
|
||||
|
||||
# 8. Run the workflow
|
||||
print("🎬 Running workflow...")
|
||||
result = await main_workflow.run(test_requests)
|
||||
events = await main_workflow.run(test_requests)
|
||||
|
||||
# 9. Handle any external requests that couldn't be intercepted
|
||||
request_events = result.get_request_info_events()
|
||||
request_events = events.get_request_info_events()
|
||||
if request_events:
|
||||
print(f"\n🌐 Handling {len(request_events)} external request(s)...")
|
||||
|
||||
|
||||
+10
-9
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
@@ -10,7 +9,6 @@ from agent_framework import (
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
@@ -43,6 +41,7 @@ Key concepts demonstrated:
|
||||
- Concurrent processing: Multiple emails processed simultaneously without interference
|
||||
- External request routing: RequestInfoExecutor handles forwarded external requests
|
||||
- Sub-workflow isolation: Sub-workflows work normally without knowing they're nested
|
||||
- Sub-workflows complete by yielding outputs when validation is finished
|
||||
|
||||
Prerequisites:
|
||||
- No external services required (external calls are simulated via `RequestInfoExecutor`).
|
||||
@@ -101,7 +100,9 @@ class EmailValidator(Executor):
|
||||
|
||||
@handler
|
||||
async def validate_request(
|
||||
self, request: EmailValidationRequest, ctx: WorkflowContext[DomainCheckRequest | ValidationResult]
|
||||
self,
|
||||
request: EmailValidationRequest,
|
||||
ctx: WorkflowContext[DomainCheckRequest | ValidationResult, ValidationResult],
|
||||
) -> None:
|
||||
"""Validate an email address."""
|
||||
print(f"🔍 Sub-workflow validating email: {request.email}")
|
||||
@@ -112,7 +113,7 @@ class EmailValidator(Executor):
|
||||
if not domain:
|
||||
print(f"❌ Invalid email format: {request.email}")
|
||||
result = ValidationResult(email=request.email, is_valid=False, reason="Invalid email format")
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=result))
|
||||
await ctx.yield_output(result)
|
||||
return
|
||||
|
||||
print(f"🌐 Sub-workflow requesting domain check for: {domain}")
|
||||
@@ -126,7 +127,7 @@ class EmailValidator(Executor):
|
||||
async def handle_domain_response(
|
||||
self,
|
||||
response: RequestResponse[DomainCheckRequest, bool],
|
||||
ctx: WorkflowContext[ValidationResult],
|
||||
ctx: WorkflowContext[ValidationResult, ValidationResult],
|
||||
) -> None:
|
||||
"""Handle domain check response from RequestInfo with correlation."""
|
||||
approved = bool(response.data)
|
||||
@@ -151,7 +152,7 @@ class EmailValidator(Executor):
|
||||
reason="Domain approved" if approved else "Domain not approved",
|
||||
)
|
||||
print(f"✅ Sub-workflow completing validation for: {email}")
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=result))
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
# 3. Implement the parent workflow with request interception
|
||||
@@ -181,7 +182,7 @@ class SmartEmailOrchestrator(Executor):
|
||||
|
||||
@intercepts_request
|
||||
async def check_domain(
|
||||
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
|
||||
self, request: DomainCheckRequest, ctx: WorkflowContext
|
||||
) -> RequestResponse[DomainCheckRequest, bool]:
|
||||
"""Intercept domain check requests from sub-workflows."""
|
||||
print(f"🔍 Parent intercepting domain check for: {request.domain}")
|
||||
@@ -192,8 +193,8 @@ class SmartEmailOrchestrator(Executor):
|
||||
return RequestResponse[DomainCheckRequest, bool].forward()
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
|
||||
"""Collect validation results. It comes from the sub-workflow emitted WorkflowCompletionEvent's data field."""
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
"""Collect validation results. It comes from the sub-workflow yielded output."""
|
||||
status_icon = "✅" if result.is_valid else "❌"
|
||||
print(f"📥 {status_icon} Validation result: {result.email} -> {result.reason}")
|
||||
self._results.append(result)
|
||||
|
||||
Reference in New Issue
Block a user