mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] Remove Request Interceptor Architecture - Simplify Sub-workflow Communication (#898)
* removed intercepts_request and simplified how interception is handled * parameterize SubWorkflowRequestInfo * revert back the field rename of RequestResponse * remove duplicate tests * ignore type error * remove SubWorkflowResponse * Remove SubWorkflowRequestInfo and update RequestInfoMessage with source_executor_id for correlation
This commit is contained in:
committed by
GitHub
Unverified
parent
39e071c430
commit
366a7f7d47
@@ -49,8 +49,8 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
| Sample | File | Concepts |
|
||||
|---|---|---|
|
||||
| Sub-Workflow (Basics) | [composition/sub_workflow_basics.py](./composition/sub_workflow_basics.py) | Wrap a workflow as an executor and orchestrate sub-workflows |
|
||||
| Sub-Workflow: Request Interception | [composition/sub_workflow_request_interception.py](./composition/sub_workflow_request_interception.py) | Intercept and forward requests with decorators and request handling |
|
||||
| Sub-Workflow: Parallel Requests | [composition/sub_workflow_parallel_requests.py](./composition/sub_workflow_parallel_requests.py) | Multi-type interception and external forwarding patterns |
|
||||
| Sub-Workflow: Request Interception | [composition/sub_workflow_request_interception.py](./composition/sub_workflow_request_interception.py) | Intercept and forward sub-workflow requests using @handler for RequestInfoMessage subclasses |
|
||||
| Sub-Workflow: Parallel Requests | [composition/sub_workflow_parallel_requests.py](./composition/sub_workflow_parallel_requests.py) | Multiple specialized interceptors handling different request types from same sub-workflow |
|
||||
|
||||
### control-flow
|
||||
| Sample | File | Concepts |
|
||||
|
||||
+92
-91
@@ -9,71 +9,60 @@ from typing_extensions import Never
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
)
|
||||
|
||||
# Import the new sub-workflow types directly from the implementation package
|
||||
try:
|
||||
from agent_framework import (
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
WorkflowExecutor,
|
||||
intercepts_request,
|
||||
)
|
||||
except ImportError:
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "packages", "workflow"))
|
||||
from agent_framework import (
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
WorkflowExecutor,
|
||||
intercepts_request,
|
||||
)
|
||||
|
||||
"""
|
||||
Sample: Sub-workflow with parallel requests
|
||||
Sample: Sub-workflow with parallel request handling by specialized interceptors
|
||||
|
||||
This sample demonstrates the PROPER pattern for request interception.
|
||||
This sample demonstrates how different parent executors can handle different types of requests
|
||||
from the same sub-workflow using regular @handler methods for RequestInfoMessage subclasses.
|
||||
|
||||
Prerequisites:
|
||||
- No external services required (external handling simulated via `RequestInfoExecutor`).
|
||||
|
||||
Key principles:
|
||||
1. Only ONE executor intercepts a given request type from a specific sub-workflow
|
||||
2. Different executors can intercept DIFFERENT request types from the same sub-workflow
|
||||
3. The same executor can intercept the same request type from DIFFERENT sub-workflows
|
||||
|
||||
This ensures:
|
||||
- Deterministic behavior
|
||||
- Clear responsibility boundaries
|
||||
- Easier debugging and maintenance
|
||||
Key architectural principles:
|
||||
1. Specialized interceptors: Each parent executor handles only specific request types
|
||||
2. Type-based routing: ResourceCache handles ResourceRequest, PolicyEngine handles PolicyCheckRequest
|
||||
3. Automatic type filtering: Each interceptor only receives requests with matching types
|
||||
4. Fallback forwarding: Unhandled requests are forwarded to external services
|
||||
|
||||
The example simulates a resource allocation system where:
|
||||
- Sub-workflow requests resources (CPU, memory, etc.)
|
||||
- A single Cache executor intercepts and handles resource requests
|
||||
- The Cache can either satisfy from cache or forward to external
|
||||
- Sub-workflow makes mixed requests for resources (CPU, memory) and policy checks
|
||||
- ResourceCache executor intercepts ResourceRequest messages, serves from cache or forwards
|
||||
- PolicyEngine executor intercepts PolicyCheckRequest messages, applies rules or forwards
|
||||
- Each interceptor uses typed @handler methods for automatic filtering
|
||||
|
||||
Simple flow visualization:
|
||||
Flow visualization:
|
||||
|
||||
Coordinator
|
||||
|
|
||||
| list[resource/policy requests]
|
||||
| Mixed list[resource + policy requests]
|
||||
v
|
||||
[ Sub-workflow: WorkflowExecutor(ResourceRequester) ]
|
||||
| |
|
||||
| ResourceRequest | PolicyCheckRequest
|
||||
v v
|
||||
ResourceCache (@intercepts) PolicyEngine (@intercepts)
|
||||
| handled/forward | handled/forward
|
||||
v v
|
||||
RequestInfo (external) <----- forwarded when not handled
|
||||
| responses
|
||||
|
|
||||
| Emits different RequestInfoMessage types:
|
||||
| - ResourceRequest
|
||||
| - PolicyCheckRequest
|
||||
v
|
||||
Back to sub-workflow -> completion -> results collected
|
||||
Parent workflow routes to specialized handlers:
|
||||
| |
|
||||
| ResourceCache.handle_resource_request | PolicyEngine.handle_policy_request
|
||||
| (@handler ResourceRequest) | (@handler PolicyCheckRequest)
|
||||
v v
|
||||
Cache hit/miss decision Policy allow/deny decision
|
||||
| |
|
||||
| RequestResponse OR forward | RequestResponse OR forward
|
||||
v v
|
||||
Back to sub-workflow <----------> External RequestInfoExecutor
|
||||
|
|
||||
v
|
||||
External responses route back
|
||||
"""
|
||||
|
||||
|
||||
@@ -201,9 +190,9 @@ class ResourceRequester(Executor):
|
||||
return self._request_count == 0
|
||||
|
||||
|
||||
# 3. Implement the Resource Cache - ONLY intercepts ResourceRequest
|
||||
# 3. Implement the Resource Cache - Uses typed handler for ResourceRequest
|
||||
class ResourceCache(Executor):
|
||||
"""Interceptor that handles RESOURCE requests from cache."""
|
||||
"""Interceptor that handles RESOURCE requests from cache using typed routing."""
|
||||
|
||||
# Use class attributes to avoid Pydantic assignment restrictions
|
||||
cache: dict[str, int] = {"cpu": 10, "memory": 50, "disk": 100}
|
||||
@@ -213,26 +202,32 @@ class ResourceCache(Executor):
|
||||
super().__init__(id="resource_cache")
|
||||
# Instance initialization only; state kept in class attributes as above
|
||||
|
||||
@intercepts_request
|
||||
async def check_cache(
|
||||
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}")
|
||||
@handler
|
||||
async def handle_resource_request(
|
||||
self, request: ResourceRequest, ctx: WorkflowContext[RequestResponse[ResourceRequest, Any] | ResourceRequest]
|
||||
) -> None:
|
||||
"""Handle RESOURCE requests from sub-workflows and check cache first."""
|
||||
resource_request = request
|
||||
print(f"🏪 CACHE interceptor checking: {resource_request.amount} {resource_request.resource_type}")
|
||||
|
||||
available = self.cache.get(request.resource_type, 0)
|
||||
available = self.cache.get(resource_request.resource_type, 0)
|
||||
|
||||
if available >= request.amount:
|
||||
if available >= resource_request.amount:
|
||||
# We can satisfy from cache
|
||||
self.cache[request.resource_type] -= request.amount
|
||||
response = ResourceResponse(resource_type=request.resource_type, allocated=request.amount, source="cache")
|
||||
print(f" ✅ Cache satisfied: {request.amount} {request.resource_type}")
|
||||
self.results.append(response)
|
||||
return RequestResponse[ResourceRequest, ResourceResponse].handled(response)
|
||||
self.cache[resource_request.resource_type] -= resource_request.amount
|
||||
response_data = ResourceResponse(
|
||||
resource_type=resource_request.resource_type, allocated=resource_request.amount, source="cache"
|
||||
)
|
||||
print(f" ✅ Cache satisfied: {resource_request.amount} {resource_request.resource_type}")
|
||||
self.results.append(response_data)
|
||||
|
||||
# Cache miss - forward to external
|
||||
print(f" ❌ Cache miss: need {request.amount}, have {available} {request.resource_type}")
|
||||
return RequestResponse[ResourceRequest, ResourceResponse].forward()
|
||||
# Send response back to sub-workflow
|
||||
response = RequestResponse(data=response_data, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
# Cache miss - forward to external
|
||||
print(f" ❌ Cache miss: need {resource_request.amount}, have {available} {resource_request.resource_type}")
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def collect_result(
|
||||
@@ -247,9 +242,9 @@ class ResourceCache(Executor):
|
||||
)
|
||||
|
||||
|
||||
# 4. Implement the Policy Engine - ONLY intercepts PolicyCheckRequest (different type!)
|
||||
# 4. Implement the Policy Engine - Uses typed handler for PolicyCheckRequest
|
||||
class PolicyEngine(Executor):
|
||||
"""Interceptor that handles POLICY requests."""
|
||||
"""Interceptor that handles POLICY requests using typed routing."""
|
||||
|
||||
# Use class attributes for simple sample state
|
||||
quota: dict[str, int] = {
|
||||
@@ -263,28 +258,35 @@ class PolicyEngine(Executor):
|
||||
super().__init__(id="policy_engine")
|
||||
# Instance initialization only; state kept in class attributes as above
|
||||
|
||||
@intercepts_request
|
||||
async def check_policy(
|
||||
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}")
|
||||
@handler
|
||||
async def handle_policy_request(
|
||||
self, request: PolicyCheckRequest, ctx: WorkflowContext[RequestResponse[PolicyCheckRequest, Any] | PolicyCheckRequest]
|
||||
) -> None:
|
||||
"""Handle POLICY requests from sub-workflows and apply rules."""
|
||||
policy_request = request
|
||||
print(f"🛡️ POLICY interceptor checking: {policy_request.amount} {policy_request.resource_type}, policy={policy_request.policy_type}")
|
||||
|
||||
quota_limit = self.quota.get(request.resource_type, 0)
|
||||
quota_limit = self.quota.get(policy_request.resource_type, 0)
|
||||
|
||||
if policy_request.policy_type == "quota":
|
||||
if policy_request.amount <= quota_limit:
|
||||
response_data = PolicyResponse(approved=True, reason=f"Within quota ({quota_limit})")
|
||||
print(f" ✅ Policy approved: {policy_request.amount} <= {quota_limit}")
|
||||
self.results.append(response_data)
|
||||
|
||||
# Send response back to sub-workflow
|
||||
response = RequestResponse(data=response_data, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
return
|
||||
|
||||
if request.policy_type == "quota":
|
||||
if request.amount <= quota_limit:
|
||||
response = PolicyResponse(approved=True, reason=f"Within quota ({quota_limit})")
|
||||
print(f" ✅ Policy approved: {request.amount} <= {quota_limit}")
|
||||
self.results.append(response)
|
||||
return RequestResponse[PolicyCheckRequest, PolicyResponse].handled(response)
|
||||
# Exceeds quota - forward to external for review
|
||||
print(f" ❌ Policy exceeds quota: {request.amount} > {quota_limit}, forwarding to external")
|
||||
return RequestResponse[PolicyCheckRequest, PolicyResponse].forward()
|
||||
print(f" ❌ Policy exceeds quota: {policy_request.amount} > {quota_limit}, forwarding to external")
|
||||
await ctx.send_message(request)
|
||||
return
|
||||
|
||||
# Unknown policy type - forward to external
|
||||
print(f" ❓ Unknown policy type: {request.policy_type}, forwarding")
|
||||
return RequestResponse[PolicyCheckRequest, PolicyResponse].forward()
|
||||
print(f" ❓ Unknown policy type: {policy_request.policy_type}, forwarding")
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def collect_policy_result(
|
||||
@@ -340,20 +342,19 @@ async def main() -> None:
|
||||
# Create a simple coordinator that starts the process
|
||||
coordinator = Coordinator()
|
||||
|
||||
# PROPER PATTERN: Each executor intercepts DIFFERENT request types
|
||||
# TYPED ROUTING: Each executor handles specific typed RequestInfoMessage messages
|
||||
main_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(coordinator)
|
||||
.add_edge(coordinator, workflow_executor) # Start sub-workflow
|
||||
.add_edge(workflow_executor, coordinator) # Sub-workflow completion back to coordinator
|
||||
.add_edge(workflow_executor, cache) # Cache intercepts ResourceRequest
|
||||
.add_edge(cache, workflow_executor) # Cache handles ResourceRequest
|
||||
.add_edge(workflow_executor, policy) # Policy handles PolicyCheckRequest
|
||||
.add_edge(policy, workflow_executor) # Policy intercepts PolicyCheckRequest
|
||||
.add_edge(cache, main_request_info) # Cache forwards to external
|
||||
.add_edge(policy, main_request_info) # Policy forwards to external
|
||||
.add_edge(main_request_info, workflow_executor) # External responses back
|
||||
.add_edge(workflow_executor, main_request_info) # Sub-workflow forwards to main
|
||||
.add_edge(workflow_executor, cache) # WorkflowExecutor sends ResourceRequest to cache
|
||||
.add_edge(workflow_executor, policy) # WorkflowExecutor sends PolicyCheckRequest to policy
|
||||
.add_edge(cache, workflow_executor) # Cache sends RequestResponse back
|
||||
.add_edge(policy, workflow_executor) # Policy sends RequestResponse back
|
||||
.add_edge(cache, main_request_info) # Cache forwards ResourceRequest to external
|
||||
.add_edge(policy, main_request_info) # Policy forwards PolicyCheckRequest to external
|
||||
.add_edge(main_request_info, workflow_executor) # External responses back to sub-workflow
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
+29
-20
@@ -12,7 +12,6 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
intercepts_request,
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -20,8 +19,8 @@ Sample: Sub-Workflows with Request Interception
|
||||
|
||||
This sample shows how to:
|
||||
1. Create workflows that execute other workflows as sub-workflows
|
||||
2. Intercept requests from sub-workflows in parent workflows using @intercepts_request
|
||||
3. Conditionally handle or forward requests using RequestResponse.handled() and RequestResponse.forward()
|
||||
2. Intercept requests from sub-workflows using an executor with @handler for RequestInfoMessage subclasses
|
||||
3. Conditionally handle or forward requests using RequestResponse messages
|
||||
4. Handle external requests that are forwarded by the parent workflow
|
||||
5. Proper request/response correlation for concurrent processing
|
||||
|
||||
@@ -35,9 +34,8 @@ The example simulates an email validation system where:
|
||||
|
||||
Key concepts demonstrated:
|
||||
- WorkflowExecutor: Wraps a workflow to make it behave as an executor
|
||||
- @intercepts_request: Decorator for parent workflows to handle sub-workflow requests
|
||||
- RequestResponse: Enables conditional handling vs forwarding of requests
|
||||
- Request correlation: Using request_id to match responses with original requests
|
||||
- RequestInfoMessage handler: @handler method to intercept sub-workflow requests
|
||||
- Request correlation: Using request_id and source_executor_id to match responses with original requests
|
||||
- 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
|
||||
@@ -48,19 +46,19 @@ Prerequisites:
|
||||
|
||||
Simple flow visualization:
|
||||
|
||||
Parent Orchestrator (@intercepts_request)
|
||||
Parent Orchestrator (handles DomainCheckRequest)
|
||||
|
|
||||
| EmailValidationRequest(email) x3 (concurrent)
|
||||
v
|
||||
[ Sub-workflow: WorkflowExecutor(EmailValidator) ]
|
||||
|
|
||||
| DomainCheckRequest(domain) with request_id correlation
|
||||
| DomainCheckRequest(domain) with request_id and source_executor_id
|
||||
v
|
||||
Interception? yes -> handled locally with RequestResponse.handled(True)
|
||||
Interception? yes -> handled locally with RequestResponse(data=True)
|
||||
no -> forwarded to RequestInfoExecutor -> external service
|
||||
|
|
||||
v
|
||||
Response routed back to sub-workflow using request_id
|
||||
Response routed back to sub-workflow using source_executor_id
|
||||
"""
|
||||
|
||||
|
||||
@@ -92,7 +90,7 @@ class ValidationResult:
|
||||
class EmailValidator(Executor):
|
||||
"""Validates email addresses - doesn't know it's in a sub-workflow."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the EmailValidator executor."""
|
||||
super().__init__(id="email_validator")
|
||||
# Use a dict to track multiple pending emails by request_id
|
||||
@@ -180,17 +178,28 @@ class SmartEmailOrchestrator(Executor):
|
||||
request = EmailValidationRequest(email=email)
|
||||
await ctx.send_message(request, target_id="email_validator_workflow")
|
||||
|
||||
@intercepts_request
|
||||
async def check_domain(
|
||||
self, request: DomainCheckRequest, ctx: WorkflowContext
|
||||
) -> RequestResponse[DomainCheckRequest, bool]:
|
||||
"""Intercept domain check requests from sub-workflows."""
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
self,
|
||||
request: DomainCheckRequest,
|
||||
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, bool] | DomainCheckRequest]
|
||||
) -> None:
|
||||
"""Handle requests from sub-workflows."""
|
||||
print(f"🔍 Parent intercepting domain check for: {request.domain}")
|
||||
|
||||
if request.domain in self.approved_domains:
|
||||
print(f"✅ Domain '{request.domain}' is pre-approved locally!")
|
||||
return RequestResponse[DomainCheckRequest, bool].handled(True)
|
||||
print(f"❓ Domain '{request.domain}' unknown, forwarding to external service...")
|
||||
return RequestResponse[DomainCheckRequest, bool].forward()
|
||||
# Send response back to sub-workflow
|
||||
response = RequestResponse(
|
||||
data=True,
|
||||
original_request=request,
|
||||
request_id=request.request_id
|
||||
)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
print(f"❓ Domain '{request.domain}' unknown, forwarding to external service...")
|
||||
# Forward to external handler
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
@@ -233,7 +242,7 @@ async def run_example() -> None:
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(orchestrator)
|
||||
.add_edge(orchestrator, workflow_executor)
|
||||
.add_edge(workflow_executor, orchestrator)
|
||||
.add_edge(workflow_executor, orchestrator) # For ValidationResult collection and request interception
|
||||
# Add edges for external request handling
|
||||
.add_edge(orchestrator, main_request_info)
|
||||
.add_edge(main_request_info, workflow_executor) # Route external responses to sub-workflow
|
||||
|
||||
Reference in New Issue
Block a user