[BREAKING] Python: Merge send_responses into run method (#3720)

* Streamline workflow run api with send responses in one method

* Fixes

* Address copilot feedback
This commit is contained in:
Evan Mattson
2026-02-06 22:32:38 +00:00
committed by GitHub
parent 15256bb616
commit a17f13598b
39 changed files with 561 additions and 335 deletions
@@ -267,9 +267,9 @@ async def test_agent_executor_tool_call_with_approval() -> None:
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
events = await workflow.send_responses({
approval_request.request_id: approval_request.data.to_function_approval_response(True)
})
events = await workflow.run(
responses={approval_request.request_id: approval_request.data.to_function_approval_response(True)}
)
# Assert
final_response = events.get_outputs()
@@ -303,9 +303,9 @@ async def test_agent_executor_tool_call_with_approval_streaming() -> None:
# Act
output: str | None = None
async for event in workflow.send_responses_streaming({
approval_request.request_id: approval_request.data.to_function_approval_response(True)
}):
async for event in workflow.run(
stream=True, responses={approval_request.request_id: approval_request.data.to_function_approval_response(True)}
):
if event.type == "output":
output = event.data
@@ -346,7 +346,7 @@ async def test_agent_executor_parallel_tool_call_with_approval() -> None:
approval_request.request_id: approval_request.data.to_function_approval_response(True) # type: ignore
for approval_request in events.get_request_info_events()
}
events = await workflow.send_responses(responses)
events = await workflow.run(responses=responses)
# Assert
final_response = events.get_outputs()
@@ -385,7 +385,7 @@ async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> No
}
output: str | None = None
async for event in workflow.send_responses_streaming(responses):
async for event in workflow.run(stream=True, responses=responses):
if event.type == "output":
output = event.data
@@ -192,7 +192,7 @@ class TestRequestInfoAndResponse:
# Send response and continue workflow
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
async for event in workflow.run(stream=True, responses={request_info_event.request_id: True}):
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
@@ -219,7 +219,7 @@ class TestRequestInfoAndResponse:
# Send response with calculated result
calculated_result = 31.0
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: calculated_result}):
async for event in workflow.run(stream=True, responses={request_info_event.request_id: calculated_result}):
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
@@ -254,7 +254,7 @@ class TestRequestInfoAndResponse:
# Send responses for both requests
responses = {approval_event.request_id: True, calc_event.request_id: 50.0}
completed = False
async for event in workflow.send_responses_streaming(responses):
async for event in workflow.run(stream=True, responses=responses):
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
@@ -276,7 +276,7 @@ class TestRequestInfoAndResponse:
# Deny the request
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: False}):
async for event in workflow.run(stream=True, responses={request_info_event.request_id: False}):
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
@@ -303,7 +303,7 @@ class TestRequestInfoAndResponse:
# Continue with response
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
async for event in workflow.run(stream=True, responses={request_info_event.request_id: True}):
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
@@ -395,9 +395,12 @@ class TestRequestInfoAndResponse:
# Step 6: Provide response to the restored request and complete the workflow
final_completed = False
async for event in restored_workflow.send_responses_streaming({
request_info_event.request_id: True # Approve the request
}):
async for event in restored_workflow.run(
stream=True,
responses={
request_info_event.request_id: True # Approve the request
},
):
if event.type == "status" and event.state == WorkflowRunState.IDLE:
final_completed = True
@@ -201,9 +201,11 @@ async def test_basic_sub_workflow() -> None:
assert request_events[0].data.domain == "example.com"
# Send response through the main workflow
await main_workflow.send_responses({
request_events[0].request_id: True # Domain is approved
})
await main_workflow.run(
responses={
request_events[0].request_id: True # Domain is approved
}
)
# Check result
assert parent.result is not None
@@ -245,9 +247,11 @@ async def test_sub_workflow_with_interception():
assert request_events[0].data.domain == "unknown.com"
# Send external response
await main_workflow.send_responses({
request_events[0].request_id: False # Domain not approved
})
await main_workflow.run(
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
@@ -447,7 +451,7 @@ async def test_concurrent_sub_workflow_execution() -> None:
# Send responses for all requests (approve all domains)
responses = {event.request_id: True for event in request_events}
await main_workflow.send_responses(responses)
await main_workflow.run(responses=responses)
# All results should be collected
assert len(processor.results) == len(emails)
@@ -613,7 +617,7 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
assert resumed_first_request_id == first_request_id
request_events: list[WorkflowEvent] = []
async for event in workflow2.send_responses_streaming({resumed_first_request_id: "first_answer"}):
async for event in workflow2.run(stream=True, responses={resumed_first_request_id: "first_answer"}):
if event.type == "request_info":
request_events.append(event)
@@ -13,6 +13,7 @@ from agent_framework._workflows._typing_utils import (
normalize_type_to_list,
resolve_type_annotation,
serialize_type,
try_coerce_to_type,
)
# region: normalize_type_to_list tests
@@ -420,3 +421,72 @@ def test_type_compatibility_complex() -> None:
# Incompatible nested structure
incompatible_target = list[dict[Union[str, bytes], int]]
assert not is_type_compatible(source, incompatible_target)
# region: try_coerce_to_type tests
def test_coerce_already_correct_type() -> None:
"""Values already matching the target type are returned as-is."""
assert try_coerce_to_type(42, int) == 42
assert try_coerce_to_type("hello", str) == "hello"
assert try_coerce_to_type(True, bool) is True
def test_coerce_int_to_float() -> None:
"""JSON integers should be coercible to float."""
result = try_coerce_to_type(1, float)
assert result == 1.0
assert isinstance(result, float)
def test_coerce_dict_to_dataclass() -> None:
"""Dicts (from JSON) should be coercible to dataclasses."""
@dataclass
class Point:
x: int
y: int
result = try_coerce_to_type({"x": 1, "y": 2}, Point)
assert isinstance(result, Point)
assert result.x == 1
assert result.y == 2
def test_coerce_dict_to_dataclass_bad_keys_returns_original() -> None:
"""Dicts with wrong keys should return the original dict, not raise."""
@dataclass
class Point:
x: int
y: int
original = {"a": 1, "b": 2}
result = try_coerce_to_type(original, Point)
assert result is original
def test_coerce_non_concrete_target_returns_original() -> None:
"""Union and other non-concrete types should return the original value."""
result = try_coerce_to_type(42, int | str)
assert result == 42
result = try_coerce_to_type({"x": 1}, Union[str, int])
assert result == {"x": 1}
def test_coerce_unrelated_types_returns_original() -> None:
"""Coercion between unrelated types should return the original value."""
assert try_coerce_to_type("hello", int) == "hello"
assert try_coerce_to_type(3.14, str) == 3.14
assert try_coerce_to_type([1, 2], dict) == [1, 2]
def test_coerce_any_returns_original() -> None:
"""Any target type should accept any value without coercion."""
assert try_coerce_to_type(42, Any) == 42
assert try_coerce_to_type({"k": "v"}, Any) == {"k": "v"}
# endregion: try_coerce_to_type tests
@@ -383,7 +383,7 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage(
try:
events: list[WorkflowEvent] = []
async for event in workflow_without_checkpointing.run(
checkpoint_id=checkpoint_id, checkpoint_storage=storage
checkpoint_id=checkpoint_id, checkpoint_storage=storage, stream=True
):
events.append(event)
if len(events) >= 2: # Limit to avoid infinite loops
@@ -952,11 +952,11 @@ async def test_workflow_run_parameter_validation(simple_executor: Executor) -> N
pass
# Invalid: none of message or checkpoint_id
with pytest.raises(ValueError, match="Must provide either"):
with pytest.raises(ValueError, match="Must provide at least one of"):
await workflow.run()
# Invalid: none of message or checkpoint_id (streaming)
with pytest.raises(ValueError, match="Must provide either"):
with pytest.raises(ValueError, match="Must provide at least one of"):
async for _ in workflow.run(stream=True):
pass
@@ -1174,8 +1174,8 @@ async def test_output_executors_filtering_with_fan_in() -> None:
assert outputs[0] == 40
async def test_output_executors_filtering_with_send_responses() -> None:
"""Test output filtering works correctly with send_responses method."""
async def test_output_executors_filtering_with_run_responses() -> None:
"""Test output filtering works correctly with run(responses=...) method."""
executor = MockExecutorRequestApproval(id="approval_executor")
workflow = WorkflowBuilder().set_start_executor(executor).with_output_from([executor]).build()
@@ -1189,7 +1189,7 @@ async def test_output_executors_filtering_with_send_responses() -> None:
# Send approval response
responses = {request_events[0].request_id: ApprovalMessage(approved=True)}
response_result = await workflow.send_responses(responses)
response_result = await workflow.run(responses=responses)
outputs = response_result.get_outputs()
# Output should be yielded since approval_executor is in output_executors
@@ -1197,8 +1197,8 @@ async def test_output_executors_filtering_with_send_responses() -> None:
assert outputs[0] == 42
async def test_output_executors_filtering_with_send_responses_streaming() -> None:
"""Test output filtering works correctly with send_responses_streaming method."""
async def test_output_executors_filtering_with_run_responses_streaming() -> None:
"""Test output filtering works correctly with run(responses=..., stream=True) method."""
executor = MockExecutorRequestApproval(id="approval_executor")
workflow = WorkflowBuilder().set_start_executor(executor).build()
@@ -1218,7 +1218,7 @@ async def test_output_executors_filtering_with_send_responses_streaming() -> Non
# Send approval response via streaming
responses = {request_events[0].request_id: ApprovalMessage(approved=True)}
output_events: list[WorkflowEvent] = []
async for event in workflow.send_responses_streaming(responses):
async for event in workflow.run(responses=responses, stream=True):
if event.type == "output":
output_events.append(event)