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:
Eric Zhu
2025-09-23 13:52:53 -07:00
committed by GitHub
Unverified
parent 0f913bcdeb
commit 2133043f11
67 changed files with 2564 additions and 1648 deletions
@@ -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:")
@@ -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)...")
@@ -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)