[BREAKING] Python: Refactor Checkpointing for runner and runner context (#1645)

* Refactor Checkpointing for runner and runner context

* exception

* Fix formatting

* Comments

* rename

* Add detailed doc string
This commit is contained in:
Tao Chen
2025-10-23 20:34:55 -07:00
committed by GitHub
Unverified
parent 3aa682082a
commit 31701dbb92
14 changed files with 280 additions and 281 deletions
@@ -11,14 +11,31 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Protocol
from ._const import DEFAULT_MAX_ITERATIONS
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class WorkflowCheckpoint:
"""Represents a complete checkpoint of workflow state."""
"""Represents a complete checkpoint of workflow state.
Checkpoints capture the full execution state of a workflow at a specific point,
enabling workflows to be paused and resumed.
Attributes:
checkpoint_id: Unique identifier for this checkpoint
workflow_id: Identifier of the workflow this checkpoint belongs to
timestamp: ISO 8601 timestamp when checkpoint was created
messages: Messages exchanged between executors
shared_state: Complete shared state including user data and executor states.
Executor states are stored under the reserved key '_executor_state'.
iteration_count: Current iteration number when checkpoint was created
metadata: Additional metadata (e.g., superstep info, graph signature)
version: Checkpoint format version
Note:
The shared_state dict may contain reserved keys managed by the framework.
See SharedState class documentation for details on reserved keys.
"""
checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4()))
workflow_id: str = ""
@@ -27,11 +44,9 @@ class WorkflowCheckpoint:
# Core workflow state
messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc]
shared_state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
executor_states: dict[str, dict[str, Any]] = field(default_factory=dict) # type: ignore[misc]
# Runtime state
iteration_count: int = 0
max_iterations: int = DEFAULT_MAX_ITERATIONS
# Metadata
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
@@ -8,6 +8,7 @@ from typing import Any
from ._checkpoint import WorkflowCheckpoint
from ._checkpoint_encoding import decode_checkpoint_value
from ._const import EXECUTOR_STATE_KEY
from ._request_info_executor import PendingRequestDetails, RequestInfoMessage, RequestResponse
logger = logging.getLogger(__name__)
@@ -33,7 +34,7 @@ def get_checkpoint_summary(
preview_width: int = 70,
) -> WorkflowCheckpointSummary:
targets = sorted(checkpoint.messages.keys())
executor_ids = sorted(checkpoint.executor_states.keys())
executor_ids = sorted(checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {}).keys())
pending = _pending_requests_from_checkpoint(checkpoint, request_executor_ids=request_executor_ids)
draft_preview: str | None = None
@@ -74,7 +75,7 @@ def _pending_requests_from_checkpoint(
pending: dict[str, PendingRequestDetails] = {}
for state in checkpoint.executor_states.values():
for state in checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {}).values():
if not isinstance(state, Mapping):
continue
inner = state.get("pending_requests")
@@ -1,3 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
DEFAULT_MAX_ITERATIONS = 100 # Default maximum iterations for workflow execution.
# Default maximum iterations for workflow execution.
DEFAULT_MAX_ITERATIONS = 100
# Key used to store executor state in shared state.
EXECUTOR_STATE_KEY = "_executor_state"
@@ -26,6 +26,7 @@ from agent_framework import (
from agent_framework._agents import BaseAgent
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
from ._events import WorkflowEvent
from ._executor import Executor, handler
from ._model_utils import DictConvertible, encode_value
@@ -2136,7 +2137,7 @@ class MagenticWorkflow:
return
# At this point, checkpoint is guaranteed to be WorkflowCheckpoint
executor_states = checkpoint.executor_states
executor_states: dict[str, Any] = checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {})
orchestrator_id = getattr(orchestrator, "id", "")
orchestrator_state = executor_states.get(orchestrator_id)
if orchestrator_state is None:
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._checkpoint_encoding import DATACLASS_MARKER, MODEL_MARKER, decode_checkpoint_value
from ._const import EXECUTOR_STATE_KEY
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
from ._events import WorkflowEvent
@@ -15,7 +16,6 @@ from ._executor import Executor
from ._runner_context import (
Message,
RunnerContext,
WorkflowState,
)
from ._shared_state import SharedState
@@ -68,16 +68,9 @@ class Runner:
"""Get the workflow context."""
return self._ctx
def mark_resumed(self, iteration: int | None = None, max_iterations: int | None = None) -> None:
"""Mark the runner as having resumed from a checkpoint.
Optionally set the current iteration and max iterations.
"""
self._resumed_from_checkpoint = True
if iteration is not None:
self._iteration = iteration
if max_iterations is not None:
self._max_iterations = max_iterations
def reset_iteration_count(self) -> None:
"""Reset the iteration count to zero."""
self._iteration = 0
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
"""Run the workflow until no more messages are sent."""
@@ -100,9 +93,6 @@ class Runner:
else:
logger.info("Skipping 'after_initial_execution' checkpoint because we resumed from a checkpoint")
# Initialize context with starting iteration state
await self._update_context_with_shared_state()
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
@@ -134,9 +124,6 @@ class Runner:
for event in await self._ctx.drain_events():
yield event
# Update context with current iteration state immediately
await self._update_context_with_shared_state()
logger.info(f"Completed superstep {self._iteration}")
# Create checkpoint after each superstep iteration
@@ -195,7 +182,6 @@ class Runner:
try:
# Auto-snapshot executor states
await self._auto_snapshot_executor_states()
await self._update_context_with_shared_state()
checkpoint_category = "initial" if checkpoint_type == "after_initial_execution" else "superstep"
metadata = {
"superstep": self._iteration,
@@ -203,7 +189,11 @@ class Runner:
}
if self.graph_signature_hash:
metadata["graph_signature"] = self.graph_signature_hash
checkpoint_id = await self._ctx.create_checkpoint(metadata=metadata)
checkpoint_id = await self._ctx.create_checkpoint(
self._shared_state,
self._iteration,
metadata=metadata,
)
logger.info(f"Created {checkpoint_type} checkpoint: {checkpoint_id}")
return checkpoint_id
except Exception as e:
@@ -213,6 +203,10 @@ class Runner:
async def _auto_snapshot_executor_states(self) -> None:
"""Populate executor state by calling snapshot hooks on executors if available.
TODO(@taochen#1614): this method is potentially problematic if executors also call
set_executor_state on the context directly. We should clarify the intended usage
pattern for executor state management.
Convention:
- If an executor defines an async or sync method `snapshot_state(self) -> dict`, use it.
- Else if it has a plain attribute `state` that is a dict, use that.
@@ -234,32 +228,13 @@ class Runner:
state_dict = state_attr # type: ignore[assignment]
except Exception as ex: # pragma: no cover
logger.debug(f"Executor {exec_id} snapshot_state failed: {ex}")
if state_dict is not None:
try:
await self._ctx.set_executor_state(exec_id, state_dict)
await self._set_executor_state(exec_id, state_dict)
except Exception as ex: # pragma: no cover
logger.debug(f"Failed to persist state for executor {exec_id}: {ex}")
async def _update_context_with_shared_state(self) -> None:
if not self._ctx.has_checkpointing():
return
try:
current_state = await self._ctx.get_workflow_state()
shared_state_data = {}
async with self._shared_state.hold():
if hasattr(self._shared_state, "_state"):
shared_state_data = dict(self._shared_state._state) # type: ignore[attr-defined]
current_state["shared_state"] = shared_state_data
current_state["iteration_count"] = self._iteration
current_state["max_iterations"] = self._max_iterations
await self._ctx.set_workflow_state(current_state)
except Exception as e:
logger.warning(f"Failed to update context with shared state: {e}")
async def restore_from_checkpoint(
self,
checkpoint_id: str,
@@ -304,20 +279,16 @@ class Runner:
checkpoint_id,
)
await self._restore_executor_states(checkpoint.executor_states)
state = _convert_checkpoint_to_workflow_state(checkpoint)
await self._ctx.set_workflow_state(state)
if checkpoint.workflow_id:
self._ctx.set_workflow_id(checkpoint.workflow_id)
self._workflow_id = checkpoint.workflow_id
# Restore shared state
await self._shared_state.import_state(checkpoint.shared_state)
# Restore executor states using the restored shared state
await self._restore_executor_states()
# Apply the checkpoint to the context
await self._ctx.apply_checkpoint(checkpoint)
# Mark the runner as resumed
self._mark_resumed(checkpoint.iteration_count)
await self._restore_shared_state_from_context()
self.mark_resumed(
iteration=checkpoint.iteration_count,
max_iterations=checkpoint.max_iterations,
)
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
return True
except ValueError:
@@ -326,12 +297,24 @@ class Runner:
logger.error(f"Failed to restore from checkpoint {checkpoint_id}: {e}")
return False
async def _restore_executor_states(self, executor_states: dict[str, dict[str, Any]]) -> None:
for exec_id, state in executor_states.items():
executor = self._executors.get(exec_id)
async def _restore_executor_states(self) -> None:
has_executor_states = await self._shared_state.has(EXECUTOR_STATE_KEY)
if not has_executor_states:
return
executor_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
if not isinstance(executor_states, dict):
raise ValueError("Executor states in shared state is not a dictionary. Unable to restore.")
for executor_id, state in executor_states.items():
if not isinstance(executor_id, str):
raise ValueError("Executor ID in executor states is not a string. Unable to restore.")
if not isinstance(state, dict):
raise ValueError(f"Executor state for {executor_id} is not a dictionary. Unable to restore.")
executor = self._executors.get(executor_id)
if not executor:
logger.debug(f"Executor {exec_id} not found during state restoration; skipping.")
continue
raise ValueError(f"Executor {executor_id} not found during state restoration.")
restored = False
restore_method = getattr(executor, "restore_state", None)
@@ -342,26 +325,10 @@ class Runner:
await maybe # type: ignore[arg-type]
restored = True
except Exception as ex: # pragma: no cover - defensive
logger.debug(f"Executor {exec_id} restore_state failed: {ex}")
raise ValueError(f"Executor {executor_id} restore_state failed: {ex}") from ex
if not restored:
logger.debug(f"Executor {exec_id} does not support state restoration; skipping.")
async def _restore_shared_state_from_context(self) -> None:
try:
restored_state = await self._ctx.get_workflow_state()
shared_state_data = restored_state.get("shared_state", {})
if shared_state_data and hasattr(self._shared_state, "_state"):
async with self._shared_state.hold():
self._shared_state._state.clear() # type: ignore[attr-defined]
self._shared_state._state.update(shared_state_data) # type: ignore[attr-defined]
self._iteration = restored_state.get("iteration_count", 0)
self._max_iterations = restored_state.get("max_iterations", self._max_iterations)
except Exception as e:
logger.warning(f"Failed to restore shared state from context: {e}")
logger.debug(f"Executor {executor_id} does not support state restoration; skipping.")
def _parse_edge_runners(self, edge_runners: list[EdgeRunner]) -> dict[str, list[EdgeRunner]]:
"""Parse the edge runners of the workflow into a mapping where each source executor ID maps to its edge runners.
@@ -413,13 +380,28 @@ class Runner:
return True
return False
def _mark_resumed(self, iteration: int) -> None:
"""Mark the runner as having resumed from a checkpoint.
def _convert_checkpoint_to_workflow_state(checkpoint: WorkflowCheckpoint) -> WorkflowState:
"""Helper function to convert a WorkflowCheckpoint to a WorkflowState."""
return {
"messages": checkpoint.messages,
"shared_state": checkpoint.shared_state,
"executor_states": checkpoint.executor_states,
"iteration_count": checkpoint.iteration_count,
"max_iterations": checkpoint.max_iterations,
}
Optionally set the current iteration and max iterations.
"""
self._resumed_from_checkpoint = True
self._iteration = iteration
async def _set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
"""Store executor state in shared state under a reserved key.
Executors call this with a JSON-serializable dict capturing the minimal
state needed to resume. It replaces any previously stored state.
"""
has_existing_states = await self._shared_state.has(EXECUTOR_STATE_KEY)
if has_existing_states:
existing_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
else:
existing_states = {}
if not isinstance(existing_states, dict):
raise ValueError("Existing executor states in shared state is not a dictionary.")
existing_states[executor_id] = state
await self._shared_state.set(EXECUTOR_STATE_KEY, existing_states)
@@ -5,11 +5,10 @@ import logging
import uuid
from copy import copy
from dataclasses import dataclass
from typing import Any, Protocol, TypedDict, TypeVar, cast, runtime_checkable
from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import DEFAULT_MAX_ITERATIONS
from ._events import WorkflowEvent
from ._shared_state import SharedState
@@ -43,7 +42,7 @@ class Message:
return self.source_span_ids[0] if self.source_span_ids else None
class WorkflowState(TypedDict):
class _WorkflowState(TypedDict):
"""TypedDict representing the serializable state of a workflow execution.
This includes all state data needed for checkpointing and restoration.
@@ -51,9 +50,7 @@ class WorkflowState(TypedDict):
messages: dict[str, list[dict[str, Any]]]
shared_state: dict[str, Any]
executor_states: dict[str, dict[str, Any]]
iteration_count: int
max_iterations: int
@runtime_checkable
@@ -116,26 +113,6 @@ class RunnerContext(Protocol):
"""Wait for and return the next event emitted by the workflow run."""
...
async def set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
"""Set the state for a specific executor.
Args:
executor_id: The ID of the executor whose state is being set.
state: The state data to be set for the executor.
"""
...
async def get_executor_state(self, executor_id: str) -> dict[str, Any] | None:
"""Get the state for a specific executor.
Args:
executor_id: The ID of the executor whose state is being retrieved.
Returns:
The state data for the executor, or None if not found.
"""
...
# Checkpointing capability
def has_checkpointing(self) -> bool:
"""Check if the context supports checkpointing.
@@ -150,7 +127,7 @@ class RunnerContext(Protocol):
"""Set the workflow ID for the context."""
...
def reset_for_new_run(self, workflow_shared_state: SharedState | None = None) -> None:
def reset_for_new_run(self) -> None:
"""Reset the context for a new workflow run."""
...
@@ -170,27 +147,42 @@ class RunnerContext(Protocol):
"""
...
async def create_checkpoint(self, metadata: dict[str, Any] | None = None) -> str:
async def create_checkpoint(
self,
shared_state: SharedState,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
"""Create a checkpoint of the current workflow state.
Args:
shared_state: The shared state to include in the checkpoint.
This is needed to capture the full state of the workflow.
The shared state is not managed by the context itself.
iteration_count: The current iteration count of the workflow.
metadata: Optional metadata to associate with the checkpoint.
Returns:
The ID of the created checkpoint.
"""
...
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Load a checkpoint without mutating the current context state."""
...
async def get_workflow_state(self) -> WorkflowState:
"""Get the current state of the workflow suitable for checkpointing."""
...
async def set_workflow_state(self, state: WorkflowState) -> None:
"""Set the state of the workflow from a checkpoint.
"""Load a checkpoint without mutating the current context state.
Args:
state: The state data to set for the workflow.
checkpoint_id: The ID of the checkpoint to load.
Returns:
The loaded checkpoint, or None if it does not exist.
"""
...
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
"""Apply a checkpoint to the current context, mutating its state.
Args:
checkpoint: The checkpoint whose state is to be applied.
"""
...
@@ -211,14 +203,11 @@ class InProcRunnerContext:
# Checkpointing configuration/state
self._checkpoint_storage = checkpoint_storage
self._workflow_id: str | None = None
self._shared_state: dict[str, Any] = {}
self._executor_states: dict[str, dict[str, Any]] = {}
self._iteration_count: int = 0
self._max_iterations: int = 100
# Streaming flag - set by workflow's run_stream() vs run()
self._streaming: bool = False
# region Messaging and Events
async def send_message(self, message: Message) -> None:
self._messages.setdefault(message.source_id, [])
self._messages[message.source_id].append(message)
@@ -259,15 +248,70 @@ class InProcRunnerContext:
"""
return await self._event_queue.get()
async def set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
self._executor_states[executor_id] = state
# endregion Messaging and Events
async def get_executor_state(self, executor_id: str) -> dict[str, Any] | None:
return self._executor_states.get(executor_id)
# region Checkpointing
def has_checkpointing(self) -> bool:
return self._checkpoint_storage is not None
async def create_checkpoint(
self,
shared_state: SharedState,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
if not self._checkpoint_storage:
raise ValueError("Checkpoint storage not configured")
self._workflow_id = self._workflow_id or str(uuid.uuid4())
state = await self._get_serialized_workflow_state(shared_state, iteration_count)
checkpoint = WorkflowCheckpoint(
workflow_id=self._workflow_id,
messages=state["messages"],
shared_state=state["shared_state"],
iteration_count=state["iteration_count"],
metadata=metadata or {},
)
checkpoint_id = await self._checkpoint_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:
raise ValueError("Checkpoint storage not configured")
return await self._checkpoint_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.
"""
self._messages.clear()
# Clear any pending events (best-effort) by recreating the queue
self._event_queue = asyncio.Queue()
self._streaming = False # Reset streaming flag
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
self._messages.clear()
messages_data = checkpoint.messages
for source_id, message_list in messages_data.items():
self._messages[source_id] = [
Message(
data=decode_checkpoint_value(msg.get("data")),
source_id=msg.get("source_id", ""),
target_id=msg.get("target_id"),
trace_contexts=msg.get("trace_contexts"),
source_span_ids=msg.get("source_span_ids"),
)
for msg in message_list
]
self._workflow_id = checkpoint.workflow_id
# endregion Checkpointing
def set_workflow_id(self, workflow_id: str) -> None:
self._workflow_id = workflow_id
@@ -287,44 +331,7 @@ class InProcRunnerContext:
"""
return self._streaming
def reset_for_new_run(self, workflow_shared_state: SharedState | None = None) -> None:
self._messages.clear()
# Clear any pending events (best-effort) by recreating the queue
self._event_queue = asyncio.Queue()
self._shared_state.clear()
self._executor_states.clear()
self._iteration_count = 0
self._streaming = False # Reset streaming flag
if workflow_shared_state is not None and hasattr(workflow_shared_state, "_state"):
workflow_shared_state._state.clear() # type: ignore[attr-defined]
async def create_checkpoint(self, metadata: dict[str, Any] | None = None) -> str:
if not self._checkpoint_storage:
raise ValueError("Checkpoint storage not configured")
wf_id = self._workflow_id or str(uuid.uuid4())
self._workflow_id = wf_id
state = await self.get_workflow_state()
checkpoint = WorkflowCheckpoint(
workflow_id=wf_id,
messages=state["messages"],
shared_state=state.get("shared_state", {}),
executor_states=state.get("executor_states", {}),
iteration_count=state.get("iteration_count", 0),
max_iterations=state.get("max_iterations", DEFAULT_MAX_ITERATIONS),
metadata=metadata or {},
)
checkpoint_id = await self._checkpoint_storage.save_checkpoint(checkpoint)
logger.info(f"Created checkpoint {checkpoint_id} for workflow {wf_id}'")
return checkpoint_id
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
if not self._checkpoint_storage:
raise ValueError("Checkpoint storage not configured")
return await self._checkpoint_storage.load_checkpoint(checkpoint_id)
async def get_workflow_state(self) -> WorkflowState:
async def _get_serialized_workflow_state(self, shared_state: SharedState, iteration_count: int) -> _WorkflowState:
serializable_messages: dict[str, list[dict[str, Any]]] = {}
for source_id, message_list in self._messages.items():
serializable_messages[source_id] = [
@@ -340,48 +347,6 @@ class InProcRunnerContext:
return {
"messages": serializable_messages,
"shared_state": encode_checkpoint_value(self._shared_state),
"executor_states": encode_checkpoint_value(self._executor_states),
"iteration_count": self._iteration_count,
"max_iterations": self._max_iterations,
"shared_state": encode_checkpoint_value(await shared_state.export_state()),
"iteration_count": iteration_count,
}
async def set_workflow_state(self, state: WorkflowState) -> None:
self._messages.clear()
messages_data = state.get("messages", {})
for source_id, message_list in messages_data.items():
self._messages[source_id] = [
Message(
data=decode_checkpoint_value(msg.get("data")),
source_id=msg.get("source_id", ""),
target_id=msg.get("target_id"),
trace_contexts=msg.get("trace_contexts"),
source_span_ids=msg.get("source_span_ids"),
)
for msg in message_list
]
# Restore shared_state
decoded_shared_raw = decode_checkpoint_value(state.get("shared_state", {}))
if isinstance(decoded_shared_raw, dict):
self._shared_state = cast(dict[str, Any], decoded_shared_raw)
else: # fallback to empty dict if corrupted
self._shared_state = {}
# Restore executor_states ensuring value types are dicts
decoded_exec_raw = decode_checkpoint_value(state.get("executor_states", {}))
if isinstance(decoded_exec_raw, dict):
typed_exec: dict[str, dict[str, Any]] = {}
for k_raw, v_raw in decoded_exec_raw.items(): # type: ignore[assignment]
if isinstance(k_raw, str) and isinstance(v_raw, dict):
# Filter inner dict to string keys only (best-effort)
inner: dict[str, Any] = {}
for inner_k, inner_v in v_raw.items(): # type: ignore[assignment]
if isinstance(inner_k, str):
inner[inner_k] = inner_v
typed_exec[k_raw] = inner
self._executor_states = typed_exec
else:
self._executor_states = {}
self._iteration_count = state.get("iteration_count", 0)
self._max_iterations = state.get("max_iterations", 100)
@@ -7,7 +7,21 @@ from typing import Any
class SharedState:
"""A class to manage shared state in a workflow."""
"""A class to manage shared state in a workflow.
SharedState provides thread-safe access to workflow state data that needs to be
shared across executors during workflow execution.
Reserved Keys:
The following keys are reserved for internal framework use and should not be
modified by user code:
- `_executor_state`: Stores executor state for checkpointing (managed by Runner)
Warning:
Do not use keys starting with underscore (_) as they may be reserved for
internal framework operations.
"""
def __init__(self) -> None:
"""Initialize the shared state."""
@@ -34,6 +48,24 @@ class SharedState:
async with self._shared_state_lock:
await self.delete_within_hold(key)
async def clear(self) -> None:
"""Clear the entire shared state."""
async with self._shared_state_lock:
self._state.clear()
async def export_state(self) -> dict[str, Any]:
"""Get a serialized copy of the entire shared state."""
async with self._shared_state_lock:
return dict(self._state)
async def import_state(self, state: dict[str, Any]) -> None:
"""Populate the shared state from a serialized state dictionary.
This replaces the entire current state with the provided state.
"""
async with self._shared_state_lock:
self._state.update(state)
@asynccontextmanager
async def hold(self) -> AsyncIterator["SharedState"]:
"""Context manager to hold the shared state lock for multiple operations.
@@ -183,13 +183,11 @@ class Workflow(DictConvertible):
# Convert start_executor to string ID if it's an Executor instance
start_executor_id = start_executor.id if isinstance(start_executor, Executor) else start_executor
id = str(uuid.uuid4())
self.edge_groups = list(edge_groups)
self.executors = dict(executors)
self.start_executor_id = start_executor_id
self.max_iterations = max_iterations
self.id = id
self.id = str(uuid.uuid4())
self.name = name
self.description = description
@@ -202,7 +200,7 @@ class Workflow(DictConvertible):
self._shared_state,
runner_context,
max_iterations=max_iterations,
workflow_id=id,
workflow_id=self.id,
)
# Flag to prevent concurrent workflow executions
@@ -317,7 +315,9 @@ class Workflow(DictConvertible):
# Reset context for a new run if supported
if reset_context:
self._runner.context.reset_for_new_run(self._shared_state)
self._runner.reset_iteration_count()
self._runner.context.reset_for_new_run()
await self._shared_state.clear()
# Set streaming mode after reset
self._runner_context.set_streaming(streaming)
@@ -11,6 +11,7 @@ from opentelemetry.trace import SpanKind
from typing_extensions import Never, TypeVar
from ..observability import OtelAttr, create_workflow_span
from ._const import EXECUTOR_STATE_KEY
from ._events import (
WorkflowEvent,
WorkflowEventSource,
@@ -436,16 +437,34 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
return self._shared_state
async def set_executor_state(self, state: dict[str, Any]) -> None:
"""Persist this executor's state into the checkpointable context.
"""Store executor state in shared state under a reserved key.
Executors call this with a JSON-serializable dict capturing the minimal
state needed to resume. It replaces any previously stored state.
"""
await self._runner_context.set_executor_state(self._executor_id, state)
has_existing_states = await self._shared_state.has(EXECUTOR_STATE_KEY)
if has_existing_states:
existing_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
else:
existing_states = {}
if not isinstance(existing_states, dict):
raise ValueError("Existing executor states in shared state is not a dictionary.")
existing_states[self._executor_id] = state
await self._shared_state.set(EXECUTOR_STATE_KEY, existing_states)
async def get_executor_state(self) -> dict[str, Any] | None:
"""Retrieve previously persisted state for this executor, if any."""
return await self._runner_context.get_executor_state(self._executor_id)
has_existing_states = await self._shared_state.has(EXECUTOR_STATE_KEY)
if not has_existing_states:
return None
existing_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
if not isinstance(existing_states, dict):
raise ValueError("Existing executor states in shared state is not a dictionary.")
return existing_states.get(self._executor_id)
def is_streaming(self) -> bool:
"""Check if the workflow is running in streaming mode.
@@ -20,9 +20,7 @@ def test_workflow_checkpoint_default_values():
assert checkpoint.timestamp != ""
assert checkpoint.messages == {}
assert checkpoint.shared_state == {}
assert checkpoint.executor_states == {}
assert checkpoint.iteration_count == 0
assert checkpoint.max_iterations == 100
assert checkpoint.metadata == {}
assert checkpoint.version == "1.0"
@@ -35,9 +33,7 @@ def test_workflow_checkpoint_custom_values():
timestamp=custom_timestamp,
messages={"executor1": [{"data": "test"}]},
shared_state={"key": "value"},
executor_states={"executor1": {"state": "active"}},
iteration_count=5,
max_iterations=50,
metadata={"test": True},
version="2.0",
)
@@ -47,9 +43,7 @@ def test_workflow_checkpoint_custom_values():
assert checkpoint.timestamp == custom_timestamp
assert checkpoint.messages == {"executor1": [{"data": "test"}]}
assert checkpoint.shared_state == {"key": "value"}
assert checkpoint.executor_states == {"executor1": {"state": "active"}}
assert checkpoint.iteration_count == 5
assert checkpoint.max_iterations == 50
assert checkpoint.metadata == {"test": True}
assert checkpoint.version == "2.0"
@@ -290,7 +284,6 @@ async def test_file_checkpoint_storage_json_serialization():
workflow_id="complex-workflow",
messages={"executor1": [{"data": {"nested": {"value": 42}}, "source_id": "test", "target_id": None}]},
shared_state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None},
executor_states={"executor1": {"state": "active", "config": {"timeout": 30, "retries": 3}}},
)
# Save and load
@@ -300,7 +293,6 @@ async def test_file_checkpoint_storage_json_serialization():
assert loaded is not None
assert loaded.messages == checkpoint.messages
assert loaded.shared_state == checkpoint.shared_state
assert loaded.executor_states == checkpoint.executor_states
# Verify the JSON file is properly formatted
file_path = Path(temp_dir) / f"{checkpoint.checkpoint_id}.json"
@@ -8,6 +8,7 @@ from typing import Any
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
from agent_framework._workflows._const import EXECUTOR_STATE_KEY
from agent_framework._workflows._events import RequestInfoEvent, WorkflowEvent
from agent_framework._workflows._request_info_executor import (
PendingRequestDetails,
@@ -16,10 +17,7 @@ from agent_framework._workflows._request_info_executor import (
RequestInfoMessage,
RequestResponse,
)
from agent_framework._workflows._runner_context import (
Message,
WorkflowState,
)
from agent_framework._workflows._runner_context import Message
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._workflow_context import WorkflowContext
@@ -29,9 +27,6 @@ PENDING_STATE_KEY = RequestInfoExecutor._PENDING_SHARED_STATE_KEY # pyright: ig
class _StubRunnerContext:
"""Minimal runner context stub for exercising WorkflowContext helpers."""
def __init__(self, stored_state: dict[str, Any] | None = None) -> None:
self._state = stored_state or {}
async def send_message(self, message: Message) -> None: # pragma: no cover - unused in tests
return None
@@ -53,31 +48,27 @@ class _StubRunnerContext:
async def next_event(self) -> WorkflowEvent: # pragma: no cover - unused
raise RuntimeError("Not implemented in stub context")
async def get_executor_state(self, executor_id: str) -> dict[str, Any] | None: # pragma: no cover - trivial
return self._state
async def set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None: # pragma: no cover - unused
self._state = state
def has_checkpointing(self) -> bool: # pragma: no cover - unused
return False
def set_workflow_id(self, workflow_id: str) -> None: # pragma: no cover - unused
pass
def reset_for_new_run(self, workflow_shared_state: SharedState | None = None) -> None: # pragma: no cover - unused
def reset_for_new_run(self) -> None: # pragma: no cover - unused
pass
async def create_checkpoint(self, metadata: dict[str, Any] | None = None) -> str: # pragma: no cover - unused
async def create_checkpoint(
self,
shared_state: SharedState,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str: # pragma: no cover - unused
raise RuntimeError("Checkpointing not supported in stub context")
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: # pragma: no cover - unused
return None
async def get_workflow_state(self) -> WorkflowState: # pragma: no cover - unused
return {} # type: ignore[return-value]
async def set_workflow_state(self, state: WorkflowState) -> None: # pragma: no cover - unused
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: # pragma: no cover - unused
pass
def set_streaming(self, streaming: bool) -> None: # pragma: no cover - unused
@@ -120,8 +111,8 @@ async def test_rehydrate_falls_back_when_request_type_missing() -> None:
},
)
runner_ctx = _StubRunnerContext({PENDING_STATE_KEY: {request_id: snapshot}})
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), runner_ctx)
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
await ctx.set_executor_state({PENDING_STATE_KEY: {request_id: snapshot}})
executor = RequestInfoExecutor(id="request_info")
@@ -143,8 +134,8 @@ async def test_has_pending_request_detects_snapshot() -> None:
},
)
runner_ctx = _StubRunnerContext({PENDING_STATE_KEY: {request_id: snapshot}})
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), runner_ctx)
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
await ctx.set_executor_state({PENDING_STATE_KEY: {request_id: snapshot}})
executor = RequestInfoExecutor(id="request_info")
@@ -152,9 +143,8 @@ async def test_has_pending_request_detects_snapshot() -> None:
async def test_has_pending_request_false_when_snapshot_absent() -> None:
shared_state = SharedState()
runner_ctx = _StubRunnerContext({"pending_requests": {}})
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], shared_state, runner_ctx)
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
await ctx.set_executor_state({PENDING_STATE_KEY: {}})
executor = RequestInfoExecutor(id="request_info")
@@ -196,7 +186,6 @@ def test_pending_requests_from_checkpoint_and_summary() -> None:
}
}
},
executor_states={},
iteration_count=1,
)
@@ -284,10 +273,13 @@ async def test_run_persists_pending_requests_in_runner_state() -> None:
await executor.execute(approval, ctx.source_executor_ids, shared_state, runner_ctx)
# Runner state should include both pending snapshot and serialized request events
assert PENDING_STATE_KEY in runner_ctx._state # pyright: ignore[reportPrivateUsage]
assert approval.request_id in runner_ctx._state[PENDING_STATE_KEY] # pyright: ignore[reportPrivateUsage]
assert await shared_state.has(EXECUTOR_STATE_KEY)
executor_state = await shared_state.get(EXECUTOR_STATE_KEY)
assert executor.id in executor_state
assert PENDING_STATE_KEY in executor_state[executor.id]
assert approval.request_id in executor_state[executor.id][PENDING_STATE_KEY]
response_ctx: WorkflowContext[None] = WorkflowContext("request_info", ["source"], shared_state, runner_ctx)
await executor.handle_response("approved", approval.request_id, response_ctx) # type: ignore
assert runner_ctx._state[PENDING_STATE_KEY] == {} # pyright: ignore[reportPrivateUsage]
assert executor_state[executor.id][PENDING_STATE_KEY] == {}
@@ -414,9 +414,7 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage(simple_
workflow_id="test-workflow",
messages={},
shared_state={},
executor_states={},
iteration_count=0,
max_iterations=100,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
@@ -451,9 +449,7 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
workflow_id="test-workflow",
messages={},
shared_state={},
executor_states={},
iteration_count=0,
max_iterations=100,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
@@ -484,9 +480,7 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executo
workflow_id="test-workflow",
messages={},
shared_state={},
executor_states={},
iteration_count=0,
max_iterations=100,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
@@ -525,7 +519,7 @@ class StateTrackingExecutor(Executor):
"""An executor that tracks state in shared state to test context reset behavior."""
@handler
async def handle_message(self, message: StateTrackingMessage, ctx: WorkflowContext[Any, list]) -> None:
async def handle_message(self, message: StateTrackingMessage, ctx: WorkflowContext[Any, list[Any]]) -> None:
"""Handle the message and track it in shared state."""
# Get existing messages from shared state
try:
@@ -6,7 +6,7 @@ import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from agent_framework import WorkflowBuilder
from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder
from agent_framework._workflows._executor import Executor, handler
from agent_framework._workflows._runner_context import InProcRunnerContext, Message
from agent_framework._workflows._shared_state import SharedState
@@ -426,7 +426,7 @@ async def test_workflow_error_handling_in_tracing(span_exporter: InMemorySpanExp
@pytest.mark.parametrize("enable_otel", [False], indirect=True)
async def test_message_trace_context_serialization(span_exporter: InMemorySpanExporter) -> None:
"""Test that message trace context is properly serialized/deserialized."""
ctx = InProcRunnerContext()
ctx = InProcRunnerContext(InMemoryCheckpointStorage())
# Create message with trace context
message = Message(
@@ -439,16 +439,18 @@ async def test_message_trace_context_serialization(span_exporter: InMemorySpanEx
await ctx.send_message(message)
# Get context state (which serializes messages)
state = await ctx.get_workflow_state()
# Create a checkpoint that includes the message
checkpoint_id = await ctx.create_checkpoint(SharedState(), 0)
checkpoint = await ctx.load_checkpoint(checkpoint_id)
assert checkpoint is not None
# Check serialized message includes trace context
serialized_msg = state["messages"]["source"][0]
serialized_msg = checkpoint.messages["source"][0]
assert serialized_msg["trace_contexts"] == [{"traceparent": "00-trace-span-01"}]
assert serialized_msg["source_span_ids"] == ["span123"]
# Test deserialization
await ctx.set_workflow_state(state)
await ctx.apply_checkpoint(checkpoint)
restored_messages = await ctx.drain_messages()
restored_msg = list(restored_messages.values())[0][0]
@@ -196,7 +196,7 @@ def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
for cp in sorted(checkpoints, key=lambda c: c.timestamp):
summary = get_checkpoint_summary(cp)
msg_count = sum(len(v) for v in cp.messages.values())
state_keys = sorted(cp.executor_states.keys())
state_keys = sorted(summary.executor_ids)
orig = cp.shared_state.get("original_input")
upper = cp.shared_state.get("upper_output")