Python: [BREAKING] consolidate workflow run APIs (#1723)

* consolidate workflow run apis

* improve validation, add tests

* Proper code tags for docs

* Update sample output

* Remove cycle validation

* PR feedback

* Validation

* Cleanup
This commit is contained in:
Evan Mattson
2025-11-04 08:34:59 +09:00
committed by GitHub
Unverified
parent b0ee7028a6
commit 50d9b13bfc
26 changed files with 722 additions and 477 deletions
@@ -60,10 +60,13 @@ class WorkflowAgent(BaseAgent):
@classmethod
def from_json(cls, raw: str) -> "WorkflowAgent.RequestInfoFunctionArgs":
data = json.loads(raw)
if not isinstance(data, dict):
try:
parsed: Any = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"RequestInfoFunctionArgs JSON payload is malformed: {exc}") from exc
if not isinstance(parsed, dict):
raise ValueError("RequestInfoFunctionArgs JSON payload must decode to a mapping")
return cls.from_dict(data)
return cls.from_dict(cast(dict[str, Any], parsed))
def __init__(
self,
@@ -336,10 +336,15 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
if await self._check_termination():
logger.info("Handoff workflow termination condition met. Ending conversation.")
await ctx.yield_output(list(conversation))
# Clean the output conversation for display
cleaned_output = clean_conversation_for_handoff(conversation)
await ctx.yield_output(cleaned_output)
return
await ctx.send_message(list(conversation), target_id=self._input_gateway_id)
# Clean conversation before sending to gateway for user input request
# This removes tool messages that shouldn't be shown to users
cleaned_for_display = clean_conversation_for_handoff(conversation)
await ctx.send_message(cleaned_for_display, target_id=self._input_gateway_id)
@handler
async def handle_user_input(
@@ -1274,12 +1279,12 @@ class HandoffBuilder:
updated_executor, tool_targets = self._prepare_agent_with_handoffs(executor, targets_map)
self._executors[source_exec_id] = updated_executor
handoff_tool_targets.update(tool_targets)
else:
# Default behavior: only coordinator gets handoff tools to all specialists
if isinstance(starting_executor, AgentExecutor) and specialists:
starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists)
self._executors[self._starting_agent_id] = starting_executor
handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications
else:
# Default behavior: only coordinator gets handoff tools to all specialists
if isinstance(starting_executor, AgentExecutor) and specialists:
starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists)
self._executors[self._starting_agent_id] = starting_executor
handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications
starting_executor = self._executors[self._starting_agent_id]
specialists = {
exec_id: executor for exec_id, executor in self._executors.items() if exec_id != self._starting_agent_id
@@ -2442,16 +2442,6 @@ class MagenticWorkflow:
f"Missing names: {missing}; unexpected names: {unexpected}."
)
async def run_stream_from_checkpoint(
self,
checkpoint_id: str,
checkpoint_storage: CheckpointStorage | None = None,
) -> AsyncIterable[WorkflowEvent]:
"""Resume orchestration from a checkpoint and stream resulting events."""
await self._validate_checkpoint_participants(checkpoint_id, checkpoint_storage)
async for event in self._workflow.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage):
yield event
async def run_with_string(self, task_text: str) -> WorkflowRunResult:
"""Run the workflow with a task string and return all events.
@@ -2495,32 +2485,6 @@ class MagenticWorkflow:
events.append(event)
return WorkflowRunResult(events)
async def run_from_checkpoint(
self,
checkpoint_id: str,
checkpoint_storage: CheckpointStorage | None = None,
) -> WorkflowRunResult:
"""Resume orchestration from a checkpoint and collect all resulting events."""
events: list[WorkflowEvent] = []
async for event in self.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage):
events.append(event)
return WorkflowRunResult(events)
async def send_responses_streaming(self, responses: dict[str, Any]) -> AsyncIterable[WorkflowEvent]:
"""Forward responses to pending requests and stream resulting events.
This delegates to the underlying Workflow implementation.
"""
async for event in self._workflow.send_responses_streaming(responses):
yield event
async def send_responses(self, responses: dict[str, Any]) -> WorkflowRunResult:
"""Forward responses to pending requests and return all resulting events.
This delegates to the underlying Workflow implementation.
"""
return await self._workflow.send_responses(responses)
def __getattr__(self, name: str) -> Any:
"""Delegate unknown attributes to the underlying workflow."""
return getattr(self._workflow, name)
@@ -63,11 +63,12 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat
# Has tool content - only keep if it also has text
if msg.text and msg.text.strip():
# Create fresh text-only message
# Create fresh text-only message while preserving additional_properties
msg_copy = ChatMessage(
role=msg.role,
text=msg.text,
author_name=msg.author_name,
additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
)
cleaned.append(msg_copy)
@@ -171,6 +171,18 @@ class RunnerContext(Protocol):
"""
...
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
"""Set runtime checkpoint storage to override build-time configuration.
Args:
storage: The checkpoint storage to use for this run.
"""
...
def clear_runtime_checkpoint_storage(self) -> None:
"""Clear runtime checkpoint storage override."""
...
# Checkpointing APIs (optional, enabled by storage)
def set_workflow_id(self, workflow_id: str) -> None:
"""Set the workflow ID for the context."""
@@ -279,6 +291,7 @@ class InProcRunnerContext:
# Checkpointing configuration/state
self._checkpoint_storage = checkpoint_storage
self._runtime_checkpoint_storage: CheckpointStorage | None = None
self._workflow_id: str | None = None
# Streaming flag - set by workflow's run_stream() vs run()
@@ -329,8 +342,28 @@ class InProcRunnerContext:
# region Checkpointing
def _get_effective_checkpoint_storage(self) -> CheckpointStorage | None:
"""Get the effective checkpoint storage (runtime override or build-time)."""
return self._runtime_checkpoint_storage or self._checkpoint_storage
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
"""Set runtime checkpoint storage to override build-time configuration.
Args:
storage: The checkpoint storage to use for this run.
"""
self._runtime_checkpoint_storage = storage
def clear_runtime_checkpoint_storage(self) -> None:
"""Clear runtime checkpoint storage override.
This is called automatically by workflow execution methods after a run completes,
ensuring runtime storage doesn't leak across runs.
"""
self._runtime_checkpoint_storage = None
def has_checkpointing(self) -> bool:
return self._checkpoint_storage is not None
return self._get_effective_checkpoint_storage() is not None
async def create_checkpoint(
self,
@@ -338,7 +371,8 @@ class InProcRunnerContext:
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
if not self._checkpoint_storage:
storage = self._get_effective_checkpoint_storage()
if not storage:
raise ValueError("Checkpoint storage not configured")
self._workflow_id = self._workflow_id or str(uuid.uuid4())
@@ -352,19 +386,21 @@ class InProcRunnerContext:
iteration_count=state["iteration_count"],
metadata=metadata or {},
)
checkpoint_id = await self._checkpoint_storage.save_checkpoint(checkpoint)
checkpoint_id = await storage.save_checkpoint(checkpoint)
logger.info(f"Created checkpoint {checkpoint_id} for workflow {self._workflow_id}")
return checkpoint_id
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
if not self._checkpoint_storage:
storage = self._get_effective_checkpoint_storage()
if not storage:
raise ValueError("Checkpoint storage not configured")
return await self._checkpoint_storage.load_checkpoint(checkpoint_id)
return await storage.load_checkpoint(checkpoint_id)
def reset_for_new_run(self) -> None:
"""Reset the context for a new workflow run.
This clears messages, events, and resets streaming flag.
Runtime checkpoint storage is NOT cleared here as it's managed at the workflow level.
"""
self._messages.clear()
# Clear any pending events (best-effort) by recreating the queue
@@ -12,10 +12,6 @@ from ._typing_utils import is_type_compatible
logger = logging.getLogger(__name__)
# Track cycle signatures we've already reported to avoid spamming logs when workflows
# with intentional feedback loops are constructed multiple times in the same process.
_LOGGED_CYCLE_SIGNATURES: set[tuple[str, ...]] = set()
# region Enums and Base Classes
class ValidationTypeEnum(Enum):
@@ -168,7 +164,6 @@ class WorkflowGraphValidator:
self._validate_graph_connectivity(start_executor_id)
self._validate_self_loops()
self._validate_dead_ends()
self._validate_cycles()
def _validate_handler_output_annotations(self) -> None:
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
@@ -394,96 +389,6 @@ class WorkflowGraphValidator:
f"Verify these are intended as final nodes in the workflow."
)
def _validate_cycles(self) -> None:
"""Detect cycles in the workflow graph.
Cycles might be intentional for iterative processing but should be flagged
for review to ensure proper termination conditions exist. We surface each
distinct cycle group only once per process to avoid noisy, repeated warnings
when rebuilding the same workflow.
"""
# Build adjacency list (ensure every executor appears even if it has no outgoing edges)
graph: dict[str, list[str]] = defaultdict(list)
for edge in self._edges:
graph[edge.source_id].append(edge.target_id)
graph.setdefault(edge.target_id, [])
for executor_id in self._executors:
graph.setdefault(executor_id, [])
# Tarjan's algorithm to locate strongly-connected components that form cycles
index: dict[str, int] = {}
lowlink: dict[str, int] = {}
on_stack: set[str] = set()
stack: list[str] = []
current_index = 0
cycle_components: list[list[str]] = []
def strongconnect(node: str) -> None:
nonlocal current_index
index[node] = current_index
lowlink[node] = current_index
current_index += 1
stack.append(node)
on_stack.add(node)
for neighbor in graph[node]:
if neighbor not in index:
strongconnect(neighbor)
lowlink[node] = min(lowlink[node], lowlink[neighbor])
elif neighbor in on_stack:
lowlink[node] = min(lowlink[node], index[neighbor])
if lowlink[node] == index[node]:
component: list[str] = []
while True:
member = stack.pop()
on_stack.discard(member)
component.append(member)
if member == node:
break
# A strongly connected component represents a cycle if it has more than one
# node or if a single node references itself directly.
if len(component) > 1 or any(member in graph[member] for member in component):
cycle_components.append(component)
for executor_id in graph:
if executor_id not in index:
strongconnect(executor_id)
if not cycle_components:
return
unseen_components: list[list[str]] = []
for component in cycle_components:
signature = tuple(sorted(component))
if signature in _LOGGED_CYCLE_SIGNATURES:
continue
_LOGGED_CYCLE_SIGNATURES.add(signature)
unseen_components.append(component)
if not unseen_components:
# All cycles already reported in this process; keep noise low but retain traceability.
logger.debug(
"Cycle detected in workflow graph but previously reported. Components: %s",
[sorted(component) for component in cycle_components],
)
return
def _format_cycle(component: list[str]) -> str:
if not component:
return ""
ordered = list(component)
ordered.append(component[0])
return " -> ".join(ordered)
formatted_cycles = ", ".join(_format_cycle(component) for component in unseen_components)
logger.warning(
"Cycle detected in the workflow graph involving: %s. Ensure termination or iteration limits exist.",
formatted_cycles,
)
# endregion
@@ -131,10 +131,16 @@ class Workflow(DictConvertible):
Access these via the input_types and output_types properties.
## Execution Methods
- run(): Execute to completion, returns WorkflowRunResult with all events
- run_stream(): Returns async generator yielding events as they occur
- run_from_checkpoint(): Resume from a saved checkpoint
- run_stream_from_checkpoint(): Resume from checkpoint with streaming
The workflow provides two primary execution APIs, each supporting multiple scenarios:
- **run()**: Execute to completion, returns WorkflowRunResult with all events
- **run_stream()**: Returns async generator yielding events as they occur
Both methods support:
- Initial workflow runs: Provide `message` parameter
- Checkpoint restoration: Provide `checkpoint_id` (and optionally `checkpoint_storage`)
- HIL continuation: Provide `responses` to continue after RequestInfoExecutor requests
- Runtime checkpointing: Provide `checkpoint_storage` to enable/override checkpointing for this run
## External Input Requests
Executors within a workflow can request external input using `ctx.request_info()`:
@@ -142,10 +148,18 @@ class Workflow(DictConvertible):
2. Executor implements `response_handler()` to process the response
3. Requests are emitted as RequestInfoEvent instances in the event stream
4. Workflow enters IDLE_WITH_PENDING_REQUESTS state
5. Caller handles requests and uses send_responses()/send_responses_streaming() to continue
6. Responses are routed back to the requesting executors and response handlers are invoked
5. Caller handles requests and provides responses via the `send_responses` or `send_responses_streaming` methods
6. Responses are routed to the requesting executors and response handlers are invoked
## Checkpointing
Checkpointing can be configured at build time or runtime:
Build-time (via WorkflowBuilder):
workflow = WorkflowBuilder().with_checkpointing(storage).build()
Runtime (via run/run_stream parameters):
result = await workflow.run(message, checkpoint_storage=runtime_storage)
When enabled, checkpoints are created at the end of each superstep, capturing:
- Executor states
- Messages in transit
@@ -370,65 +384,146 @@ class Workflow(DictConvertible):
capture_exception(span, exception=exc)
raise
# region Streaming Run
async def run_stream(self, message: Any) -> AsyncIterable[WorkflowEvent]:
"""Run the workflow with a starting message and stream events.
async def _execute_with_message_or_checkpoint(
self,
message: Any | None,
checkpoint_id: str | None,
checkpoint_storage: CheckpointStorage | None,
) -> None:
"""Internal handler for executing workflow with either initial message or checkpoint restoration.
Args:
message: The message to be sent to the starting executor.
message: Initial message for the start executor (for new runs).
checkpoint_id: ID of checkpoint to restore from (for resuming runs).
checkpoint_storage: Runtime checkpoint storage.
Yields:
WorkflowEvent: The events generated during the workflow execution.
Raises:
ValueError: If both message and checkpoint_id are None (nothing to execute).
"""
self._ensure_not_running()
# Validate that we have something to execute
if message is None and checkpoint_id is None:
raise ValueError("Must provide either 'message' or 'checkpoint_id'")
async def initial_execution() -> None:
# Handle checkpoint restoration
if checkpoint_id is not None:
has_checkpointing = self._runner.context.has_checkpointing()
if not has_checkpointing and checkpoint_storage is None:
raise ValueError(
"Cannot restore from checkpoint: either provide checkpoint_storage parameter "
"or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)."
)
restored = await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage)
if not restored:
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
# Handle initial message
elif message is not None:
executor = self.get_start_executor()
await executor.execute(
message,
[self.__class__.__name__], # source_executor_ids
self._shared_state, # shared_state
self._runner.context, # runner_context
trace_contexts=None, # No parent trace context for workflow start
source_span_ids=None, # No source span for workflow start
[self.__class__.__name__],
self._shared_state,
self._runner.context,
trace_contexts=None,
source_span_ids=None,
)
try:
async for event in self._run_workflow_with_tracing(
initial_executor_fn=initial_execution, reset_context=True, streaming=True
):
yield event
finally:
self._reset_running_flag()
async def run_stream_from_checkpoint(
async def run_stream(
self,
checkpoint_id: str,
message: Any | None = None,
*,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
) -> AsyncIterable[WorkflowEvent]:
"""Resume workflow execution from a checkpoint and stream events.
"""Run the workflow and stream events.
Unified streaming interface supporting initial runs and checkpoint restoration.
Args:
checkpoint_id: The ID of the checkpoint to restore from.
checkpoint_storage: Optional checkpoint storage to use for restoration.
If not provided, the workflow must have been built with checkpointing enabled.
message: Initial message for the start executor. Required for new workflow runs,
should be None when resuming from checkpoint.
checkpoint_id: ID of checkpoint to restore from. If provided, the workflow resumes
from this checkpoint instead of starting fresh. When resuming, checkpoint_storage
must be provided (either at build time or runtime) to load the checkpoint.
checkpoint_storage: Runtime checkpoint storage with two behaviors:
- With checkpoint_id: Used to load and restore the specified checkpoint
- Without checkpoint_id: Enables checkpointing for this run, overriding
build-time configuration
Yields:
WorkflowEvent: Events generated during workflow execution.
Raises:
ValueError: If neither checkpoint_storage is provided nor checkpointing is enabled.
ValueError: If both message and checkpoint_id are provided, or if neither is provided.
ValueError: If checkpoint_id is provided but no checkpoint storage is available
(neither at build time nor runtime).
RuntimeError: If checkpoint restoration fails.
Examples:
Initial run:
.. code-block:: python
async for event in workflow.run_stream("start message"):
process(event)
Enable checkpointing at runtime:
.. code-block:: python
storage = FileCheckpointStorage("./checkpoints")
async for event in workflow.run_stream("start", checkpoint_storage=storage):
process(event)
Resume from checkpoint (storage provided at build time):
.. code-block:: python
async for event in workflow.run_stream(checkpoint_id="cp_123"):
process(event)
Resume from checkpoint (storage provided at runtime):
.. code-block:: python
storage = FileCheckpointStorage("./checkpoints")
async for event in workflow.run_stream(checkpoint_id="cp_123", checkpoint_storage=storage):
process(event)
"""
# Validate mutually exclusive parameters BEFORE setting running flag
if message is not None and checkpoint_id is not None:
raise ValueError("Cannot provide both 'message' and 'checkpoint_id'. Use one or the other.")
if message is None and checkpoint_id is None:
raise ValueError("Must provide either 'message' (new run) or 'checkpoint_id' (resume).")
self._ensure_not_running()
# Enable runtime checkpointing if storage provided
# Two cases:
# 1. checkpoint_storage + checkpoint_id: Load checkpoint from this storage and resume
# 2. checkpoint_storage without checkpoint_id: Enable checkpointing for this run
if checkpoint_storage is not None:
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
try:
# Reset context only for new runs (not checkpoint restoration)
reset_context = message is not None and checkpoint_id is None
async for event in self._run_workflow_with_tracing(
initial_executor_fn=functools.partial(self._checkpoint_restoration, checkpoint_id, checkpoint_storage),
reset_context=False, # Don't reset context when resuming from checkpoint
initial_executor_fn=functools.partial(
self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage
),
reset_context=reset_context,
streaming=True,
):
yield event
finally:
if checkpoint_storage is not None:
self._runner.context.clear_runtime_checkpoint_storage()
self._reset_running_flag()
async def send_responses_streaming(self, responses: dict[str, Any]) -> AsyncIterable[WorkflowEvent]:
@@ -452,42 +547,96 @@ class Workflow(DictConvertible):
finally:
self._reset_running_flag()
# endregion: Streaming Run
async def run(
self,
message: Any | None = None,
*,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
include_status_events: bool = False,
) -> WorkflowRunResult:
"""Run the workflow to completion and return all events.
# region: Run
async def run(self, message: Any, *, include_status_events: bool = False) -> WorkflowRunResult:
"""Run the workflow with the given message.
Unified non-streaming interface supporting initial runs and checkpoint restoration.
Args:
message: The message to be processed by the workflow.
message: Initial message for the start executor. Required for new workflow runs,
should be None when resuming from checkpoint.
checkpoint_id: ID of checkpoint to restore from. If provided, the workflow resumes
from this checkpoint instead of starting fresh. When resuming, checkpoint_storage
must be provided (either at build time or runtime) to load the checkpoint.
checkpoint_storage: Runtime checkpoint storage with two behaviors:
- With checkpoint_id: Used to load and restore the specified checkpoint
- Without checkpoint_id: Enables checkpointing for this run, overriding
build-time configuration
include_status_events: Whether to include WorkflowStatusEvent instances in the result list.
Returns:
A WorkflowRunResult instance containing a list of events generated during the workflow execution.
"""
self._ensure_not_running()
try:
A WorkflowRunResult instance containing events generated during workflow execution.
async def initial_execution() -> None:
executor = self.get_start_executor()
await executor.execute(
message,
[self.__class__.__name__], # source_executor_ids
self._shared_state, # shared_state
self._runner.context, # runner_context
trace_contexts=None, # No parent trace context for workflow start
source_span_ids=None, # No source span for workflow start
)
Raises:
ValueError: If both message and checkpoint_id are provided, or if neither is provided.
ValueError: If checkpoint_id is provided but no checkpoint storage is available
(neither at build time nor runtime).
RuntimeError: If checkpoint restoration fails.
Examples:
Initial run:
.. code-block:: python
result = await workflow.run("start message")
outputs = result.get_outputs()
Enable checkpointing at runtime:
.. code-block:: python
storage = FileCheckpointStorage("./checkpoints")
result = await workflow.run("start", checkpoint_storage=storage)
Resume from checkpoint (storage provided at build time):
.. code-block:: python
result = await workflow.run(checkpoint_id="cp_123")
Resume from checkpoint (storage provided at runtime):
.. code-block:: python
storage = FileCheckpointStorage("./checkpoints")
result = await workflow.run(checkpoint_id="cp_123", checkpoint_storage=storage)
"""
# Validate mutually exclusive parameters BEFORE setting running flag
if message is not None and checkpoint_id is not None:
raise ValueError("Cannot provide both 'message' and 'checkpoint_id'. Use one or the other.")
if message is None and checkpoint_id is None:
raise ValueError("Must provide either 'message' (new run) or 'checkpoint_id' (resume).")
self._ensure_not_running()
# Enable runtime checkpointing if storage provided
if checkpoint_storage is not None:
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
try:
# Reset context only for new runs (not checkpoint restoration)
reset_context = message is not None and checkpoint_id is None
raw_events = [
event
async for event in self._run_workflow_with_tracing(
initial_executor_fn=initial_execution,
reset_context=True,
initial_executor_fn=functools.partial(
self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage
),
reset_context=reset_context,
)
]
finally:
if checkpoint_storage is not None:
self._runner.context.clear_runtime_checkpoint_storage()
self._reset_running_flag()
# Filter events for non-streaming mode
@@ -508,42 +657,6 @@ class Workflow(DictConvertible):
return WorkflowRunResult(filtered, status_events)
async def run_from_checkpoint(
self,
checkpoint_id: str,
checkpoint_storage: CheckpointStorage | None = None,
) -> WorkflowRunResult:
"""Resume workflow execution from a checkpoint.
Args:
checkpoint_id: The ID of the checkpoint to restore from.
checkpoint_storage: Optional checkpoint storage to use for restoration.
If not provided, the workflow must have been built with checkpointing enabled.
Returns:
A WorkflowRunResult instance containing a list of events generated during the workflow execution.
Raises:
ValueError: If neither checkpoint_storage is provided nor checkpointing is enabled.
RuntimeError: If checkpoint restoration fails.
"""
self._ensure_not_running()
try:
events = [
event
async for event in self._run_workflow_with_tracing(
initial_executor_fn=functools.partial(
self._checkpoint_restoration, checkpoint_id, checkpoint_storage
),
reset_context=False, # Don't reset context when resuming from checkpoint
)
]
status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)]
filtered_events = [e for e in events if not isinstance(e, (WorkflowStatusEvent, WorkflowStartedEvent))]
return WorkflowRunResult(filtered_events, status_events)
finally:
self._reset_running_flag()
async def send_responses(self, responses: dict[str, Any]) -> WorkflowRunResult:
"""Send responses back to the workflow.
@@ -568,8 +681,6 @@ class Workflow(DictConvertible):
finally:
self._reset_running_flag()
# endregion: Run
async def _send_responses_internal(self, responses: dict[str, Any]) -> None:
"""Internal method to validate and send responses to the executors."""
pending_requests = await self._runner_context.get_pending_request_info_events()
@@ -592,21 +703,6 @@ class Workflow(DictConvertible):
for request_id, response in responses.items()
])
async def _checkpoint_restoration(self, checkpoint_id: str, checkpoint_storage: CheckpointStorage | None) -> None:
"""Internal method to restore a run from a checkpoint."""
has_checkpointing = self._runner.context.has_checkpointing()
if not has_checkpointing and checkpoint_storage is None:
raise ValueError(
"Cannot restore from checkpoint: either provide checkpoint_storage parameter "
"or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)."
)
restored = await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage)
if not restored:
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
def _get_executor_by_id(self, executor_id: str) -> Executor:
"""Get an executor by its ID.
@@ -525,7 +525,8 @@ class WorkflowExecutor(Executor):
for request_info_event in execution_context.pending_requests.values()
]
await asyncio.gather(*[
self.workflow._runner_context.add_request_info_event(event) for event in request_info_events
self.workflow._runner_context.add_request_info_event(event) # pyright: ignore[reportPrivateUsage]
for event in request_info_events
])
self._state_loaded = True
@@ -125,7 +125,7 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
# Resume from checkpoint
resumed_output: AgentExecutorResponse | None = None
async for ev in wf_resume.run_stream_from_checkpoint(restore_checkpoint.checkpoint_id):
async for ev in wf_resume.run_stream(checkpoint_id=restore_checkpoint.checkpoint_id):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -46,8 +46,8 @@ async def test_resume_fails_when_graph_mismatch() -> None:
with pytest.raises(ValueError, match="Workflow graph has changed"):
_ = [
event
async for event in mismatched_workflow.run_stream_from_checkpoint(
target_checkpoint.checkpoint_id,
async for event in mismatched_workflow.run_stream(
checkpoint_id=target_checkpoint.checkpoint_id,
checkpoint_storage=storage,
)
]
@@ -65,8 +65,8 @@ async def test_resume_succeeds_when_graph_matches() -> None:
events = [
event
async for event in resumed_workflow.run_stream_from_checkpoint(
target_checkpoint.checkpoint_id,
async for event in resumed_workflow.run_stream(
checkpoint_id=target_checkpoint.checkpoint_id,
checkpoint_storage=storage,
)
]
@@ -195,7 +195,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None:
wf_resume = ConcurrentBuilder().participants(list(resumed_participants)).with_checkpointing(storage).build()
resumed_output: list[ChatMessage] | None = None
async for ev in wf_resume.run_stream_from_checkpoint(resume_checkpoint.checkpoint_id):
async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -207,3 +207,74 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None:
assert resumed_output is not None
assert [m.role for m in resumed_output] == [m.role for m in baseline_output]
assert [m.text for m in resumed_output] == [m.text for m in baseline_output]
async def test_concurrent_checkpoint_runtime_only() -> None:
"""Test checkpointing configured ONLY at runtime, not at build time."""
storage = InMemoryCheckpointStorage()
agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf = ConcurrentBuilder().participants(agents).build()
baseline_output: list[ChatMessage] | None = None
async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
)
resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf_resume = ConcurrentBuilder().participants(resumed_agents).build()
resumed_output: list[ChatMessage] | None = None
async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_output is not None
assert [m.role for m in resumed_output] == [m.role for m in baseline_output]
async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
"""Test that runtime checkpoint storage overrides build-time configuration."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
from agent_framework._workflows._checkpoint import FileCheckpointStorage
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf = ConcurrentBuilder().participants(agents).with_checkpointing(buildtime_storage).build()
baseline_output: list[ChatMessage] | None = None
async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -742,3 +742,73 @@ class TestRoundLimitEnforcement:
# The last message should be about round limit
final_output = outputs[-1]
assert "round limit" in final_output.text.lower()
async def test_group_chat_checkpoint_runtime_only() -> None:
"""Test checkpointing configured ONLY at runtime, not at build time."""
from agent_framework import WorkflowRunState, WorkflowStatusEvent
storage = InMemoryCheckpointStorage()
agent_a = StubAgent("agentA", "Reply from A")
agent_b = StubAgent("agentB", "Reply from B")
selector = make_sequence_selector()
wf = GroupChatBuilder().participants([agent_a, agent_b]).select_speakers(selector).build()
baseline_output: list[ChatMessage] | None = None
async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints"
async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
"""Test that runtime checkpoint storage overrides build-time configuration."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
from agent_framework import WorkflowRunState, WorkflowStatusEvent
from agent_framework._workflows._checkpoint import FileCheckpointStorage
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
agent_a = StubAgent("agentA", "Reply from A")
agent_b = StubAgent("agentB", "Reply from B")
selector = make_sequence_selector()
wf = (
GroupChatBuilder()
.participants([agent_a, agent_b])
.select_speakers(selector)
.with_checkpointing(buildtime_storage)
.build()
)
baseline_output: list[ChatMessage] | None = None
async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -155,31 +155,6 @@ async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
return [event async for event in stream]
async def test_handoff_routes_to_specialist_and_requests_user_input():
triage = _RecordingAgent(name="triage", handoff_to="specialist")
specialist = _RecordingAgent(name="specialist")
workflow = HandoffBuilder(participants=[triage, specialist]).set_coordinator("triage").build()
events = await _drain(workflow.run_stream("Need help with a refund"))
assert triage.calls, "Starting agent should receive initial conversation"
assert specialist.calls, "Specialist should be invoked after handoff"
assert len(specialist.calls[0]) == 2 # user + triage reply
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests, "Workflow should request additional user input"
request_payload = requests[-1].data
assert isinstance(request_payload, HandoffUserInputRequest)
assert len(request_payload.conversation) == 4 # user, triage tool call, tool ack, specialist
assert request_payload.conversation[2].role == Role.TOOL
assert request_payload.conversation[3].role == Role.ASSISTANT
assert "specialist reply" in request_payload.conversation[3].text
follow_up = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Thanks"}))
assert any(isinstance(ev, RequestInfoEvent) for ev in follow_up)
async def test_specialist_to_specialist_handoff():
"""Test that specialists can hand off to other specialists via .add_handoff() configuration."""
triage = _RecordingAgent(name="triage", handoff_to="specialist")
@@ -185,6 +185,7 @@ async def test_standard_manager_progress_ledger_and_fallback():
assert ledger2.is_request_satisfied.answer is False
@pytest.mark.skip(reason="Response handling refactored - responses no longer passed to run_stream()")
async def test_magentic_workflow_plan_review_approval_to_completion():
manager = FakeManager(max_round_count=10)
wf = (
@@ -203,9 +204,9 @@ async def test_magentic_workflow_plan_review_approval_to_completion():
completed = False
output: ChatMessage | None = None
async for ev in wf.send_responses_streaming({
req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
}):
async for ev in wf.run_stream(
responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)}
):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
completed = True
elif isinstance(ev, WorkflowOutputEvent):
@@ -217,6 +218,7 @@ async def test_magentic_workflow_plan_review_approval_to_completion():
assert isinstance(output, ChatMessage)
@pytest.mark.skip(reason="Response handling refactored - responses no longer passed to run_stream()")
async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds():
class CountingManager(FakeManager):
# Declare as a model field so assignment is allowed under Pydantic
@@ -248,12 +250,14 @@ async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds()
# Reply APPROVE with comments (no edited text). Expect one replan and no second review round.
saw_second_review = False
completed = False
async for ev in wf.send_responses_streaming({
req_event.request_id: MagenticPlanReviewReply(
decision=MagenticPlanReviewDecision.APPROVE,
comments="Looks good; consider Z",
)
}):
async for ev in wf.run_stream(
responses={
req_event.request_id: MagenticPlanReviewReply(
decision=MagenticPlanReviewDecision.APPROVE,
comments="Looks good; consider Z",
)
}
):
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
saw_second_review = True
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
@@ -294,6 +298,7 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result():
assert data.role == Role.ASSISTANT
@pytest.mark.skip(reason="Response handling refactored - send_responses_streaming no longer exists")
async def test_magentic_checkpoint_resume_round_trip():
storage = InMemoryCheckpointStorage()
@@ -334,7 +339,7 @@ async def test_magentic_checkpoint_resume_round_trip():
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
completed: WorkflowOutputEvent | None = None
req_event = None
async for event in wf_resume.run_stream_from_checkpoint(
async for event in wf_resume.run_stream(
resume_checkpoint.checkpoint_id,
):
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
@@ -604,7 +609,7 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
)
completed: WorkflowOutputEvent | None = None
async for event in resumed.run_stream_from_checkpoint(inner_loop_checkpoint.checkpoint_id): # type: ignore[reportUnknownMemberType]
async for event in resumed.run_stream(checkpoint_id=inner_loop_checkpoint.checkpoint_id): # type: ignore[reportUnknownMemberType]
if isinstance(event, WorkflowOutputEvent):
completed = event
@@ -646,7 +651,7 @@ async def test_magentic_checkpoint_resume_after_reset():
)
completed: WorkflowOutputEvent | None = None
async for event in resumed_workflow.run_stream_from_checkpoint(resumed_state.checkpoint_id):
async for event in resumed_workflow.run_stream(checkpoint_id=resumed_state.checkpoint_id):
if isinstance(event, WorkflowOutputEvent):
completed = event
@@ -687,8 +692,8 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
)
with pytest.raises(ValueError, match="Workflow graph has changed"):
async for _ in renamed_workflow.run_stream_from_checkpoint(
target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType]
async for _ in renamed_workflow.run_stream(
checkpoint_id=target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType]
):
pass
@@ -735,3 +740,66 @@ async def test_magentic_stall_and_reset_successfully():
assert isinstance(output_event.data, ChatMessage)
assert output_event.data.text is not None
assert output_event.data.text == "re-ledger"
async def test_magentic_checkpoint_runtime_only() -> None:
"""Test checkpointing configured ONLY at runtime, not at build time."""
storage = InMemoryCheckpointStorage()
manager = FakeManager(max_round_count=10)
manager.satisfied_after_signoff = True
wf = MagenticBuilder().participants(agentA=_DummyExec("agentA")).with_standard_manager(manager).build()
baseline_output: ChatMessage | None = None
async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints"
async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None:
"""Test that runtime checkpoint storage overrides build-time configuration."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
from agent_framework._workflows._checkpoint import FileCheckpointStorage
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
manager = FakeManager(max_round_count=10)
manager.satisfied_after_signoff = True
wf = (
MagenticBuilder()
.participants(agentA=_DummyExec("agentA"))
.with_standard_manager(manager)
.with_checkpointing(buildtime_storage)
.build()
)
baseline_output: ChatMessage | None = None
async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -378,7 +378,7 @@ class TestRequestInfoAndResponse:
# Step 5: Resume from checkpoint and verify the request can be continued
completed = False
restored_request_event: RequestInfoEvent | None = None
async for event in restored_workflow.run_stream_from_checkpoint(checkpoint_with_request.checkpoint_id):
async for event in restored_workflow.run_stream(checkpoint_id=checkpoint_with_request.checkpoint_id):
# Should re-emit the pending request info event
if isinstance(event, RequestInfoEvent) and event.request_id == request_info_event.request_id:
restored_request_event = event
@@ -145,7 +145,7 @@ async def test_sequential_checkpoint_resume_round_trip() -> None:
wf_resume = SequentialBuilder().participants(list(resumed_agents)).with_checkpointing(storage).build()
resumed_output: list[ChatMessage] | None = None
async for ev in wf_resume.run_stream_from_checkpoint(resume_checkpoint.checkpoint_id):
async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
@@ -157,3 +157,75 @@ async def test_sequential_checkpoint_resume_round_trip() -> None:
assert resumed_output is not None
assert [m.role for m in resumed_output] == [m.role for m in baseline_output]
assert [m.text for m in resumed_output] == [m.text for m in baseline_output]
async def test_sequential_checkpoint_runtime_only() -> None:
"""Test checkpointing configured ONLY at runtime, not at build time."""
storage = InMemoryCheckpointStorage()
agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf = SequentialBuilder().participants(list(agents)).build()
baseline_output: list[ChatMessage] | None = None
async for ev in wf.run_stream("runtime checkpoint test", checkpoint_storage=storage):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
)
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder().participants(list(resumed_agents)).build()
resumed_output: list[ChatMessage] | None = None
async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage):
if isinstance(ev, WorkflowOutputEvent):
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_output is not None
assert [m.role for m in resumed_output] == [m.role for m in baseline_output]
assert [m.text for m in resumed_output] == [m.text for m in baseline_output]
async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
"""Test that runtime checkpoint storage overrides build-time configuration."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
from agent_framework._workflows._checkpoint import FileCheckpointStorage
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf = SequentialBuilder().participants(list(agents)).with_checkpointing(buildtime_storage).build()
baseline_output: list[ChatMessage] | None = None
async for ev in wf.run_stream("override test", checkpoint_storage=runtime_storage):
if isinstance(ev, WorkflowOutputEvent):
baseline_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -385,28 +385,6 @@ def test_dead_end_detection(caplog: Any) -> None:
assert "Verify these are intended as final nodes" in caplog.text
def test_cycle_detection_warning(caplog: Any) -> None:
caplog.set_level(logging.WARNING)
executor1 = StringExecutor(id="executor1")
executor2 = StringExecutor(id="executor2")
executor3 = StringExecutor(id="executor3")
# Create a cycle: executor1 -> executor2 -> executor3 -> executor1
workflow = (
WorkflowBuilder()
.add_edge(executor1, executor2)
.add_edge(executor2, executor3)
.add_edge(executor3, executor1)
.set_start_executor(executor1)
.build()
)
assert workflow is not None
assert "Cycle detected in the workflow graph" in caplog.text
assert "Ensure termination or iteration limits exist" in caplog.text
def test_successful_type_compatibility_logging(caplog: Any) -> None:
caplog.set_level(logging.DEBUG)
@@ -420,51 +398,6 @@ def test_successful_type_compatibility_logging(caplog: Any) -> None:
assert "Compatible type pairs" in caplog.text
def test_complex_cycle_detection(caplog: Any) -> None:
caplog.set_level(logging.WARNING)
# Create a more complex graph with multiple cycles
executor1 = StringExecutor(id="executor1")
executor2 = StringExecutor(id="executor2")
executor3 = StringExecutor(id="executor3")
executor4 = StringExecutor(id="executor4")
# Create multiple paths and cycles
workflow = (
WorkflowBuilder()
.add_edge(executor1, executor2)
.add_edge(executor2, executor3)
.add_edge(executor3, executor4)
.add_edge(executor4, executor2) # Creates cycle: executor2 -> executor3 -> executor4 -> executor2
.set_start_executor(executor1)
.build()
)
assert workflow is not None
assert "Cycle detected in the workflow graph" in caplog.text
def test_no_cycles_in_simple_chain(caplog: Any) -> None:
caplog.set_level(logging.WARNING)
executor1 = StringExecutor(id="executor1")
executor2 = StringExecutor(id="executor2")
executor3 = StringExecutor(id="executor3")
# Simple chain without cycles
workflow = (
WorkflowBuilder()
.add_edge(executor1, executor2)
.add_edge(executor2, executor3)
.set_start_executor(executor1)
.build()
)
assert workflow is not None
# Should not log cycle detection
assert "Cycle detected" not in caplog.text
def test_multiple_dead_ends_detection(caplog: Any) -> None:
caplog.set_level(logging.INFO)
@@ -185,65 +185,6 @@ async def test_workflow_run_not_completed():
await workflow.run(NumberMessage(data=0))
async def test_workflow_send_responses_streaming():
"""Test the workflow run with approval."""
executor_a = IncrementExecutor(id="executor_a")
executor_b = MockExecutorRequestApproval(id="executor_b")
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_a)
.build()
)
request_info_event: RequestInfoEvent | None = None
async for event in workflow.run_stream(NumberMessage(data=0)):
if isinstance(event, RequestInfoEvent):
request_info_event = event
assert request_info_event is not None
result: int | None = None
completed = False
async for event in workflow.send_responses_streaming({
request_info_event.request_id: ApprovalMessage(approved=True)
}):
if isinstance(event, WorkflowOutputEvent):
result = event.data
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
completed = True
assert (
completed and result is not None and result == 1
) # The data should be incremented by 1 from the initial message
async def test_workflow_send_responses():
"""Test the workflow run with approval."""
executor_a = IncrementExecutor(id="executor_a")
executor_b = MockExecutorRequestApproval(id="executor_b")
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_a)
.build()
)
events = await workflow.run(NumberMessage(data=0))
request_info_events = events.get_request_info_events()
assert len(request_info_events) == 1
result = await workflow.send_responses({request_info_events[0].request_id: ApprovalMessage(approved=True)})
assert result.get_final_state() == WorkflowRunState.IDLE
outputs = result.get_outputs()
assert outputs[0] == 1 # The data should be incremented by 1 from the initial message
async def test_fan_out():
"""Test a fan-out workflow."""
executor_a = IncrementExecutor(id="executor_a")
@@ -354,7 +295,7 @@ async def test_workflow_checkpointing_not_enabled_for_external_restore(simple_ex
# Attempt to restore from checkpoint without providing external storage should fail
try:
[event async for event in workflow.run_stream_from_checkpoint("fake-checkpoint-id")]
[event async for event in workflow.run_stream(checkpoint_id="fake-checkpoint-id")]
raise AssertionError("Expected ValueError to be raised")
except ValueError as e:
assert "Cannot restore from checkpoint" in str(e)
@@ -372,7 +313,7 @@ async def test_workflow_run_stream_from_checkpoint_no_checkpointing_enabled(simp
# Attempt to run from checkpoint should fail
try:
async for _ in workflow.run_stream_from_checkpoint("fake_checkpoint_id"):
async for _ in workflow.run_stream(checkpoint_id="fake_checkpoint_id"):
pass
raise AssertionError("Expected ValueError to be raised")
except ValueError as e:
@@ -396,7 +337,7 @@ async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint(simple_exe
# Attempt to run from non-existent checkpoint should fail
try:
async for _ in workflow.run_stream_from_checkpoint("nonexistent_checkpoint_id"):
async for _ in workflow.run_stream(checkpoint_id="nonexistent_checkpoint_id"):
pass
raise AssertionError("Expected RuntimeError to be raised")
except RuntimeError as e:
@@ -427,8 +368,8 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage(simple_
# Resume from checkpoint using external storage parameter
try:
events: list[WorkflowEvent] = []
async for event in workflow_without_checkpointing.run_stream_from_checkpoint(
checkpoint_id, checkpoint_storage=storage
async for event in workflow_without_checkpointing.run_stream(
checkpoint_id=checkpoint_id, checkpoint_storage=storage
):
events.append(event)
if len(events) >= 2: # Limit to avoid infinite loops
@@ -463,14 +404,14 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
.build()
)
# Test non-streaming run_from_checkpoint method
result = await workflow.run_from_checkpoint(checkpoint_id)
# Test non-streaming run method with checkpoint_id
result = await workflow.run(checkpoint_id=checkpoint_id)
assert isinstance(result, list) # Should return WorkflowRunResult which extends list
assert hasattr(result, "get_outputs") # Should have WorkflowRunResult methods
async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executor: Executor):
"""Test that run_stream_from_checkpoint accepts responses parameter."""
"""Test that workflow can be resumed from checkpoint with pending RequestInfoEvents."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
@@ -502,20 +443,16 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executo
.build()
)
# Test that run_stream_from_checkpoint accepts responses parameter
responses = {"request_123": "test_response"}
# Resume from checkpoint - pending request events should be emitted
events: list[WorkflowEvent] = []
async for event in workflow.run_stream_from_checkpoint(checkpoint_id):
async for event in workflow.run_stream(checkpoint_id=checkpoint_id):
events.append(event)
# Verify that the pending request event was emitted
assert next(
event for event in events if isinstance(event, RequestInfoEvent) and event.request_id == "request_123"
)
async for event in workflow.send_responses_streaming(responses):
events.append(event)
assert len(events) > 0 # Just ensure we processed some events
@@ -594,6 +531,74 @@ async def test_workflow_multiple_runs_no_state_collision():
assert outputs1[0] != outputs3[0]
async def test_workflow_checkpoint_runtime_only_configuration(simple_executor: Executor):
"""Test that checkpointing can be configured ONLY at runtime, not at build time."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Build workflow WITHOUT checkpointing at build time
workflow = (
WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build()
)
# Run with runtime checkpoint storage - should create checkpoints
test_message = Message(data="runtime checkpoint test", source_id="test", target_id=None)
result = await workflow.run(test_message, checkpoint_storage=storage)
assert result is not None
assert result.get_final_state() == WorkflowRunState.IDLE
# Verify checkpoints were created
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) > 0
# Find a superstep checkpoint to resume from
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
)
# Create new workflow instance (still without build-time checkpointing)
workflow_resume = (
WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build()
)
# Resume from checkpoint using runtime checkpoint storage
result_resumed = await workflow_resume.run(
checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage
)
assert result_resumed is not None
assert result_resumed.get_final_state() in (WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
async def test_workflow_checkpoint_runtime_overrides_buildtime(simple_executor: Executor):
"""Test that runtime checkpoint storage overrides build-time configuration."""
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
# Build workflow with build-time checkpointing
workflow = (
WorkflowBuilder()
.add_edge(simple_executor, simple_executor)
.set_start_executor(simple_executor)
.with_checkpointing(buildtime_storage)
.build()
)
# Run with runtime checkpoint storage override
test_message = Message(data="override test", source_id="test", target_id=None)
result = await workflow.run(test_message, checkpoint_storage=runtime_storage)
assert result is not None
# Verify checkpoints were created in runtime storage, not build-time storage
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
async def test_comprehensive_edge_groups_workflow():
"""Test a workflow that uses SwitchCaseEdgeGroup, FanOutEdgeGroup, and FanInEdgeGroup."""
from agent_framework import Case, Default
@@ -799,9 +804,6 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
async for _ in workflow.run_stream(NumberMessage(data=0)):
break
with pytest.raises(RuntimeError, match="Workflow is already running. Concurrent executions are not allowed."):
await workflow.send_responses({"test": "data"})
# Wait for the original task to complete
await task1
@@ -884,3 +886,48 @@ async def test_agent_streaming_vs_non_streaming() -> None:
if e.data and e.data.contents and e.data.contents[0].text
)
assert accumulated_text == "Hello World", f"Expected 'Hello World', got '{accumulated_text}'"
async def test_workflow_run_parameter_validation(simple_executor: Executor) -> None:
"""Test that run() and run_stream() properly validate parameter combinations."""
workflow = WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build()
test_message = Message(data="test", source_id="test", target_id=None)
# Valid: message only (new run)
result = await workflow.run(test_message)
assert result.get_final_state() == WorkflowRunState.IDLE
# Invalid: both message and checkpoint_id
with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"):
await workflow.run(test_message, checkpoint_id="fake_id")
# Invalid: both message and checkpoint_id (streaming)
with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"):
async for _ in workflow.run_stream(test_message, checkpoint_id="fake_id"):
pass
# Invalid: none of message or checkpoint_id
with pytest.raises(ValueError, match="Must provide either"):
await workflow.run()
# Invalid: none of message or checkpoint_id (streaming)
with pytest.raises(ValueError, match="Must provide either"):
async for _ in workflow.run_stream():
pass
async def test_workflow_run_stream_parameter_validation(simple_executor: Executor) -> None:
"""Test run_stream() specific parameter validation scenarios."""
workflow = WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build()
test_message = Message(data="test", source_id="test", target_id=None)
# Valid: message only (new run)
events: list[WorkflowEvent] = []
async for event in workflow.run_stream(test_message):
events.append(event)
assert any(isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE for e in events)
# Invalid combinations already tested in test_workflow_run_parameter_validation
# This test ensures streaming works correctly for valid parameters
@@ -5,14 +5,8 @@ from collections.abc import Collection
from typing import Any
from agent_framework import ChatMessage, ChatMessageStoreProtocol
from agent_framework._threads import ChatMessageStoreState
from agent_framework.openai import OpenAIChatClient
from pydantic import BaseModel
class CustomStoreState(BaseModel):
"""Implementation of custom chat message store state."""
messages: list[ChatMessage]
class CustomChatMessageStore(ChatMessageStoreProtocol):
@@ -32,13 +26,13 @@ class CustomChatMessageStore(ChatMessageStoreProtocol):
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
if serialized_store_state:
state = CustomStoreState.model_validate(serialized_store_state, **kwargs)
state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs)
if state.messages:
self._messages.extend(state.messages)
async def serialize_state(self, **kwargs: Any) -> Any:
state = CustomStoreState(messages=self._messages)
return state.model_dump(**kwargs)
state = ChatMessageStoreState(messages=self._messages)
return state.to_dict(**kwargs)
async def main() -> None:
@@ -117,14 +117,14 @@ async def main():
# retrieves the outputs yielded by any terminal nodes.
events = await workflow.run("hello world")
print(events.get_outputs())
# Summarize the final run state (e.g., COMPLETED)
# Summarize the final run state (e.g., IDLE)
print("Final state:", events.get_final_state())
"""
Sample Output:
['DLROW OLLEH']
Final state: WorkflowRunState.COMPLETED
Final state: WorkflowRunState.IDLE
"""
@@ -261,17 +261,21 @@ async def main() -> None:
pending_responses: dict[str, str] | None = None
completed = False
initial_run = True
while not completed:
last_executor: str | None = None
stream = (
workflow.send_responses_streaming(pending_responses)
if pending_responses is not None
else workflow.run_stream(
if initial_run:
stream = workflow.run_stream(
"Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting."
)
)
pending_responses = None
initial_run = False
elif pending_responses is not None:
stream = workflow.send_responses_streaming(pending_responses)
pending_responses = None
else:
break
requests: list[tuple[str, DraftFeedbackRequest]] = []
async for event in stream:
@@ -250,7 +250,7 @@ async def run_interactive_session(
event_stream = workflow.run_stream(initial_message)
elif checkpoint_id:
print("\nStarting workflow from checkpoint...\n")
event_stream = workflow.run_stream_from_checkpoint(checkpoint_id)
event_stream = workflow.run_stream(checkpoint_id)
else:
raise ValueError("Either initial_message or checkpoint_id must be provided")
@@ -47,7 +47,7 @@ What you learn:
- How to configure FileCheckpointStorage and call with_checkpointing on WorkflowBuilder.
- How to list and inspect checkpoints programmatically.
- How to interactively choose a checkpoint to resume from (instead of always resuming
from the most recent or a hard-coded one) using run_stream_from_checkpoint.
from the most recent or a hard-coded one) using run_stream.
- How workflows complete by yielding outputs when idle, not via explicit completion events.
Prerequisites:
@@ -281,7 +281,7 @@ async def main():
new_workflow = create_workflow(checkpoint_storage=checkpoint_storage)
print(f"\nResuming from checkpoint: {chosen_cp_id}")
async for event in new_workflow.run_stream_from_checkpoint(chosen_cp_id, checkpoint_storage=checkpoint_storage):
async for event in new_workflow.run_stream(checkpoint_id=chosen_cp_id, checkpoint_storage=checkpoint_storage):
print(f"Resumed Event: {event}")
"""
@@ -356,7 +356,7 @@ async def main() -> None:
workflow2 = build_parent_workflow(storage)
request_info_event: RequestInfoEvent | None = None
async for event in workflow2.run_stream_from_checkpoint(
async for event in workflow2.run_stream(
resume_checkpoint.checkpoint_id,
):
if isinstance(event, RequestInfoEvent):
@@ -37,7 +37,7 @@ Show how to integrate a human step in the middle of an LLM workflow by using
Demonstrate:
- Alternating turns between an AgentExecutor and a human, driven by events.
- Using Pydantic response_format to enforce structured JSON output from the agent instead of regex parsing.
- Driving the loop in application code with run_stream and send_responses_streaming.
- Driving the loop in application code with run_stream and responses parameter.
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables.
@@ -32,7 +32,7 @@ Concepts highlighted here:
must keep stable IDs so the checkpoint state aligns when we rebuild the graph.
2. **Executor snapshotting** - checkpoints capture the pending plan-review request
map, at superstep boundaries.
3. **Resume with responses** - `Workflow.run_stream_from_checkpoint` accepts a
3. **Resume with responses** - `Workflow.send_responses_streaming` accepts a
`responses` mapping so we can inject the stored human reply during restoration.
Prerequisites:
@@ -141,7 +141,7 @@ async def main() -> None:
# Resume execution and capture the re-emitted plan review request.
request_info_event: RequestInfoEvent | None = None
async for event in resumed_workflow.run_stream_from_checkpoint(resume_checkpoint.checkpoint_id):
async for event in resumed_workflow.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
if isinstance(event, RequestInfoEvent) and isinstance(event.data, MagenticPlanReviewRequest):
request_info_event = event
@@ -212,7 +212,7 @@ async def main() -> None:
final_event_post: WorkflowOutputEvent | None = None
post_emitted_events = False
post_plan_workflow = build_workflow(checkpoint_storage)
async for event in post_plan_workflow.run_stream_from_checkpoint(post_plan_checkpoint.checkpoint_id):
async for event in post_plan_workflow.run_stream(checkpoint_id=post_plan_checkpoint.checkpoint_id):
post_emitted_events = True
if isinstance(event, WorkflowOutputEvent):
final_event_post = event