Files
agent-framework/python/packages/main/tests/workflow/test_runner.py
T
Eric Zhu 2133043f11 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>
2025-09-23 20:52:53 +00:00

170 lines
5.3 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
import pytest
from agent_framework import (
AgentExecutorResponse,
AgentRunResponse,
Executor,
WorkflowContext,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
)
from agent_framework._workflow._edge import SingleEdgeGroup
from agent_framework._workflow._runner import Runner
from agent_framework._workflow._runner_context import InProcRunnerContext, Message, RunnerContext
from agent_framework._workflow._shared_state import SharedState
@dataclass
class MockMessage:
"""A mock message for testing purposes."""
data: int
class MockExecutor(Executor):
"""A mock executor for testing purposes."""
@handler
async def mock_handler(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
if message.data < 10:
await ctx.send_message(MockMessage(data=message.data + 1))
else:
await ctx.yield_output(message.data)
pass
def test_create_runner():
"""Test creating a runner with edges and shared state."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
# Create a loop
edge_groups = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
runner = Runner(edge_groups, executors, shared_state=SharedState(), ctx=InProcRunnerContext())
assert runner.context is not None and isinstance(runner.context, RunnerContext)
async def test_runner_run_until_convergence():
"""Test running the runner with a simple workflow."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
# Create a loop
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
shared_state = SharedState()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, shared_state, ctx)
result: int | None = None
await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
shared_state, # shared_state
ctx, # runner_context
)
async for event in runner.run_until_convergence():
assert isinstance(event, WorkflowEvent)
if isinstance(event, WorkflowOutputEvent):
result = event.data
assert result is not None and result == 10
async def test_runner_run_until_convergence_not_completed():
"""Test running the runner with a simple workflow."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
# Create a loop
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
shared_state = SharedState()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, shared_state, ctx, max_iterations=5)
await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
shared_state, # shared_state
ctx, # runner_context
)
with pytest.raises(RuntimeError, match="Runner did not converge after 5 iterations."):
async for event in runner.run_until_convergence():
assert not isinstance(event, WorkflowStatusEvent) or event.state != WorkflowRunState.IDLE
async def test_runner_already_running():
"""Test that running the runner while it is already running raises an error."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
# Create a loop
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
shared_state = SharedState()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, shared_state, ctx)
await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
shared_state, # shared_state
ctx, # runner_context
)
with pytest.raises(RuntimeError, match="Runner is already running."):
async def _run():
async for _ in runner.run_until_convergence():
pass
await asyncio.gather(_run(), _run())
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
ctx = InProcRunnerContext()
runner = Runner([], {}, SharedState(), ctx)
await ctx.send_message(
Message(
data=AgentExecutorResponse("agent", AgentRunResponse()),
source_id="agent",
)
)
events: list[WorkflowEvent] = [event async for event in runner.run_until_convergence()]
# The runner should complete without errors when handling AgentExecutorResponse without targets
# No specific events are expected since there are no executors to process the message
assert isinstance(events, list) # Just verify the runner completed without errors