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:
Eric Zhu
2025-09-24 20:43:37 -07:00
committed by GitHub
Unverified
parent 39e071c430
commit 366a7f7d47
14 changed files with 397 additions and 1131 deletions
@@ -14,11 +14,10 @@ class SampleRequest(RequestInfoMessage):
def test_decode_dataclass_with_nested_request() -> None:
original = RequestResponse[SampleRequest, str].handled("approve")
original = RequestResponse[SampleRequest, str].with_correlation(
original,
SampleRequest(request_id="abc", prompt="prompt"),
"abc",
original = RequestResponse[SampleRequest, str](
data="approve",
original_request=SampleRequest(request_id="abc", prompt="prompt"),
request_id="abc",
)
encoded = _encode_checkpoint_value(original)
@@ -32,11 +31,10 @@ def test_decode_dataclass_with_nested_request() -> None:
def test_is_instance_of_coerces_request_response_original_request_dict() -> None:
response = RequestResponse[SampleRequest, str].handled("approve")
response = RequestResponse[SampleRequest, str].with_correlation(
response,
SampleRequest(request_id="req-1", prompt="prompt"),
"req-1",
response = RequestResponse[SampleRequest, str](
data="approve",
original_request=SampleRequest(request_id="req-1", prompt="prompt"),
request_id="req-1",
)
# Simulate checkpoint decode fallback leaving a dict
@@ -171,11 +171,10 @@ def test_pending_requests_from_checkpoint_and_summary() -> None:
request = SimpleApproval(prompt="Review draft", draft="Draft text", iteration=3)
request.request_id = "req-42"
response = RequestResponse[SimpleApproval, str].handled("approve")
response = RequestResponse[SimpleApproval, str].with_correlation(
response,
request,
request.request_id,
response = RequestResponse[SimpleApproval, str](
data="approve",
original_request=request,
request_id=request.request_id,
)
encoded_response = _encode_checkpoint_value(response)
@@ -1,108 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from typing_extensions import Never
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
)
@dataclass
class SimpleRequest:
"""Simple request for testing."""
text: str
@dataclass
class SimpleResponse:
"""Simple response for testing."""
result: str
class SimpleSubExecutor(Executor):
"""Simple executor for sub-workflow."""
def __init__(self):
super().__init__(id="simple_sub")
@handler
async def process(self, request: SimpleRequest, ctx: WorkflowContext[Never, SimpleResponse]) -> None:
"""Process a simple request."""
# Just echo back with prefix and complete
response = SimpleResponse(result=f"processed: {request.text}")
await ctx.yield_output(response)
class SimpleParent(Executor):
"""Simple parent executor."""
result: SimpleResponse | None = None
def __init__(self):
super().__init__(id="simple_parent")
@handler
async def start(self, text: str, ctx: WorkflowContext[SimpleRequest]) -> None:
"""Start the process."""
request = SimpleRequest(text=text)
await ctx.send_message(request, target_id="sub_workflow")
@handler
async def collect(self, response: SimpleResponse, ctx: WorkflowContext) -> None:
"""Collect the result."""
self.result = response
async def test_simple_sub_workflow():
"""Test the simplest possible sub-workflow."""
# Create sub-workflow with dummy executor to satisfy validation
sub_executor = SimpleSubExecutor()
class DummyExecutor(Executor):
def __init__(self):
super().__init__(id="dummy")
@handler
async def process(self, message: object, ctx: WorkflowContext) -> None:
pass # Do nothing
dummy = DummyExecutor()
sub_workflow = (
WorkflowBuilder()
.set_start_executor(sub_executor)
.add_edge(sub_executor, dummy) # Add edge to satisfy validation
.build()
)
# Create parent workflow
parent = SimpleParent()
workflow_executor = WorkflowExecutor(sub_workflow, id="sub_workflow")
main_workflow = (
WorkflowBuilder()
.set_start_executor(parent)
.add_edge(parent, workflow_executor)
.add_edge(workflow_executor, parent)
.build()
)
# Run the workflow
await main_workflow.run("hello world")
# Check result
assert parent.result is not None
assert parent.result.result == "processed: hello world"
if __name__ == "__main__":
# Run the simple test
asyncio.run(test_simple_sub_workflow())
@@ -1,6 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from typing import Any
@@ -12,11 +11,11 @@ from agent_framework import (
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
intercepts_request,
)
@@ -45,6 +44,61 @@ class ValidationResult:
reason: str
# Test helper functions
def create_email_validation_workflow() -> Workflow:
"""Create a standard email validation workflow."""
email_validator = EmailValidator()
email_request_info = RequestInfoExecutor(id="email_request_info")
return (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, email_request_info)
.add_edge(email_request_info, email_validator)
.build()
)
class BasicParent(Executor):
"""Basic parent executor for simple sub-workflow tests."""
result: ValidationResult | None = Field(default=None)
cache: dict[str, bool] = Field(default_factory=dict)
def __init__(self, cache: dict[str, bool] | None = None, **kwargs: Any):
if cache is not None:
kwargs["cache"] = cache
super().__init__(id="basic_parent", **kwargs)
@handler
async def start(self, email: str, ctx: WorkflowContext[EmailValidationRequest]) -> None:
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
@handler
async def handle_domain_request(
self,
request: DomainCheckRequest,
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
) -> None:
"""Handle requests from sub-workflows with optional caching."""
domain_request = request
if domain_request.domain in self.cache:
# Return cached result
response = RequestResponse(
data=self.cache[domain_request.domain], original_request=request, request_id=request.request_id
)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
# Not in cache, forward to external
await ctx.send_message(request)
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
self.result = result
# Test executors
class EmailValidator(Executor):
"""Validates email addresses in a sub-workflow."""
@@ -54,7 +108,7 @@ class EmailValidator(Executor):
@handler
async def validate_request(
self, request: EmailValidationRequest, ctx: WorkflowContext[RequestInfoMessage, ValidationResult]
self, request: EmailValidationRequest, ctx: WorkflowContext[DomainCheckRequest, ValidationResult]
) -> None:
"""Validate an email address."""
# Extract domain and check if it's approved
@@ -101,17 +155,23 @@ class ParentOrchestrator(Executor):
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
@intercepts_request
async def check_domain(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
"""Intercept domain check requests from sub-workflows."""
# Check if we know this domain
if request.domain in self.approved_domains:
return RequestResponse[DomainCheckRequest, bool].handled(True)
@handler
async def handle_domain_request(
self,
request: DomainCheckRequest,
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
) -> None:
"""Handle requests from sub-workflows."""
domain_request = request
# We don't know this domain, forward to external
return RequestResponse[DomainCheckRequest, bool].forward()
# Check if we know this domain
if domain_request.domain in self.approved_domains:
# 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:
# We don't know this domain, forward to external
await ctx.send_message(request)
@handler
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
@@ -122,34 +182,10 @@ class ParentOrchestrator(Executor):
async def test_basic_sub_workflow() -> None:
"""Test basic sub-workflow execution without interception."""
# Create sub-workflow
email_validator = EmailValidator()
email_request_info = RequestInfoExecutor(id="email_request_info")
validation_workflow = (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, email_request_info)
.add_edge(email_request_info, email_validator)
.build()
)
validation_workflow = create_email_validation_workflow()
# Create parent workflow without interception
class SimpleParent(Executor):
result: ValidationResult | None = Field(default=None)
def __init__(self, **kwargs: Any):
super().__init__(id="simple_parent", **kwargs)
@handler
async def start(self, email: str, ctx: WorkflowContext[EmailValidationRequest]) -> None:
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
self.result = result
parent = SimpleParent()
parent = BasicParent()
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
main_request_info = RequestInfoExecutor(id="main_request_info")
@@ -159,7 +195,7 @@ async def test_basic_sub_workflow() -> None:
.add_edge(parent, workflow_executor)
.add_edge(workflow_executor, parent)
.add_edge(workflow_executor, main_request_info)
.add_edge(main_request_info, workflow_executor) # CRITICAL: For SubWorkflowResponse routing
.add_edge(main_request_info, workflow_executor) # CRITICAL: For RequestResponse routing
.build()
)
@@ -184,21 +220,12 @@ async def test_basic_sub_workflow() -> None:
async def test_sub_workflow_with_interception():
"""Test sub-workflow with parent interception of requests."""
"""Test sub-workflow with parent interception and conditional forwarding."""
# Create sub-workflow
email_validator = EmailValidator()
email_request_info = RequestInfoExecutor(id="email_request_info")
validation_workflow = create_email_validation_workflow()
validation_workflow = (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, email_request_info)
.add_edge(email_request_info, email_validator)
.build()
)
# Create parent workflow with interception
parent = ParentOrchestrator(approved_domains={"example.com", "internal.org"})
# Create parent workflow with interception cache
parent = BasicParent(cache={"example.com": True, "internal.org": True})
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
parent_request_info = RequestInfoExecutor(id="request_info")
@@ -208,29 +235,23 @@ async def test_sub_workflow_with_interception():
.add_edge(parent, workflow_executor)
.add_edge(workflow_executor, parent)
.add_edge(parent, parent_request_info) # For forwarded requests
.add_edge(parent_request_info, workflow_executor) # For SubWorkflowResponse routing
.add_edge(parent_request_info, workflow_executor) # For RequestResponse routing
.build()
)
# Test 1: Email with known domain (intercepted)
result = await main_workflow.run(["user@example.com"])
# Should complete without external requests
# Test 1: Email with cached domain (intercepted)
result = await main_workflow.run("user@example.com")
request_events = result.get_request_info_events()
assert len(request_events) == 0 # No external requests, handled internally
assert len(request_events) == 0 # No external requests, handled from cache
assert parent.result is not None
assert parent.result.email == "user@example.com"
assert parent.result.is_valid is True
assert len(parent.results) == 1
assert parent.results[0].email == "user@example.com"
assert parent.results[0].is_valid is True
assert parent.results[0].reason == "Domain approved"
# Test 2: Email with unknown domain (forwarded)
parent.results.clear()
result = await main_workflow.run(["user@unknown.com"])
# Should have external request
# Test 2: Email with unknown domain (forwarded to external)
parent.result = None
result = await main_workflow.run("user@unknown.com")
request_events = result.get_request_info_events()
assert len(request_events) == 1
assert len(request_events) == 1 # Forwarded to external
assert isinstance(request_events[0].data, DomainCheckRequest)
assert request_events[0].data.domain == "unknown.com"
@@ -238,89 +259,18 @@ async def test_sub_workflow_with_interception():
await main_workflow.send_responses({
request_events[0].request_id: False # Domain not approved
})
assert parent.result is not None
assert parent.result.email == "user@unknown.com"
assert parent.result.is_valid is False
assert len(parent.results) == 1
assert parent.results[0].email == "user@unknown.com"
assert parent.results[0].is_valid is False
assert parent.results[0].reason == "Domain not approved"
async def test_conditional_forwarding() -> None:
"""Test conditional forwarding with RequestResponse.forward()."""
class ConditionalParent(Executor):
"""Parent that conditionally handles requests."""
cache: dict[str, bool] = Field(default_factory=lambda: {"cached.com": True})
result: ValidationResult | None = Field(default=None)
def __init__(self, **kwargs: Any):
super().__init__(id="conditional_parent", **kwargs)
@handler
async def start(self, email: str, ctx: WorkflowContext[EmailValidationRequest]) -> None:
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
@intercepts_request
async def check_domain(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
"""Check cache first, then forward if not found."""
if request.domain in self.cache:
# Return cached result
return RequestResponse[DomainCheckRequest, bool].handled(self.cache[request.domain])
# Not in cache, forward to external
return RequestResponse[DomainCheckRequest, bool].forward()
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
self.result = result
# Setup workflows
email_validator = EmailValidator()
request_info = RequestInfoExecutor(id="request_info")
validation_workflow = (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, request_info)
.add_edge(request_info, email_validator)
.build()
)
parent = ConditionalParent()
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
parent_request_info = RequestInfoExecutor(id="request_info")
main_workflow = (
WorkflowBuilder()
.set_start_executor(parent)
.add_edge(parent, workflow_executor)
.add_edge(workflow_executor, parent)
.add_edge(parent, parent_request_info)
.add_edge(parent_request_info, workflow_executor) # For SubWorkflowResponse routing
.build()
)
# Test cached domain
result = await main_workflow.run("user@cached.com")
# Test 3: Another cached domain
parent.result = None
result = await main_workflow.run("user@internal.org")
request_events = result.get_request_info_events()
assert len(request_events) == 0 # Handled from cache
assert parent.result is not None
assert parent.result.is_valid is True
# Test uncached domain
parent.result = None
result = await main_workflow.run("user@new.com")
request_events = result.get_request_info_events()
assert len(request_events) == 1 # Forwarded to external
await main_workflow.send_responses({request_events[0].request_id: True})
assert parent.result is not None
assert parent.result.is_valid is True
async def test_workflow_scoped_interception() -> None:
"""Test interception scoped to specific sub-workflows."""
@@ -339,42 +289,41 @@ async def test_workflow_scoped_interception() -> None:
await ctx.send_message(EmailValidationRequest(email=data["email1"]), target_id="workflow_a")
await ctx.send_message(EmailValidationRequest(email=data["email2"]), target_id="workflow_b")
@intercepts_request(from_workflow="workflow_a")
async def check_domain_a(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
"""Strict rules for workflow A."""
if request.domain == "strict.com":
return RequestResponse[DomainCheckRequest, bool].handled(True)
return RequestResponse[DomainCheckRequest, bool].forward()
@handler
async def handle_domain_request(
self,
request: DomainCheckRequest,
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
) -> None:
domain_request = request
@intercepts_request(from_workflow="workflow_b")
async def check_domain_b(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
"""Lenient rules for workflow B."""
if request.domain.endswith(".com"):
return RequestResponse[DomainCheckRequest, bool].handled(True)
return RequestResponse[DomainCheckRequest, bool].forward()
if request.source_executor_id == "workflow_a":
# Strict rules for workflow A
if domain_request.domain == "strict.com":
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
# Forward to external
await ctx.send_message(request)
elif request.source_executor_id == "workflow_b":
# Lenient rules for workflow B
if domain_request.domain.endswith(".com"):
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
# Forward to external
await ctx.send_message(request)
else:
# Unknown source, forward to external
await ctx.send_message(request)
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
self.results[result.email] = result
# Create two identical sub-workflows
def create_validation_workflow():
validator = EmailValidator()
request_info = RequestInfoExecutor(id="request_info")
return (
WorkflowBuilder()
.set_start_executor(validator)
.add_edge(validator, request_info)
.add_edge(request_info, validator)
.build()
)
workflow_a = create_validation_workflow()
workflow_b = create_validation_workflow()
workflow_a = create_email_validation_workflow()
workflow_b = create_email_validation_workflow()
parent = MultiWorkflowParent()
executor_a = WorkflowExecutor(workflow_a, "workflow_a")
@@ -389,8 +338,8 @@ async def test_workflow_scoped_interception() -> None:
.add_edge(executor_a, parent)
.add_edge(executor_b, parent)
.add_edge(parent, parent_request_info)
.add_edge(parent_request_info, executor_a) # For SubWorkflowResponse routing
.add_edge(parent_request_info, executor_b) # For SubWorkflowResponse routing
.add_edge(parent_request_info, executor_a) # For RequestResponse routing
.add_edge(parent_request_info, executor_b) # For RequestResponse routing
.build()
)
@@ -432,16 +381,7 @@ async def test_concurrent_sub_workflow_execution() -> None:
self.results.append(result)
# Create sub-workflow for email validation
email_validator = EmailValidator()
email_request_info = RequestInfoExecutor(id="email_request_info")
validation_workflow = (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, email_request_info)
.add_edge(email_request_info, email_validator)
.build()
)
validation_workflow = create_email_validation_workflow()
# Create parent workflow
processor = ConcurrentProcessor()
@@ -454,7 +394,7 @@ async def test_concurrent_sub_workflow_execution() -> None:
.add_edge(processor, workflow_executor)
.add_edge(workflow_executor, processor)
.add_edge(workflow_executor, parent_request_info) # For external requests
.add_edge(parent_request_info, workflow_executor) # For SubWorkflowResponse routing
.add_edge(parent_request_info, workflow_executor) # For RequestResponse routing
.build()
)
@@ -497,12 +437,3 @@ async def test_concurrent_sub_workflow_execution() -> None:
# Verify that concurrent executions were properly isolated
# (This is implicitly tested by the fact that we got correct results for all emails)
if __name__ == "__main__":
# Run tests
asyncio.run(test_basic_sub_workflow())
asyncio.run(test_sub_workflow_with_interception())
asyncio.run(test_conditional_forwarding())
asyncio.run(test_workflow_scoped_interception())
asyncio.run(test_concurrent_sub_workflow_execution())
@@ -95,7 +95,8 @@ def test_request_response_type() -> None:
"""Test RequestResponse generic type checking."""
request_instance = RequestResponse[RequestInfoMessage, str](
is_handled=False,
data="approve",
request_id="req-1",
original_request=RequestInfoMessage(),
)