mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Checkpoint refactor: encode/decode, checkpoint format, etc (#3744)
* WIP: Checkpoint refactor: encode/decode, checkpoint format, etc * WIP: Remove workflow ID in checkpoints * Refactor checkpointing * Add get_latest tests * Increase test coverage * Fix formatting * Fix unit tests * Fix samples * fix unit tests * fix pipeline * Copilot comments * Fix tests * Fix more tests * Address comments part 1 * Address comments part 2 * Comments
This commit is contained in:
committed by
GitHub
Unverified
parent
a2a672b687
commit
7db6c4ab4e
@@ -13,7 +13,6 @@ from ._checkpoint import (
|
||||
InMemoryCheckpointStorage,
|
||||
WorkflowCheckpoint,
|
||||
)
|
||||
from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary
|
||||
from ._const import (
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
)
|
||||
@@ -107,7 +106,6 @@ __all__ = [
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCheckpointException",
|
||||
"WorkflowCheckpointSummary",
|
||||
"WorkflowContext",
|
||||
"WorkflowConvergenceException",
|
||||
"WorkflowErrorDetails",
|
||||
@@ -124,7 +122,6 @@ __all__ = [
|
||||
"WorkflowViz",
|
||||
"create_edge_runner",
|
||||
"executor",
|
||||
"get_checkpoint_summary",
|
||||
"handler",
|
||||
"resolve_agent_id",
|
||||
"response_handler",
|
||||
|
||||
@@ -13,9 +13,7 @@ from .._agents import SupportsAgentRun
|
||||
from .._threads import AgentThread
|
||||
from .._types import AgentResponse, AgentResponseUpdate, Message
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._conversation_state import encode_chat_messages
|
||||
from ._executor import Executor, handler
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._request_info_mixin import response_handler
|
||||
@@ -232,11 +230,11 @@ class AgentExecutor(Executor):
|
||||
serialized_thread = await self._agent_thread.serialize()
|
||||
|
||||
return {
|
||||
"cache": encode_chat_messages(self._cache),
|
||||
"full_conversation": encode_chat_messages(self._full_conversation),
|
||||
"cache": self._cache,
|
||||
"full_conversation": self._full_conversation,
|
||||
"agent_thread": serialized_thread,
|
||||
"pending_agent_requests": encode_checkpoint_value(self._pending_agent_requests),
|
||||
"pending_responses_to_agent": encode_checkpoint_value(self._pending_responses_to_agent),
|
||||
"pending_agent_requests": self._pending_agent_requests,
|
||||
"pending_responses_to_agent": self._pending_responses_to_agent,
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -246,27 +244,11 @@ class AgentExecutor(Executor):
|
||||
Args:
|
||||
state: Checkpoint data dict
|
||||
"""
|
||||
from ._conversation_state import decode_chat_messages
|
||||
|
||||
cache_payload = state.get("cache")
|
||||
if cache_payload:
|
||||
try:
|
||||
self._cache = decode_chat_messages(cache_payload)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore cache: %s", exc)
|
||||
self._cache = []
|
||||
else:
|
||||
self._cache = []
|
||||
self._cache = cache_payload or []
|
||||
|
||||
full_conversation_payload = state.get("full_conversation")
|
||||
if full_conversation_payload:
|
||||
try:
|
||||
self._full_conversation = decode_chat_messages(full_conversation_payload)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore full conversation: %s", exc)
|
||||
self._full_conversation = []
|
||||
else:
|
||||
self._full_conversation = []
|
||||
self._full_conversation = full_conversation_payload or []
|
||||
|
||||
thread_payload = state.get("agent_thread")
|
||||
if thread_payload:
|
||||
@@ -282,11 +264,11 @@ class AgentExecutor(Executor):
|
||||
|
||||
pending_requests_payload = state.get("pending_agent_requests")
|
||||
if pending_requests_payload:
|
||||
self._pending_agent_requests = decode_checkpoint_value(pending_requests_payload)
|
||||
self._pending_agent_requests = pending_requests_payload
|
||||
|
||||
pending_responses_payload = state.get("pending_responses_to_agent")
|
||||
if pending_responses_payload:
|
||||
self._pending_responses_to_agent = decode_checkpoint_value(pending_responses_payload)
|
||||
self._pending_responses_to_agent = pending_responses_payload
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the internal cache of the executor."""
|
||||
|
||||
@@ -3,18 +3,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from dataclasses import dataclass, field, fields
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias
|
||||
|
||||
from ._exceptions import WorkflowCheckpointException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._events import WorkflowEvent
|
||||
from ._runner_context import WorkflowMessage
|
||||
|
||||
# Type alias for checkpoint IDs in case we want to change the
|
||||
# underlying type in the future (e.g., to UUID or a custom class)
|
||||
CheckpointID: TypeAlias = str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkflowCheckpoint:
|
||||
@@ -23,15 +34,31 @@ class WorkflowCheckpoint:
|
||||
Checkpoints capture the full execution state of a workflow at a specific point,
|
||||
enabling workflows to be paused and resumed.
|
||||
|
||||
Note that a checkpoint is not tied to a specific workflow instance, but rather to
|
||||
a workflow definition (identified by workflow_name and graph_signature_hash). Thus,
|
||||
the ID of the workflow instance that created the checkpoint is not included in the
|
||||
checkpoint data. This allows checkpoints to be shared and restored across different
|
||||
workflow instances of the same workflow definition.
|
||||
|
||||
Attributes:
|
||||
workflow_name: Name of the workflow this checkpoint belongs to. This acts as a
|
||||
logical grouping for checkpoints and can be used to filter checkpoints by
|
||||
workflow. Workflows with the same name are expected to have compatible graph
|
||||
structures for checkpointing.
|
||||
graph_signature_hash: Hash of the workflow graph topology to validate checkpoint
|
||||
compatibility during restore
|
||||
checkpoint_id: Unique identifier for this checkpoint
|
||||
workflow_id: Identifier of the workflow this checkpoint belongs to
|
||||
previous_checkpoint_id: ID of the previous checkpoint in the chain, if any. This
|
||||
allows chaining checkpoints together to form a history of workflow states.
|
||||
timestamp: ISO 8601 timestamp when checkpoint was created
|
||||
messages: Messages exchanged between executors
|
||||
state: Committed workflow state including user data and executor states.
|
||||
This contains only committed state; pending state changes are not
|
||||
included in checkpoints. Executor states are stored under the
|
||||
reserved key '_executor_state'.
|
||||
This contains only committed state; pending state changes are not
|
||||
included in checkpoints. Executor states are stored under the
|
||||
reserved key '_executor_state'.
|
||||
pending_request_info_events: Any pending request info events that have not
|
||||
yet been processed at the time of checkpointing. This allows the workflow
|
||||
to resume with the correct pending events after a restore.
|
||||
iteration_count: Current iteration number when checkpoint was created
|
||||
metadata: Additional metadata (e.g., superstep info, graph signature)
|
||||
version: Checkpoint format version
|
||||
@@ -41,14 +68,17 @@ class WorkflowCheckpoint:
|
||||
See State class documentation for details on reserved keys.
|
||||
"""
|
||||
|
||||
checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
workflow_id: str = ""
|
||||
workflow_name: str
|
||||
graph_signature_hash: str
|
||||
|
||||
checkpoint_id: CheckpointID = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
previous_checkpoint_id: CheckpointID | None = None
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
# Core workflow state
|
||||
messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc]
|
||||
messages: dict[str, list[WorkflowMessage]] = field(default_factory=dict) # type: ignore[misc]
|
||||
state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
|
||||
pending_request_info_events: dict[str, dict[str, Any]] = field(default_factory=dict) # type: ignore[misc]
|
||||
pending_request_info_events: dict[str, WorkflowEvent[Any]] = field(default_factory=dict) # type: ignore[misc]
|
||||
|
||||
# Runtime state
|
||||
iteration_count: int = 0
|
||||
@@ -58,34 +88,104 @@ class WorkflowCheckpoint:
|
||||
version: str = "1.0"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
"""Convert the WorkflowCheckpoint to a dictionary.
|
||||
|
||||
Notes:
|
||||
1. This method does not recursively convert nested dataclasses to dicts.
|
||||
2. This is a shallow conversion. The resulting dict will contain the same
|
||||
references to nested objects as the original dataclass.
|
||||
"""
|
||||
return {f.name: getattr(self, f.name) for f in fields(self)}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> WorkflowCheckpoint:
|
||||
return cls(**data)
|
||||
"""Create a WorkflowCheckpoint from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing checkpoint fields.
|
||||
|
||||
Returns:
|
||||
A new WorkflowCheckpoint instance.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If required fields are missing.
|
||||
"""
|
||||
try:
|
||||
return cls(**data)
|
||||
except Exception as ex:
|
||||
raise WorkflowCheckpointException(f"Failed to create WorkflowCheckpoint from dict: {ex}") from ex
|
||||
|
||||
|
||||
class CheckpointStorage(Protocol):
|
||||
"""Protocol for checkpoint storage backends."""
|
||||
|
||||
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
|
||||
"""Save a checkpoint and return its ID."""
|
||||
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
|
||||
"""Save a checkpoint and return its ID.
|
||||
|
||||
Args:
|
||||
checkpoint: The WorkflowCheckpoint object to save.
|
||||
|
||||
Returns:
|
||||
The unique ID of the saved checkpoint.
|
||||
"""
|
||||
...
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
"""Load a checkpoint by ID."""
|
||||
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
"""Load a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to load.
|
||||
|
||||
Returns:
|
||||
The WorkflowCheckpoint object corresponding to the given ID.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If no checkpoint with the given ID exists.
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
|
||||
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
|
||||
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoints for.
|
||||
|
||||
Returns:
|
||||
A list of WorkflowCheckpoint objects for the specified workflow name.
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
|
||||
async def delete(self, checkpoint_id: CheckpointID) -> bool:
|
||||
"""Delete a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to delete.
|
||||
|
||||
Returns:
|
||||
True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists.
|
||||
"""
|
||||
...
|
||||
|
||||
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
"""Delete a checkpoint by ID."""
|
||||
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
|
||||
"""Get the latest checkpoint for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to get the latest checkpoint for.
|
||||
|
||||
Returns:
|
||||
The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist.
|
||||
"""
|
||||
...
|
||||
|
||||
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
|
||||
"""List checkpoint IDs for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoint IDs for.
|
||||
|
||||
Returns:
|
||||
A list of checkpoint IDs for the specified workflow name.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -94,34 +194,27 @@ class InMemoryCheckpointStorage:
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the memory storage."""
|
||||
self._checkpoints: dict[str, WorkflowCheckpoint] = {}
|
||||
self._checkpoints: dict[CheckpointID, WorkflowCheckpoint] = {}
|
||||
|
||||
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
|
||||
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
|
||||
"""Save a checkpoint and return its ID."""
|
||||
self._checkpoints[checkpoint.checkpoint_id] = checkpoint
|
||||
self._checkpoints[checkpoint.checkpoint_id] = copy.deepcopy(checkpoint)
|
||||
logger.debug(f"Saved checkpoint {checkpoint.checkpoint_id} to memory")
|
||||
return checkpoint.checkpoint_id
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
"""Load a checkpoint by ID."""
|
||||
checkpoint = self._checkpoints.get(checkpoint_id)
|
||||
if checkpoint:
|
||||
logger.debug(f"Loaded checkpoint {checkpoint_id} from memory")
|
||||
return checkpoint
|
||||
return checkpoint
|
||||
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
|
||||
|
||||
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
|
||||
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
|
||||
if workflow_id is None:
|
||||
return list(self._checkpoints.keys())
|
||||
return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
|
||||
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects for a given workflow name."""
|
||||
return [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
|
||||
|
||||
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
|
||||
if workflow_id is None:
|
||||
return list(self._checkpoints.values())
|
||||
return [cp for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
|
||||
|
||||
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
async def delete(self, checkpoint_id: CheckpointID) -> bool:
|
||||
"""Delete a checkpoint by ID."""
|
||||
if checkpoint_id in self._checkpoints:
|
||||
del self._checkpoints[checkpoint_id]
|
||||
@@ -129,9 +222,31 @@ class InMemoryCheckpointStorage:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
|
||||
"""Get the latest checkpoint for a given workflow name."""
|
||||
checkpoints = [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
|
||||
if not checkpoints:
|
||||
return None
|
||||
latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
|
||||
logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}")
|
||||
return latest_checkpoint
|
||||
|
||||
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
|
||||
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
|
||||
return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
|
||||
|
||||
|
||||
class FileCheckpointStorage:
|
||||
"""File-based checkpoint storage for persistence."""
|
||||
"""File-based checkpoint storage for persistence.
|
||||
|
||||
This storage implements a hybrid approach where the checkpoint metadata and structure are
|
||||
stored in JSON format, while the actual state data (which may contain complex Python objects)
|
||||
is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows
|
||||
for human-readable checkpoint files while preserving the ability to store complex Python objects.
|
||||
|
||||
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
|
||||
from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
|
||||
"""
|
||||
|
||||
def __init__(self, storage_path: str | Path):
|
||||
"""Initialize the file storage."""
|
||||
@@ -139,15 +254,45 @@ class FileCheckpointStorage:
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Initialized file checkpoint storage at {self.storage_path}")
|
||||
|
||||
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
|
||||
"""Save a checkpoint and return its ID."""
|
||||
file_path = self.storage_path / f"{checkpoint.checkpoint_id}.json"
|
||||
checkpoint_dict = asdict(checkpoint)
|
||||
def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path:
|
||||
"""Validate that a checkpoint ID resolves to a path within the storage directory.
|
||||
|
||||
This can prevent someone from crafting a checkpoint ID that points to an arbitrary
|
||||
file on the filesystem.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The checkpoint ID to validate.
|
||||
|
||||
Returns:
|
||||
The validated file path.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the checkpoint ID would resolve outside the storage directory.
|
||||
"""
|
||||
file_path = (self.storage_path / f"{checkpoint_id}.json").resolve()
|
||||
if not file_path.is_relative_to(self.storage_path.resolve()):
|
||||
raise WorkflowCheckpointException(f"Invalid checkpoint ID: {checkpoint_id}")
|
||||
return file_path
|
||||
|
||||
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
|
||||
"""Save a checkpoint and return its ID.
|
||||
|
||||
Args:
|
||||
checkpoint: The WorkflowCheckpoint object to save.
|
||||
|
||||
Returns:
|
||||
The unique ID of the saved checkpoint.
|
||||
"""
|
||||
from ._checkpoint_encoding import encode_checkpoint_value
|
||||
|
||||
file_path = self._validate_file_path(checkpoint.checkpoint_id)
|
||||
checkpoint_dict = checkpoint.to_dict()
|
||||
encoded_checkpoint = encode_checkpoint_value(checkpoint_dict)
|
||||
|
||||
def _write_atomic() -> None:
|
||||
tmp_path = file_path.with_suffix(".json.tmp")
|
||||
with open(tmp_path, "w") as f:
|
||||
json.dump(checkpoint_dict, f, indent=2, ensure_ascii=False)
|
||||
json.dump(encoded_checkpoint, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, file_path)
|
||||
|
||||
await asyncio.to_thread(_write_atomic)
|
||||
@@ -155,60 +300,78 @@ class FileCheckpointStorage:
|
||||
logger.info(f"Saved checkpoint {checkpoint.checkpoint_id} to {file_path}")
|
||||
return checkpoint.checkpoint_id
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
"""Load a checkpoint by ID."""
|
||||
file_path = self.storage_path / f"{checkpoint_id}.json"
|
||||
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
"""Load a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to load.
|
||||
|
||||
Returns:
|
||||
The WorkflowCheckpoint object corresponding to the given ID.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If no checkpoint with the given ID exists,
|
||||
or if checkpoint decoding fails.
|
||||
"""
|
||||
file_path = self._validate_file_path(checkpoint_id)
|
||||
|
||||
if not file_path.exists():
|
||||
return None
|
||||
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
|
||||
|
||||
def _read() -> dict[str, Any]:
|
||||
with open(file_path) as f:
|
||||
return json.load(f) # type: ignore[no-any-return]
|
||||
|
||||
checkpoint_dict = await asyncio.to_thread(_read)
|
||||
encoded_checkpoint = await asyncio.to_thread(_read)
|
||||
|
||||
checkpoint = WorkflowCheckpoint(**checkpoint_dict)
|
||||
from ._checkpoint_encoding import CheckpointDecodingError, decode_checkpoint_value
|
||||
|
||||
try:
|
||||
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
|
||||
except CheckpointDecodingError as exc:
|
||||
raise WorkflowCheckpointException(f"Failed to decode checkpoint {checkpoint_id}: {exc}") from exc
|
||||
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
|
||||
logger.info(f"Loaded checkpoint {checkpoint_id} from {file_path}")
|
||||
return checkpoint
|
||||
|
||||
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
|
||||
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
|
||||
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects for a given workflow name.
|
||||
|
||||
def _list_ids() -> list[str]:
|
||||
checkpoint_ids: list[str] = []
|
||||
for file_path in self.storage_path.glob("*.json"):
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
data = json.load(f)
|
||||
if workflow_id is None or data.get("workflow_id") == workflow_id:
|
||||
checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
|
||||
return checkpoint_ids
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoints for.
|
||||
|
||||
return await asyncio.to_thread(_list_ids)
|
||||
|
||||
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
|
||||
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
|
||||
Returns:
|
||||
A list of WorkflowCheckpoint objects for the specified workflow name.
|
||||
"""
|
||||
|
||||
def _list_checkpoints() -> list[WorkflowCheckpoint]:
|
||||
checkpoints: list[WorkflowCheckpoint] = []
|
||||
for file_path in self.storage_path.glob("*.json"):
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
data = json.load(f)
|
||||
if workflow_id is None or data.get("workflow_id") == workflow_id:
|
||||
checkpoints.append(WorkflowCheckpoint.from_dict(data))
|
||||
encoded_checkpoint = json.load(f)
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
|
||||
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
|
||||
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
|
||||
if checkpoint.workflow_name == workflow_name:
|
||||
checkpoints.append(checkpoint)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
|
||||
return checkpoints
|
||||
|
||||
return await asyncio.to_thread(_list_checkpoints)
|
||||
|
||||
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
"""Delete a checkpoint by ID."""
|
||||
file_path = self.storage_path / f"{checkpoint_id}.json"
|
||||
async def delete(self, checkpoint_id: CheckpointID) -> bool:
|
||||
"""Delete a checkpoint by ID.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The unique ID of the checkpoint to delete.
|
||||
|
||||
Returns:
|
||||
True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists.
|
||||
"""
|
||||
file_path = self._validate_file_path(checkpoint_id)
|
||||
|
||||
def _delete() -> bool:
|
||||
if file_path.exists():
|
||||
@@ -218,3 +381,43 @@ class FileCheckpointStorage:
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_delete)
|
||||
|
||||
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
|
||||
"""Get the latest checkpoint for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to get the latest checkpoint for.
|
||||
|
||||
Returns:
|
||||
The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist.
|
||||
"""
|
||||
checkpoints = await self.list_checkpoints(workflow_name=workflow_name)
|
||||
if not checkpoints:
|
||||
return None
|
||||
latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
|
||||
logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}")
|
||||
return latest_checkpoint
|
||||
|
||||
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
|
||||
"""List checkpoint IDs for a given workflow name.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow to list checkpoint IDs for.
|
||||
|
||||
Returns:
|
||||
A list of checkpoint IDs for the specified workflow name.
|
||||
"""
|
||||
|
||||
def _list_ids() -> list[CheckpointID]:
|
||||
checkpoint_ids: list[CheckpointID] = []
|
||||
for file_path in self.storage_path.glob("*.json"):
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
data = json.load(f)
|
||||
if data.get("workflow_name") == workflow_name:
|
||||
checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
|
||||
return checkpoint_ids
|
||||
|
||||
return await asyncio.to_thread(_list_ids)
|
||||
|
||||
@@ -2,269 +2,169 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import fields, is_dataclass
|
||||
from typing import Any, cast
|
||||
import base64
|
||||
import pickle # nosec # noqa: S403
|
||||
from typing import Any
|
||||
|
||||
# Checkpoint serialization helpers
|
||||
MODEL_MARKER = "__af_model__"
|
||||
DATACLASS_MARKER = "__af_dataclass__"
|
||||
from agent_framework import get_logger
|
||||
|
||||
# Guards to prevent runaway recursion while encoding arbitrary user data
|
||||
_MAX_ENCODE_DEPTH = 100
|
||||
_CYCLE_SENTINEL = "<cycle>"
|
||||
"""Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data.
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
This hybrid approach provides:
|
||||
- Human-readable JSON structure for debugging and inspection of primitives and collections
|
||||
- Full Python object fidelity via pickle for data values (non-JSON-native types)
|
||||
- Base64 encoding to embed binary pickle data in JSON strings
|
||||
|
||||
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
|
||||
from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
|
||||
"""
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Marker to identify pickled values in serialized JSON
|
||||
_PICKLE_MARKER = "__pickled__"
|
||||
_TYPE_MARKER = "__type__"
|
||||
|
||||
# Types that are natively JSON-serializable and don't need pickling
|
||||
_JSON_NATIVE_TYPES = (str, int, float, bool, type(None))
|
||||
|
||||
|
||||
class CheckpointDecodingError(Exception):
|
||||
"""Raised when checkpoint decoding fails due to type mismatch or corruption."""
|
||||
|
||||
|
||||
def encode_checkpoint_value(value: Any) -> Any:
|
||||
"""Recursively encode values into JSON-serializable structures.
|
||||
"""Encode a Python value for checkpoint storage.
|
||||
|
||||
- Objects exposing to_dict/to_json -> { MODEL_MARKER: "module:Class", value: encoded }
|
||||
- dataclass instances -> { DATACLASS_MARKER: "module:Class", value: {field: encoded} }
|
||||
- dict -> encode keys as str and values recursively
|
||||
- list/tuple/set -> list of encoded items
|
||||
- other -> returned as-is if already JSON-serializable
|
||||
JSON-native types (str, int, float, bool, None) pass through unchanged.
|
||||
Collections (dict, list) are recursed with their values encoded.
|
||||
All other types (dataclasses, custom objects, datetime, etc.) are pickled
|
||||
and stored as base64-encoded strings.
|
||||
|
||||
Includes cycle and depth protection to avoid infinite recursion.
|
||||
Args:
|
||||
value: Any Python value to encode.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable representation of the value.
|
||||
"""
|
||||
|
||||
def _enc(v: Any, stack: set[int], depth: int) -> Any:
|
||||
# Depth guard
|
||||
if depth > _MAX_ENCODE_DEPTH:
|
||||
logger.debug(f"Max encode depth reached at depth={depth} for type={type(v)}")
|
||||
return "<max_depth>"
|
||||
|
||||
# Structured model handling (objects exposing to_dict/to_json)
|
||||
if _supports_model_protocol(v):
|
||||
cls = cast(type[Any], type(v)) # type: ignore
|
||||
try:
|
||||
if hasattr(v, "to_dict") and callable(getattr(v, "to_dict", None)):
|
||||
raw = v.to_dict() # type: ignore[attr-defined]
|
||||
strategy = "to_dict"
|
||||
elif hasattr(v, "to_json") and callable(getattr(v, "to_json", None)):
|
||||
serialized = v.to_json() # type: ignore[attr-defined]
|
||||
if isinstance(serialized, (bytes, bytearray)):
|
||||
try:
|
||||
serialized = serialized.decode()
|
||||
except Exception:
|
||||
serialized = serialized.decode(errors="replace")
|
||||
raw = serialized
|
||||
strategy = "to_json"
|
||||
else:
|
||||
raise AttributeError("Structured model lacks serialization hooks")
|
||||
return {
|
||||
MODEL_MARKER: f"{cls.__module__}:{cls.__name__}",
|
||||
"strategy": strategy,
|
||||
"value": _enc(raw, stack, depth + 1),
|
||||
}
|
||||
except Exception as exc: # best-effort fallback
|
||||
logger.debug(f"Structured model serialization failed for {cls}: {exc}")
|
||||
return str(v)
|
||||
|
||||
# Dataclasses (instances only)
|
||||
if is_dataclass(v) and not isinstance(v, type):
|
||||
oid = id(v)
|
||||
if oid in stack:
|
||||
logger.debug("Cycle detected while encoding dataclass instance")
|
||||
return _CYCLE_SENTINEL
|
||||
stack.add(oid)
|
||||
try:
|
||||
# type(v) already narrows sufficiently; cast was redundant
|
||||
dc_cls: type[Any] = type(v)
|
||||
field_values: dict[str, Any] = {}
|
||||
for f in fields(v):
|
||||
field_values[f.name] = _enc(getattr(v, f.name), stack, depth + 1)
|
||||
return {
|
||||
DATACLASS_MARKER: f"{dc_cls.__module__}:{dc_cls.__name__}",
|
||||
"value": field_values,
|
||||
}
|
||||
finally:
|
||||
stack.remove(oid)
|
||||
|
||||
# Collections
|
||||
if isinstance(v, dict):
|
||||
v_dict = cast("dict[object, object]", v)
|
||||
oid = id(v_dict)
|
||||
if oid in stack:
|
||||
logger.debug("Cycle detected while encoding dict")
|
||||
return _CYCLE_SENTINEL
|
||||
stack.add(oid)
|
||||
try:
|
||||
json_dict: dict[str, Any] = {}
|
||||
for k_any, val_any in v_dict.items(): # type: ignore[assignment]
|
||||
k_str: str = str(k_any)
|
||||
json_dict[k_str] = _enc(val_any, stack, depth + 1)
|
||||
return json_dict
|
||||
finally:
|
||||
stack.remove(oid)
|
||||
|
||||
if isinstance(v, (list, tuple, set)):
|
||||
iterable_v = cast("list[object] | tuple[object, ...] | set[object]", v)
|
||||
oid = id(iterable_v)
|
||||
if oid in stack:
|
||||
logger.debug("Cycle detected while encoding iterable")
|
||||
return _CYCLE_SENTINEL
|
||||
stack.add(oid)
|
||||
try:
|
||||
seq: list[object] = list(iterable_v)
|
||||
encoded_list: list[Any] = []
|
||||
for item in seq:
|
||||
encoded_list.append(_enc(item, stack, depth + 1))
|
||||
return encoded_list
|
||||
finally:
|
||||
stack.remove(oid)
|
||||
|
||||
# Primitives (or unknown objects): ensure JSON-serializable
|
||||
if isinstance(v, (str, int, float, bool)) or v is None:
|
||||
return v
|
||||
# Fallback: stringify unknown objects to avoid JSON serialization errors
|
||||
try:
|
||||
return str(v)
|
||||
except Exception:
|
||||
return f"<{type(v).__name__}>"
|
||||
|
||||
return _enc(value, set(), 0)
|
||||
return _encode(value)
|
||||
|
||||
|
||||
def decode_checkpoint_value(value: Any) -> Any:
|
||||
"""Recursively decode values previously encoded by encode_checkpoint_value."""
|
||||
"""Decode a value from checkpoint storage.
|
||||
|
||||
Reverses the encoding performed by encode_checkpoint_value.
|
||||
Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled.
|
||||
|
||||
WARNING: Only call this with trusted data. Pickle can execute
|
||||
arbitrary code during deserialization. The post-unpickle type verification
|
||||
detects accidental corruption or type mismatches, but cannot prevent
|
||||
arbitrary code execution from malicious pickle payloads.
|
||||
|
||||
Args:
|
||||
value: A JSON-deserialized value from checkpoint storage.
|
||||
|
||||
Returns:
|
||||
The original Python value.
|
||||
|
||||
Raises:
|
||||
CheckpointDecodingError: If the unpickled object's type doesn't match
|
||||
the recorded type, indicating corruption, or if the base64/pickle
|
||||
data is malformed.
|
||||
"""
|
||||
return _decode(value)
|
||||
|
||||
|
||||
def _encode(value: Any) -> Any:
|
||||
"""Recursively encode a value for JSON storage."""
|
||||
# JSON-native types pass through
|
||||
if isinstance(value, _JSON_NATIVE_TYPES):
|
||||
return value
|
||||
|
||||
# Recursively encode dict values (keys become strings)
|
||||
if isinstance(value, dict):
|
||||
value_dict = cast(dict[str, Any], value) # encoded form always uses string keys
|
||||
# Structured model marker handling
|
||||
if MODEL_MARKER in value_dict and "value" in value_dict:
|
||||
type_key: str | None = value_dict.get(MODEL_MARKER) # type: ignore[assignment]
|
||||
strategy: str | None = value_dict.get("strategy") # type: ignore[assignment]
|
||||
raw_encoded: Any = value_dict.get("value")
|
||||
decoded_payload = decode_checkpoint_value(raw_encoded)
|
||||
if isinstance(type_key, str):
|
||||
try:
|
||||
cls = _import_qualified_name(type_key)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to import structured model {type_key}: {exc}")
|
||||
cls = None
|
||||
return {str(k): _encode(v) for k, v in value.items()} # type: ignore
|
||||
|
||||
if cls is not None:
|
||||
# Verify the class actually supports the model protocol
|
||||
if not _class_supports_model_protocol(cls):
|
||||
logger.debug(f"Class {type_key} does not support model protocol; returning raw value")
|
||||
return decoded_payload
|
||||
if strategy == "to_dict" and hasattr(cls, "from_dict"):
|
||||
with contextlib.suppress(Exception):
|
||||
return cls.from_dict(decoded_payload)
|
||||
if strategy == "to_json" and hasattr(cls, "from_json"):
|
||||
if isinstance(decoded_payload, (str, bytes, bytearray)):
|
||||
with contextlib.suppress(Exception):
|
||||
return cls.from_json(decoded_payload)
|
||||
if isinstance(decoded_payload, dict) and hasattr(cls, "from_dict"):
|
||||
with contextlib.suppress(Exception):
|
||||
return cls.from_dict(decoded_payload)
|
||||
return decoded_payload
|
||||
# Dataclass marker handling
|
||||
if DATACLASS_MARKER in value_dict and "value" in value_dict:
|
||||
type_key_dc: str | None = value_dict.get(DATACLASS_MARKER) # type: ignore[assignment]
|
||||
raw_dc: Any = value_dict.get("value")
|
||||
decoded_raw = decode_checkpoint_value(raw_dc)
|
||||
if isinstance(type_key_dc, str):
|
||||
try:
|
||||
module_name, class_name = type_key_dc.split(":", 1)
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
module = importlib.import_module(module_name)
|
||||
cls_dc: Any = getattr(module, class_name)
|
||||
# Verify the class is actually a dataclass type (not an instance)
|
||||
if not isinstance(cls_dc, type) or not is_dataclass(cls_dc):
|
||||
logger.debug(f"Class {type_key_dc} is not a dataclass type; returning raw value")
|
||||
return decoded_raw
|
||||
constructed = _instantiate_checkpoint_dataclass(cls_dc, decoded_raw)
|
||||
if constructed is not None:
|
||||
return constructed
|
||||
except Exception as exc:
|
||||
logger.debug(f"Failed to decode dataclass {type_key_dc}: {exc}; returning raw value")
|
||||
return decoded_raw
|
||||
|
||||
# Regular dict: decode recursively
|
||||
decoded: dict[str, Any] = {}
|
||||
for k_any, v_any in value_dict.items():
|
||||
decoded[k_any] = decode_checkpoint_value(v_any)
|
||||
return decoded
|
||||
# Recursively encode list items (lists are JSON-native collections)
|
||||
if isinstance(value, list):
|
||||
# After isinstance check, treat value as list[Any] for decoding
|
||||
value_list: list[Any] = value # type: ignore[assignment]
|
||||
return [decode_checkpoint_value(v_any) for v_any in value_list]
|
||||
return [_encode(item) for item in value] # type: ignore
|
||||
|
||||
# Everything else (tuples, sets, dataclasses, custom objects, etc.): pickle and base64 encode
|
||||
return {
|
||||
_PICKLE_MARKER: _pickle_to_base64(value),
|
||||
_TYPE_MARKER: _type_to_key(type(value)), # type: ignore
|
||||
}
|
||||
|
||||
|
||||
def _decode(value: Any) -> Any:
|
||||
"""Recursively decode a value from JSON storage."""
|
||||
# JSON-native types pass through
|
||||
if isinstance(value, _JSON_NATIVE_TYPES):
|
||||
return value
|
||||
|
||||
# Handle encoded dicts
|
||||
if isinstance(value, dict):
|
||||
# Pickled value: decode, unpickle, and verify type
|
||||
if _PICKLE_MARKER in value and _TYPE_MARKER in value:
|
||||
obj = _base64_to_unpickle(value[_PICKLE_MARKER]) # type: ignore
|
||||
_verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore
|
||||
return obj
|
||||
|
||||
# Regular dict: decode values recursively
|
||||
return {k: _decode(v) for k, v in value.items()} # type: ignore
|
||||
|
||||
# Handle encoded lists
|
||||
if isinstance(value, list):
|
||||
return [_decode(item) for item in value] # type: ignore
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _class_supports_model_protocol(cls: type[Any]) -> bool:
|
||||
"""Check if a class type supports the model serialization protocol.
|
||||
def _verify_type(obj: Any, expected_type_key: str) -> None:
|
||||
"""Verify that an unpickled object matches its recorded type.
|
||||
|
||||
Checks for pairs of serialization/deserialization methods:
|
||||
- to_dict/from_dict
|
||||
- to_json/from_json
|
||||
This is a post-deserialization integrity check that detects accidental
|
||||
corruption or type mismatches. It does not prevent arbitrary code execution
|
||||
from malicious pickle payloads, since ``pickle.loads()`` has already
|
||||
executed by the time this function is called.
|
||||
|
||||
Args:
|
||||
obj: The unpickled object.
|
||||
expected_type_key: The recorded type key (module:qualname format).
|
||||
|
||||
Raises:
|
||||
CheckpointDecodingError: If the types don't match.
|
||||
"""
|
||||
has_to_dict = hasattr(cls, "to_dict") and callable(getattr(cls, "to_dict", None))
|
||||
has_from_dict = hasattr(cls, "from_dict") and callable(getattr(cls, "from_dict", None))
|
||||
|
||||
has_to_json = hasattr(cls, "to_json") and callable(getattr(cls, "to_json", None))
|
||||
has_from_json = hasattr(cls, "from_json") and callable(getattr(cls, "from_json", None))
|
||||
|
||||
return (has_to_dict and has_from_dict) or (has_to_json and has_from_json)
|
||||
actual_type_key = _type_to_key(type(obj)) # type: ignore
|
||||
if actual_type_key != expected_type_key:
|
||||
raise CheckpointDecodingError(
|
||||
f"Type mismatch during checkpoint decoding: "
|
||||
f"expected '{expected_type_key}', got '{actual_type_key}'. "
|
||||
f"The checkpoint may be corrupted or tampered with."
|
||||
)
|
||||
|
||||
|
||||
def _supports_model_protocol(obj: object) -> bool:
|
||||
"""Detect objects that expose dictionary serialization hooks."""
|
||||
def _pickle_to_base64(value: Any) -> str:
|
||||
"""Pickle a value and encode as base64 string."""
|
||||
pickled = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
return base64.b64encode(pickled).decode("ascii")
|
||||
|
||||
|
||||
def _base64_to_unpickle(encoded: str) -> Any:
|
||||
"""Decode base64 string and unpickle.
|
||||
|
||||
Raises:
|
||||
CheckpointDecodingError: If the base64 data is corrupted or the pickle
|
||||
format is incompatible.
|
||||
"""
|
||||
try:
|
||||
obj_type: type[Any] = type(obj)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return _class_supports_model_protocol(obj_type)
|
||||
|
||||
|
||||
def _import_qualified_name(qualname: str) -> type[Any] | None:
|
||||
if ":" not in qualname:
|
||||
return None
|
||||
module_name, class_name = qualname.split(":", 1)
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
module = importlib.import_module(module_name)
|
||||
attr: Any = module
|
||||
for part in class_name.split("."):
|
||||
attr = getattr(attr, part)
|
||||
return attr if isinstance(attr, type) else None
|
||||
|
||||
|
||||
def _instantiate_checkpoint_dataclass(cls: type[Any], payload: Any) -> Any | None:
|
||||
if not isinstance(cls, type):
|
||||
logger.debug(f"Checkpoint decoder received non-type dataclass reference: {cls!r}")
|
||||
return None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
try:
|
||||
return cls(**payload) # type: ignore[arg-type]
|
||||
except TypeError as exc:
|
||||
logger.debug(f"Checkpoint decoder could not call {cls.__name__}(**payload): {exc}")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}(**payload): {exc}")
|
||||
try:
|
||||
instance = object.__new__(cls)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Checkpoint decoder could not allocate {cls.__name__} without __init__: {exc}")
|
||||
return None
|
||||
for key, val in payload.items(): # type: ignore[attr-defined]
|
||||
try:
|
||||
setattr(instance, key, val) # type: ignore[arg-type]
|
||||
except Exception as exc:
|
||||
logger.debug(f"Checkpoint decoder could not set attribute {key} on {cls.__name__}: {exc}")
|
||||
return instance
|
||||
|
||||
try:
|
||||
return cls(payload) # type: ignore[call-arg]
|
||||
except TypeError as exc:
|
||||
logger.debug(f"Checkpoint decoder could not call {cls.__name__}({payload!r}): {exc}")
|
||||
pickled = base64.b64decode(encoded.encode("ascii"))
|
||||
return pickle.loads(pickled) # nosec # noqa: S301
|
||||
except Exception as exc:
|
||||
logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}({payload!r}): {exc}")
|
||||
return None
|
||||
raise CheckpointDecodingError(f"Failed to decode pickled checkpoint data: {exc}") from exc
|
||||
|
||||
|
||||
def _type_to_key(t: type) -> str:
|
||||
"""Convert a type to a module:qualname string."""
|
||||
return f"{t.__module__}:{t.__qualname__}"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ._checkpoint import WorkflowCheckpoint
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._events import WorkflowEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowCheckpointSummary:
|
||||
"""Human-readable summary of a workflow checkpoint."""
|
||||
|
||||
checkpoint_id: str
|
||||
timestamp: str
|
||||
iteration_count: int
|
||||
targets: list[str]
|
||||
executor_ids: list[str]
|
||||
status: str
|
||||
pending_request_info_events: list[WorkflowEvent]
|
||||
|
||||
|
||||
def get_checkpoint_summary(checkpoint: WorkflowCheckpoint) -> WorkflowCheckpointSummary:
|
||||
targets = sorted(checkpoint.messages.keys())
|
||||
executor_ids = sorted(checkpoint.state.get(EXECUTOR_STATE_KEY, {}).keys())
|
||||
pending_request_info_events = [
|
||||
WorkflowEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
|
||||
]
|
||||
|
||||
status = "idle"
|
||||
if pending_request_info_events:
|
||||
status = "awaiting request response"
|
||||
elif not checkpoint.messages and "finalise" in executor_ids:
|
||||
status = "completed"
|
||||
elif checkpoint.messages:
|
||||
status = "awaiting next superstep"
|
||||
|
||||
return WorkflowCheckpointSummary(
|
||||
checkpoint_id=checkpoint.checkpoint_id,
|
||||
timestamp=checkpoint.timestamp,
|
||||
iteration_count=checkpoint.iteration_count,
|
||||
targets=targets,
|
||||
executor_ids=executor_ids,
|
||||
status=status,
|
||||
pending_request_info_events=pending_request_info_events,
|
||||
)
|
||||
@@ -1,75 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import Message
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
|
||||
"""Utilities for serializing and deserializing chat conversations for persistence.
|
||||
|
||||
These helpers convert rich `Message` instances to checkpoint-friendly payloads
|
||||
using the same encoding primitives as the workflow runner. This preserves
|
||||
`additional_properties` and other metadata without relying on unsafe mechanisms
|
||||
such as pickling.
|
||||
"""
|
||||
|
||||
|
||||
def encode_chat_messages(messages: Iterable[Message]) -> list[dict[str, Any]]:
|
||||
"""Serialize chat messages into checkpoint-safe payloads."""
|
||||
encoded: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
encoded.append({
|
||||
"role": encode_checkpoint_value(message.role),
|
||||
"contents": [encode_checkpoint_value(content) for content in message.contents],
|
||||
"author_name": message.author_name,
|
||||
"message_id": message.message_id,
|
||||
"additional_properties": {
|
||||
key: encode_checkpoint_value(value) for key, value in message.additional_properties.items()
|
||||
},
|
||||
})
|
||||
return encoded
|
||||
|
||||
|
||||
def decode_chat_messages(payload: Iterable[dict[str, Any]]) -> list[Message]:
|
||||
"""Restore chat messages from checkpoint-safe payloads."""
|
||||
restored: list[Message] = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
role_value = decode_checkpoint_value(item.get("role"))
|
||||
if isinstance(role_value, str):
|
||||
role = role_value
|
||||
elif isinstance(role_value, dict) and "value" in role_value:
|
||||
# Handle legacy serialization format
|
||||
role = role_value["value"]
|
||||
else:
|
||||
role = "assistant"
|
||||
|
||||
contents_field = item.get("contents", [])
|
||||
contents: list[Any] = []
|
||||
if isinstance(contents_field, list):
|
||||
contents_iter: list[Any] = contents_field # type: ignore[assignment]
|
||||
for entry in contents_iter:
|
||||
decoded_entry: Any = decode_checkpoint_value(entry)
|
||||
contents.append(decoded_entry)
|
||||
|
||||
additional_field = item.get("additional_properties", {})
|
||||
additional: dict[str, Any] = {}
|
||||
if isinstance(additional_field, dict):
|
||||
additional_dict = cast(dict[str, Any], additional_field)
|
||||
for key, value in additional_dict.items():
|
||||
additional[key] = decode_checkpoint_value(value)
|
||||
|
||||
restored.append(
|
||||
Message( # type: ignore[call-overload]
|
||||
role=role,
|
||||
contents=contents,
|
||||
author_name=item.get("author_name"),
|
||||
message_id=item.get("message_id"),
|
||||
additional_properties=additional,
|
||||
)
|
||||
)
|
||||
return restored
|
||||
@@ -12,7 +12,6 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Generic, Literal, cast
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._typing_utils import deserialize_type, serialize_type
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
@@ -396,7 +395,7 @@ class WorkflowEvent(Generic[DataT]):
|
||||
raise ValueError(f"to_dict() only supported for 'request_info' events, got '{self.type}'")
|
||||
return {
|
||||
"type": self.type,
|
||||
"data": encode_checkpoint_value(self.data),
|
||||
"data": self.data,
|
||||
"request_id": self._request_id,
|
||||
"source_executor_id": self._source_executor_id,
|
||||
"request_type": serialize_type(self._request_type) if self._request_type else None,
|
||||
@@ -410,7 +409,7 @@ class WorkflowEvent(Generic[DataT]):
|
||||
if prop not in data:
|
||||
raise KeyError(f"Missing '{prop}' field in WorkflowEvent dictionary.")
|
||||
|
||||
request_data = decode_checkpoint_value(data["data"])
|
||||
request_data = data["data"]
|
||||
request_type = deserialize_type(data["request_type"])
|
||||
|
||||
if request_type is not type(request_data):
|
||||
|
||||
@@ -7,12 +7,7 @@ from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from ._checkpoint_encoding import (
|
||||
DATACLASS_MARKER,
|
||||
MODEL_MARKER,
|
||||
decode_checkpoint_value,
|
||||
)
|
||||
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._edge import EdgeGroup
|
||||
from ._edge_runner import EdgeRunner, create_edge_runner
|
||||
@@ -41,8 +36,9 @@ class Runner:
|
||||
executors: dict[str, Executor],
|
||||
state: State,
|
||||
ctx: RunnerContext,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
max_iterations: int = 100,
|
||||
workflow_id: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the runner with edges, state, and context.
|
||||
|
||||
@@ -51,24 +47,24 @@ class Runner:
|
||||
executors: Map of executor IDs to executor instances.
|
||||
state: The state for the workflow.
|
||||
ctx: The runner context for the workflow.
|
||||
workflow_name: The name of the workflow, used for checkpoint labeling.
|
||||
graph_signature_hash: A hash representing the workflow graph topology for checkpoint validation.
|
||||
max_iterations: The maximum number of iterations to run.
|
||||
workflow_id: The workflow ID for checkpointing.
|
||||
"""
|
||||
# Workflow instance related attributes
|
||||
self._executors = executors
|
||||
self._edge_runners = [create_edge_runner(group, executors) for group in edge_groups]
|
||||
self._edge_runner_map = self._parse_edge_runners(self._edge_runners)
|
||||
self._ctx = ctx
|
||||
self._workflow_name = workflow_name
|
||||
self._graph_signature_hash = graph_signature_hash
|
||||
|
||||
# Runner state related attributes
|
||||
self._iteration = 0
|
||||
self._max_iterations = max_iterations
|
||||
self._state = state
|
||||
self._workflow_id = workflow_id
|
||||
self._running = False
|
||||
self._resumed_from_checkpoint = False # Track whether we resumed
|
||||
self.graph_signature_hash: str | None = None
|
||||
|
||||
# Set workflow ID in context if provided
|
||||
if workflow_id:
|
||||
self._ctx.set_workflow_id(workflow_id)
|
||||
|
||||
@property
|
||||
def context(self) -> RunnerContext:
|
||||
@@ -85,6 +81,7 @@ class Runner:
|
||||
raise WorkflowRunnerException("Runner is already running.")
|
||||
|
||||
self._running = True
|
||||
previous_checkpoint_id: CheckpointID | None = None
|
||||
try:
|
||||
# Emit any events already produced prior to entering loop
|
||||
if await self._ctx.has_events():
|
||||
@@ -92,13 +89,12 @@ class Runner:
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
|
||||
# Create first checkpoint if there are messages from initial execution
|
||||
if await self._ctx.has_messages() and self._ctx.has_checkpointing():
|
||||
if not self._resumed_from_checkpoint:
|
||||
logger.info("Creating checkpoint after initial execution")
|
||||
await self._create_checkpoint_if_enabled("after_initial_execution")
|
||||
else:
|
||||
logger.info("Skipping 'after_initial_execution' checkpoint because we resumed from a checkpoint")
|
||||
# Create the first checkpoint. Checkpoints are usually considered to be created at the end of an iteration,
|
||||
# we can think of the first checkpoint as being created at the end of a "superstep 0" which captures the
|
||||
# states after which the start executor has run. Note that we execute the start executor outside of the
|
||||
# main iteration loop.
|
||||
if await self._ctx.has_messages() and not self._resumed_from_checkpoint:
|
||||
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
|
||||
|
||||
while self._iteration < self._max_iterations:
|
||||
logger.info(f"Starting superstep {self._iteration + 1}")
|
||||
@@ -145,7 +141,7 @@ class Runner:
|
||||
self._state.commit()
|
||||
|
||||
# Create checkpoint after each superstep iteration
|
||||
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
|
||||
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
|
||||
|
||||
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
|
||||
|
||||
@@ -169,19 +165,6 @@ class Runner:
|
||||
"""Inner loop to deliver a single message through an edge runner."""
|
||||
return await edge_runner.send_message(message, self._state, self._ctx)
|
||||
|
||||
def _normalize_message_payload(message: WorkflowMessage) -> None:
|
||||
data = message.data
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
if MODEL_MARKER not in data and DATACLASS_MARKER not in data:
|
||||
return
|
||||
try:
|
||||
decoded = decode_checkpoint_value(data)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("Failed to decode checkpoint payload during delivery: %s", exc)
|
||||
return
|
||||
message.data = decoded
|
||||
|
||||
# Route all messages through normal workflow edges
|
||||
associated_edge_runners = self._edge_runner_map.get(source_executor_id, [])
|
||||
if not associated_edge_runners:
|
||||
@@ -190,7 +173,6 @@ class Runner:
|
||||
return
|
||||
|
||||
for message in messages:
|
||||
_normalize_message_payload(message)
|
||||
# Deliver a message through all edge runners associated with the source executor concurrently.
|
||||
tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners]
|
||||
await asyncio.gather(*tasks)
|
||||
@@ -199,35 +181,33 @@ class Runner:
|
||||
tasks = [_deliver_messages(source_executor_id, messages) for source_executor_id, messages in messages.items()]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _create_checkpoint_if_enabled(self, checkpoint_type: str) -> str | None:
|
||||
async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None:
|
||||
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
|
||||
if not self._ctx.has_checkpointing():
|
||||
return None
|
||||
|
||||
try:
|
||||
# Snapshot executor states
|
||||
# Save executor states into the shared state before creating the checkpoint,
|
||||
# so that they are included in the checkpoint payload.
|
||||
await self._save_executor_states()
|
||||
checkpoint_category = "initial" if checkpoint_type == "after_initial_execution" else "superstep"
|
||||
metadata = {
|
||||
"superstep": self._iteration,
|
||||
"checkpoint_type": checkpoint_category,
|
||||
}
|
||||
if self.graph_signature_hash:
|
||||
metadata["graph_signature"] = self.graph_signature_hash
|
||||
|
||||
checkpoint_id = await self._ctx.create_checkpoint(
|
||||
self._workflow_name,
|
||||
self._graph_signature_hash,
|
||||
self._state,
|
||||
previous_checkpoint_id,
|
||||
self._iteration,
|
||||
metadata=metadata,
|
||||
)
|
||||
logger.info(f"Created {checkpoint_type} checkpoint: {checkpoint_id}")
|
||||
|
||||
logger.info(f"Created checkpoint: {checkpoint_id}")
|
||||
return checkpoint_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create {checkpoint_type} checkpoint: {e}")
|
||||
logger.warning(f"Failed to create checkpoint: {e}")
|
||||
return None
|
||||
|
||||
async def restore_from_checkpoint(
|
||||
self,
|
||||
checkpoint_id: str,
|
||||
checkpoint_id: CheckpointID,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
) -> None:
|
||||
"""Restore workflow state from a checkpoint.
|
||||
@@ -249,7 +229,7 @@ class Runner:
|
||||
if self._ctx.has_checkpointing():
|
||||
checkpoint = await self._ctx.load_checkpoint(checkpoint_id)
|
||||
elif checkpoint_storage is not None:
|
||||
checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id)
|
||||
checkpoint = await checkpoint_storage.load(checkpoint_id)
|
||||
else:
|
||||
raise WorkflowCheckpointException(
|
||||
"Cannot load checkpoint: no checkpointing configured in context or external storage provided."
|
||||
@@ -260,22 +240,14 @@ class Runner:
|
||||
raise WorkflowCheckpointException(f"Checkpoint {checkpoint_id} not found")
|
||||
|
||||
# Validate the loaded checkpoint against the workflow
|
||||
graph_hash = getattr(self, "graph_signature_hash", None)
|
||||
checkpoint_hash = (checkpoint.metadata or {}).get("graph_signature")
|
||||
if graph_hash and checkpoint_hash and graph_hash != checkpoint_hash:
|
||||
if self._graph_signature_hash != checkpoint.graph_signature_hash:
|
||||
raise WorkflowCheckpointException(
|
||||
"Workflow graph has changed since the checkpoint was created. "
|
||||
"Please rebuild the original workflow before resuming."
|
||||
)
|
||||
if graph_hash and not checkpoint_hash:
|
||||
logger.warning(
|
||||
"Checkpoint %s does not include graph signature metadata; skipping topology validation.",
|
||||
checkpoint_id,
|
||||
)
|
||||
|
||||
self._workflow_id = checkpoint.workflow_id
|
||||
# Restore state
|
||||
self._state.import_state(decode_checkpoint_value(checkpoint.state))
|
||||
self._state.import_state(checkpoint.state)
|
||||
# Restore executor states using the restored state
|
||||
await self._restore_executor_states()
|
||||
# Apply the checkpoint to the context
|
||||
@@ -291,64 +263,19 @@ class Runner:
|
||||
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint_id}") from e
|
||||
|
||||
async def _save_executor_states(self) -> None:
|
||||
"""Populate executor state by calling checkpoint hooks on executors.
|
||||
|
||||
Backward compatibility behavior:
|
||||
- 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.
|
||||
|
||||
Updated behavior:
|
||||
- Executors should implement `on_checkpoint_save(self) -> dict` to provide state.
|
||||
|
||||
This method will try the backward compatibility behavior first; if that does not yield state,
|
||||
it falls back to the updated behavior.
|
||||
|
||||
Only JSON-serializable dicts should be provided by executors.
|
||||
"""
|
||||
"""Populate executor state by calling checkpoint hooks on executors."""
|
||||
for exec_id, executor in self._executors.items():
|
||||
state_dict: dict[str, Any] | None = None
|
||||
# Try backward compatibility behavior first
|
||||
# TODO(@taochen): Remove backward compatibility
|
||||
snapshot = getattr(executor, "snapshot_state", None)
|
||||
try:
|
||||
if callable(snapshot):
|
||||
maybe = snapshot()
|
||||
if asyncio.iscoroutine(maybe): # type: ignore[arg-type]
|
||||
maybe = await maybe # type: ignore[assignment]
|
||||
if isinstance(maybe, dict):
|
||||
state_dict = maybe # type: ignore[assignment]
|
||||
else:
|
||||
state_attr = getattr(executor, "state", None)
|
||||
if isinstance(state_attr, dict):
|
||||
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 None:
|
||||
# Try the updated behavior only if backward compatibility did not yield state
|
||||
try:
|
||||
state_dict = await executor.on_checkpoint_save()
|
||||
except Exception as ex: # pragma: no cover
|
||||
raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex
|
||||
|
||||
# Try the updated behavior only if backward compatibility did not yield state
|
||||
try:
|
||||
state_dict = await executor.on_checkpoint_save()
|
||||
await self._set_executor_state(exec_id, state_dict)
|
||||
except WorkflowCheckpointException:
|
||||
raise
|
||||
except Exception as ex: # pragma: no cover
|
||||
logger.debug(f"Failed to persist state for executor {exec_id}: {ex}")
|
||||
raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex
|
||||
|
||||
async def _restore_executor_states(self) -> None:
|
||||
"""Restore executor state by calling restore hooks on executors.
|
||||
|
||||
Backward compatibility behavior:
|
||||
- If an executor defines an async or sync method `restore_state(self, state: dict)`, use it.
|
||||
- Else, skip restoration for that executor.
|
||||
|
||||
Updated behavior:
|
||||
- Executors should implement `on_checkpoint_restore(self, state: dict)` to restore state.
|
||||
|
||||
This method will try the backward compatibility behavior first; if that does not restore state,
|
||||
it falls back to the updated behavior.
|
||||
"""
|
||||
"""Restore executor state by calling restore hooks on executors."""
|
||||
has_executor_states = self._state.has(EXECUTOR_STATE_KEY)
|
||||
if not has_executor_states:
|
||||
return
|
||||
@@ -369,29 +296,11 @@ class Runner:
|
||||
if not executor:
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} not found during state restoration.")
|
||||
|
||||
# Try backward compatibility behavior first
|
||||
# TODO(@taochen): Remove backward compatibility
|
||||
restored = False
|
||||
restore_method = getattr(executor, "restore_state", None)
|
||||
# Try the updated behavior only if backward compatibility did not restore
|
||||
try:
|
||||
if callable(restore_method):
|
||||
maybe = restore_method(state)
|
||||
if asyncio.iscoroutine(maybe): # type: ignore[arg-type]
|
||||
await maybe # type: ignore[arg-type]
|
||||
restored = True
|
||||
await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType]
|
||||
except Exception as ex: # pragma: no cover - defensive
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} restore_state failed") from ex
|
||||
|
||||
if not restored:
|
||||
# Try the updated behavior only if backward compatibility did not restore
|
||||
try:
|
||||
await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType]
|
||||
restored = True
|
||||
except Exception as ex: # pragma: no cover - defensive
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex
|
||||
|
||||
if not restored:
|
||||
logger.debug(f"Executor {executor_id} does not support state restoration; skipping.")
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex
|
||||
|
||||
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.
|
||||
|
||||
@@ -4,25 +4,17 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from copy import copy
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
|
||||
from ._const import INTERNAL_SOURCE_ID
|
||||
from ._events import WorkflowEvent
|
||||
from ._state import State
|
||||
from ._typing_utils import is_instance_of
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypedDict # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import TypedDict # type: ignore # pragma: no cover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -69,13 +61,13 @@ class WorkflowMessage:
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert the WorkflowMessage to a dictionary for serialization."""
|
||||
return {
|
||||
"data": encode_checkpoint_value(self.data),
|
||||
"data": self.data,
|
||||
"source_id": self.source_id,
|
||||
"target_id": self.target_id,
|
||||
"type": self.type.value,
|
||||
"trace_contexts": self.trace_contexts,
|
||||
"source_span_ids": self.source_span_ids,
|
||||
"original_request_info_event": encode_checkpoint_value(self.original_request_info_event),
|
||||
"original_request_info_event": self.original_request_info_event,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -89,28 +81,16 @@ class WorkflowMessage:
|
||||
raise KeyError("Missing 'source_id' field in WorkflowMessage dictionary.")
|
||||
|
||||
return WorkflowMessage(
|
||||
data=decode_checkpoint_value(data["data"]),
|
||||
data=data["data"],
|
||||
source_id=data["source_id"],
|
||||
target_id=data.get("target_id"),
|
||||
type=MessageType(data.get("type", "standard")),
|
||||
trace_contexts=data.get("trace_contexts"),
|
||||
source_span_ids=data.get("source_span_ids"),
|
||||
original_request_info_event=decode_checkpoint_value(data.get("original_request_info_event")),
|
||||
original_request_info_event=data.get("original_request_info_event"),
|
||||
)
|
||||
|
||||
|
||||
class _WorkflowState(TypedDict):
|
||||
"""TypedDict representing the serializable state of a workflow execution.
|
||||
|
||||
This includes all state data needed for checkpointing and restoration.
|
||||
"""
|
||||
|
||||
messages: dict[str, list[dict[str, Any]]]
|
||||
state: dict[str, Any]
|
||||
iteration_count: int
|
||||
pending_request_info_events: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RunnerContext(Protocol):
|
||||
"""Protocol for the execution context used by the runner.
|
||||
@@ -192,11 +172,6 @@ class RunnerContext(Protocol):
|
||||
"""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."""
|
||||
...
|
||||
|
||||
def reset_for_new_run(self) -> None:
|
||||
"""Reset the context for a new workflow run."""
|
||||
...
|
||||
@@ -219,16 +194,23 @@ class RunnerContext(Protocol):
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: CheckpointID | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
) -> CheckpointID:
|
||||
"""Create a checkpoint of the current workflow state.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow for which the checkpoint is being created.
|
||||
graph_signature_hash: Hash of the workflow graph topology to
|
||||
validate checkpoint compatibility during restore.
|
||||
state: The state to include in the checkpoint.
|
||||
This is needed to capture the full state of the workflow.
|
||||
The state is not managed by the context itself.
|
||||
previous_checkpoint_id: The ID of the previous checkpoint, if any, to form a checkpoint chain.
|
||||
iteration_count: The current iteration count of the workflow.
|
||||
metadata: Optional metadata to associate with the checkpoint.
|
||||
|
||||
@@ -237,7 +219,7 @@ class RunnerContext(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint | None:
|
||||
"""Load a checkpoint without mutating the current context state.
|
||||
|
||||
Args:
|
||||
@@ -301,7 +283,6 @@ 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=True) vs run(..., stream=False)
|
||||
self._streaming: bool = False
|
||||
@@ -376,34 +357,36 @@ class InProcRunnerContext:
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: CheckpointID | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
) -> CheckpointID:
|
||||
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())
|
||||
workflow_state = self._get_serialized_workflow_state(state, iteration_count)
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_id=self._workflow_id,
|
||||
messages=workflow_state["messages"],
|
||||
state=workflow_state["state"],
|
||||
pending_request_info_events=workflow_state["pending_request_info_events"],
|
||||
iteration_count=workflow_state["iteration_count"],
|
||||
workflow_name=workflow_name,
|
||||
graph_signature_hash=graph_signature_hash,
|
||||
previous_checkpoint_id=previous_checkpoint_id,
|
||||
messages=dict(self._messages),
|
||||
state=state.export_state(),
|
||||
pending_request_info_events=dict(self._pending_request_info_events),
|
||||
iteration_count=iteration_count,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
checkpoint_id = await storage.save_checkpoint(checkpoint)
|
||||
logger.info(f"Created checkpoint {checkpoint_id} for workflow {self._workflow_id}")
|
||||
checkpoint_id = await storage.save(checkpoint)
|
||||
logger.debug(f"Created checkpoint {checkpoint_id}")
|
||||
return checkpoint_id
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
|
||||
storage = self._get_effective_checkpoint_storage()
|
||||
if not storage:
|
||||
raise ValueError("Checkpoint storage not configured")
|
||||
return await storage.load_checkpoint(checkpoint_id)
|
||||
return await storage.load(checkpoint_id)
|
||||
|
||||
def reset_for_new_run(self) -> None:
|
||||
"""Reset the context for a new workflow run.
|
||||
@@ -422,24 +405,16 @@ class InProcRunnerContext:
|
||||
self._messages.clear()
|
||||
messages_data = checkpoint.messages
|
||||
for source_id, message_list in messages_data.items():
|
||||
self._messages[source_id] = [WorkflowMessage.from_dict(msg) for msg in message_list]
|
||||
self._messages[source_id] = list(message_list)
|
||||
|
||||
# Restore pending request info events
|
||||
self._pending_request_info_events.clear()
|
||||
pending_requests_data = checkpoint.pending_request_info_events
|
||||
for request_id, request_data in pending_requests_data.items():
|
||||
request_info_event = WorkflowEvent.from_dict(request_data)
|
||||
for request_id, request_info_event in checkpoint.pending_request_info_events.items():
|
||||
self._pending_request_info_events[request_id] = request_info_event
|
||||
await self.add_event(request_info_event)
|
||||
|
||||
# Restore workflow ID
|
||||
self._workflow_id = checkpoint.workflow_id
|
||||
|
||||
# endregion Checkpointing
|
||||
|
||||
def set_workflow_id(self, workflow_id: str) -> None:
|
||||
self._workflow_id = workflow_id
|
||||
|
||||
def set_streaming(self, streaming: bool) -> None:
|
||||
"""Set whether agents should stream incremental updates.
|
||||
|
||||
@@ -456,30 +431,14 @@ class InProcRunnerContext:
|
||||
"""
|
||||
return self._streaming
|
||||
|
||||
def _get_serialized_workflow_state(self, state: State, iteration_count: int) -> _WorkflowState:
|
||||
serialized_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
for source_id, message_list in self._messages.items():
|
||||
serialized_messages[source_id] = [msg.to_dict() for msg in message_list]
|
||||
|
||||
serialized_pending_request_info_events: dict[str, dict[str, Any]] = {
|
||||
request_id: request.to_dict() for request_id, request in self._pending_request_info_events.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"messages": serialized_messages,
|
||||
"state": encode_checkpoint_value(state.export_state()),
|
||||
"iteration_count": iteration_count,
|
||||
"pending_request_info_events": serialized_pending_request_info_events,
|
||||
}
|
||||
|
||||
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
|
||||
"""Add a request_info event to the context and track it for correlation.
|
||||
|
||||
Args:
|
||||
event: The WorkflowEvent with type='request_info' to be added.
|
||||
"""
|
||||
if event.request_id is None:
|
||||
raise ValueError("request_info event must have a request_id")
|
||||
if event.type != "request_info":
|
||||
raise ValueError("Event type must be 'request_info'")
|
||||
self._pending_request_info_events[event.request_id] = event
|
||||
await self.add_event(event)
|
||||
|
||||
|
||||
@@ -175,9 +175,9 @@ class Workflow(DictConvertible):
|
||||
executors: dict[str, Executor],
|
||||
start_executor: Executor,
|
||||
runner_context: RunnerContext,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
name: str | None = None,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
output_executors: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
@@ -189,8 +189,12 @@ class Workflow(DictConvertible):
|
||||
start_executor: The starting executor for the workflow.
|
||||
runner_context: The RunnerContext instance to be used during workflow execution.
|
||||
max_iterations: The maximum number of iterations the workflow will run for convergence.
|
||||
name: Optional human-readable name for the workflow.
|
||||
description: Optional description of what the workflow does.
|
||||
name: A human-readable name for the workflow. This can be used to identify the workflow in
|
||||
checkpoints, and telemetry. If the workflow is built using WorkflowBuilder, this will be the
|
||||
name of the builder. This name should be unique across different workflow definitions for
|
||||
better observability and management.
|
||||
description: Optional description of what the workflow does. If the workflow is built using
|
||||
WorkflowBuilder, this will be the description of the builder.
|
||||
output_executors: Optional list of executor IDs whose outputs will be considered workflow outputs.
|
||||
If None or empty, all executor outputs are treated as workflow outputs.
|
||||
kwargs: Additional keyword arguments. Unused in this implementation.
|
||||
@@ -199,9 +203,15 @@ class Workflow(DictConvertible):
|
||||
self.executors = dict(executors)
|
||||
self.start_executor_id = start_executor.id
|
||||
self.max_iterations = max_iterations
|
||||
self.id = str(uuid.uuid4())
|
||||
self.name = name
|
||||
self.description = description
|
||||
# Generate a unique ID for the workflow instance for monitoring purposes. This is not intended to be a
|
||||
# stable identifier across instances created from the same builder, for that, use the name field.
|
||||
self.id = str(uuid.uuid4())
|
||||
# Capture a canonical fingerprint of the workflow graph so checkpoints can assert they are resumed with
|
||||
# an equivalent topology.
|
||||
self.graph_signature = self._compute_graph_signature()
|
||||
self.graph_signature_hash = self._hash_graph_signature(self.graph_signature)
|
||||
|
||||
# Output events (WorkflowEvent with type='output') from these executors are treated as workflow outputs.
|
||||
# If None or empty, all executor outputs are considered workflow outputs.
|
||||
@@ -215,19 +225,14 @@ class Workflow(DictConvertible):
|
||||
self.executors,
|
||||
self._state,
|
||||
runner_context,
|
||||
self.name,
|
||||
self.graph_signature_hash,
|
||||
max_iterations=max_iterations,
|
||||
workflow_id=self.id,
|
||||
)
|
||||
|
||||
# Flag to prevent concurrent workflow executions
|
||||
self._is_running = False
|
||||
|
||||
# Capture a canonical fingerprint of the workflow graph so checkpoints
|
||||
# can assert they are resumed with an equivalent topology.
|
||||
self._graph_signature = self._compute_graph_signature()
|
||||
self._graph_signature_hash = self._hash_graph_signature(self._graph_signature)
|
||||
self._runner.graph_signature_hash = self._graph_signature_hash
|
||||
|
||||
def _ensure_not_running(self) -> None:
|
||||
"""Ensure the workflow is not already running."""
|
||||
if self._is_running:
|
||||
@@ -241,6 +246,7 @@ class Workflow(DictConvertible):
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the workflow definition into a JSON-ready dictionary."""
|
||||
data: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"id": self.id,
|
||||
"start_executor_id": self.start_executor_id,
|
||||
"max_iterations": self.max_iterations,
|
||||
@@ -249,9 +255,6 @@ class Workflow(DictConvertible):
|
||||
"output_executors": self._output_executors,
|
||||
}
|
||||
|
||||
# Add optional name and description if provided
|
||||
if self.name is not None:
|
||||
data["name"] = self.name
|
||||
if self.description is not None:
|
||||
data["description"] = self.description
|
||||
|
||||
@@ -565,6 +568,15 @@ class Workflow(DictConvertible):
|
||||
):
|
||||
if event.type == "output" and not self._should_yield_output_event(event):
|
||||
continue
|
||||
if event.type == "request_info" and event.request_id in (responses or {}):
|
||||
# Don't yield request_info events for which we have responses to send -
|
||||
# these are considered "handled". This prevents the caller from seeing
|
||||
# events for requests they are already responding to.
|
||||
# This usually happens when responses are provided with a checkpoint
|
||||
# (restore then send), because the request_info events are stored in the
|
||||
# checkpoint and would be emitted on restoration by the runner regardless
|
||||
# of if a response is provided or not.
|
||||
continue
|
||||
yield event
|
||||
|
||||
async def _run_cleanup(self, checkpoint_storage: CheckpointStorage | None) -> None:
|
||||
@@ -753,7 +765,7 @@ class Workflow(DictConvertible):
|
||||
if isinstance(executor, WorkflowExecutor):
|
||||
executor_sig = {
|
||||
"type": executor_sig,
|
||||
"sub_workflow": executor.workflow._graph_signature,
|
||||
"sub_workflow": executor.workflow.graph_signature,
|
||||
}
|
||||
|
||||
executors_signature[executor_id] = executor_sig
|
||||
@@ -796,7 +808,6 @@ class Workflow(DictConvertible):
|
||||
"start_executor": self.start_executor_id,
|
||||
"executors": executors_signature,
|
||||
"edge_groups": edge_groups_signature,
|
||||
"max_iterations": self.max_iterations,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -804,10 +815,6 @@ class Workflow(DictConvertible):
|
||||
canonical = json.dumps(signature, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
@property
|
||||
def graph_signature_hash(self) -> str:
|
||||
return self._graph_signature_hash
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the input types of the workflow.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
@@ -88,7 +89,11 @@ class WorkflowBuilder:
|
||||
|
||||
Args:
|
||||
max_iterations: Maximum number of iterations for workflow convergence. Default is 100.
|
||||
name: Optional human-readable name for the workflow.
|
||||
name: A human-readable name for the workflow builder. This name will be the identifier
|
||||
for all workflow instances created from this builder. If not provided, a unique name
|
||||
will be generated. This will be useful for versioning, monitoring, checkpointing, and
|
||||
debugging workflows. Keeping this name unique across versions of your workflow definitions
|
||||
is recommended for better observability and management.
|
||||
description: Optional description of what the workflow does.
|
||||
start_executor: The starting executor for the workflow. Can be an Executor instance
|
||||
or SupportsAgentRun instance.
|
||||
@@ -101,7 +106,7 @@ class WorkflowBuilder:
|
||||
self._start_executor: Executor | None = None
|
||||
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
|
||||
self._max_iterations: int = max_iterations
|
||||
self._name: str | None = name
|
||||
self._name: str = name or f"WorkflowBuilder-{uuid.uuid4()!s}"
|
||||
self._description: str | None = description
|
||||
# Maps underlying SupportsAgentRun object id -> wrapped Executor so we reuse the same wrapper
|
||||
# across start_executor / add_edge calls. This avoids multiple AgentExecutor instances
|
||||
@@ -658,19 +663,18 @@ class WorkflowBuilder:
|
||||
executors,
|
||||
start_executor,
|
||||
context,
|
||||
self._max_iterations,
|
||||
name=self._name,
|
||||
self._name,
|
||||
description=self._description,
|
||||
max_iterations=self._max_iterations,
|
||||
output_executors=output_executors,
|
||||
)
|
||||
build_attributes: dict[str, Any] = {
|
||||
OtelAttr.WORKFLOW_BUILDER_NAME: self._name,
|
||||
OtelAttr.WORKFLOW_ID: workflow.id,
|
||||
OtelAttr.WORKFLOW_DEFINITION: workflow.to_json(),
|
||||
}
|
||||
if workflow.name:
|
||||
build_attributes[OtelAttr.WORKFLOW_NAME] = workflow.name
|
||||
if workflow.description:
|
||||
build_attributes[OtelAttr.WORKFLOW_DESCRIPTION] = workflow.description
|
||||
if self._description:
|
||||
build_attributes[OtelAttr.WORKFLOW_BUILDER_DESCRIPTION] = self._description
|
||||
span.set_attributes(build_attributes)
|
||||
|
||||
# Add workflow build completed event
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
from ._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._events import (
|
||||
WorkflowEvent,
|
||||
@@ -454,8 +454,7 @@ class WorkflowExecutor(Executor):
|
||||
"""Get the current state of the WorkflowExecutor for checkpointing purposes."""
|
||||
return {
|
||||
"execution_contexts": {
|
||||
execution_id: encode_checkpoint_value(execution_context)
|
||||
for execution_id, execution_context in self._execution_contexts.items()
|
||||
execution_id: execution_context for execution_id, execution_context in self._execution_contexts.items()
|
||||
},
|
||||
"request_to_execution": dict(self._request_to_execution),
|
||||
}
|
||||
@@ -654,21 +653,6 @@ class WorkflowExecutor(Executor):
|
||||
try:
|
||||
# Resume the sub-workflow with all collected responses
|
||||
result = await self.workflow.run(responses=responses_to_send)
|
||||
# Remove handled requests from result. The result may contain the original
|
||||
# RequestInfoEvents that were already handled. This is due to checkpointing
|
||||
# and rehydration of the workflow that re-adds the RequestInfoEvents to the
|
||||
# workflow's _runner_context thus the event queue. When the workflow is resumed,
|
||||
# those events will be emitted at the very beginning of the superstep, prior to
|
||||
# processing messages/responses, creating the illusion that the workflow is
|
||||
# requesting the same information again.
|
||||
for request_id in responses_to_send:
|
||||
event_to_remove = next(
|
||||
(event for event in result if event.type == "request_info" and event.request_id == request_id),
|
||||
None,
|
||||
)
|
||||
if event_to_remove:
|
||||
result.remove(event_to_remove)
|
||||
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
|
||||
@@ -196,6 +196,8 @@ class OtelAttr(str, Enum):
|
||||
|
||||
# Workflow attributes
|
||||
WORKFLOW_ID = "workflow.id"
|
||||
WORKFLOW_BUILDER_NAME = "workflow_builder.name"
|
||||
WORKFLOW_BUILDER_DESCRIPTION = "workflow_builder.description"
|
||||
WORKFLOW_NAME = "workflow.name"
|
||||
WORKFLOW_DESCRIPTION = "workflow.description"
|
||||
WORKFLOW_DEFINITION = "workflow.definition"
|
||||
|
||||
@@ -84,16 +84,17 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
|
||||
assert initial_agent.call_count == 1
|
||||
|
||||
# Verify checkpoint was created
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert len(checkpoints) > 0
|
||||
|
||||
# Find a suitable checkpoint to restore (prefer superstep checkpoint)
|
||||
checkpoints.sort(key=lambda cp: cp.timestamp)
|
||||
restore_checkpoint = next(
|
||||
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
|
||||
checkpoints[-1],
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
|
||||
assert len(checkpoints) >= 2, (
|
||||
"Expected at least 2 checkpoints. The first one is after the start executor, "
|
||||
"and the second one is after the agent execution."
|
||||
)
|
||||
|
||||
# Get the second checkpoint which should contain the state after processing
|
||||
# the first message by the start executor in the sequential workflow
|
||||
checkpoints.sort(key=lambda cp: cp.timestamp)
|
||||
restore_checkpoint = checkpoints[1]
|
||||
|
||||
# Verify checkpoint contains executor state with both cache and thread
|
||||
assert "_executor_state" in restore_checkpoint.state
|
||||
executor_states = restore_checkpoint.state["_executor_state"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,17 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass # noqa: I001
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework._workflows._checkpoint_encoding import (
|
||||
DATACLASS_MARKER,
|
||||
MODEL_MARKER,
|
||||
_TYPE_MARKER, # type: ignore
|
||||
CheckpointDecodingError,
|
||||
decode_checkpoint_value,
|
||||
encode_checkpoint_value,
|
||||
)
|
||||
from agent_framework._workflows._typing_utils import is_instance_of
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,7 +31,22 @@ class SampleResponse:
|
||||
request_id: str
|
||||
|
||||
|
||||
def test_decode_dataclass_with_nested_request() -> None:
|
||||
# --- Tests for round-trip encode/decode ---
|
||||
|
||||
|
||||
def test_roundtrip_simple_dataclass() -> None:
|
||||
"""Test encoding and decoding of a simple dataclass."""
|
||||
original = SampleRequest(request_id="test-123", prompt="test prompt")
|
||||
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = cast(SampleRequest, decode_checkpoint_value(encoded))
|
||||
|
||||
assert isinstance(decoded, SampleRequest)
|
||||
assert decoded.request_id == "test-123"
|
||||
assert decoded.prompt == "test prompt"
|
||||
|
||||
|
||||
def test_roundtrip_dataclass_with_nested_request() -> None:
|
||||
"""Test that dataclass with nested dataclass fields can be encoded and decoded correctly."""
|
||||
original = SampleResponse(
|
||||
data="approve",
|
||||
@@ -49,45 +65,7 @@ def test_decode_dataclass_with_nested_request() -> None:
|
||||
assert decoded.original_request.request_id == "abc"
|
||||
|
||||
|
||||
def test_is_instance_of_coerces_nested_dataclass_dict() -> None:
|
||||
"""Test that is_instance_of can handle nested structures with dict conversion."""
|
||||
response = SampleResponse(
|
||||
data="approve",
|
||||
original_request=SampleRequest(request_id="req-1", prompt="prompt"),
|
||||
request_id="req-1",
|
||||
)
|
||||
|
||||
# Simulate checkpoint decode fallback leaving a dict
|
||||
response.original_request = cast(
|
||||
Any,
|
||||
{
|
||||
"request_id": "req-1",
|
||||
"prompt": "prompt",
|
||||
},
|
||||
)
|
||||
|
||||
assert is_instance_of(response, SampleResponse)
|
||||
assert isinstance(response.original_request, dict)
|
||||
|
||||
# Verify the dict contains expected values
|
||||
dict_request = cast(dict[str, Any], response.original_request)
|
||||
assert dict_request["request_id"] == "req-1"
|
||||
assert dict_request["prompt"] == "prompt"
|
||||
|
||||
|
||||
def test_encode_decode_simple_dataclass() -> None:
|
||||
"""Test encoding and decoding of a simple dataclass."""
|
||||
original = SampleRequest(request_id="test-123", prompt="test prompt")
|
||||
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = cast(SampleRequest, decode_checkpoint_value(encoded))
|
||||
|
||||
assert isinstance(decoded, SampleRequest)
|
||||
assert decoded.request_id == "test-123"
|
||||
assert decoded.prompt == "test prompt"
|
||||
|
||||
|
||||
def test_encode_decode_nested_structures() -> None:
|
||||
def test_roundtrip_nested_structures() -> None:
|
||||
"""Test encoding and decoding of complex nested structures."""
|
||||
nested_data = {
|
||||
"requests": [
|
||||
@@ -110,7 +88,6 @@ def test_encode_decode_nested_structures() -> None:
|
||||
assert "requests" in decoded
|
||||
assert "responses" in decoded
|
||||
|
||||
# Check the requests list
|
||||
requests = cast(list[Any], decoded["requests"])
|
||||
assert isinstance(requests, list)
|
||||
assert len(requests) == 2
|
||||
@@ -120,7 +97,6 @@ def test_encode_decode_nested_structures() -> None:
|
||||
assert first_request.request_id == "req-1"
|
||||
assert second_request.request_id == "req-2"
|
||||
|
||||
# Check the responses dict
|
||||
responses = cast(dict[str, Any], decoded["responses"])
|
||||
assert isinstance(responses, dict)
|
||||
assert "req-1" in responses
|
||||
@@ -131,108 +107,145 @@ def test_encode_decode_nested_structures() -> None:
|
||||
assert response.original_request.request_id == "req-1"
|
||||
|
||||
|
||||
def test_encode_allows_marker_key_without_value_key() -> None:
|
||||
"""Test that encoding a dict with only the marker key (no 'value') is allowed."""
|
||||
dict_with_marker_only = {
|
||||
MODEL_MARKER: "some.module:FakeClass",
|
||||
"other_key": "test",
|
||||
def test_roundtrip_datetime() -> None:
|
||||
"""Test round-trip encoding/decoding of datetime objects."""
|
||||
original = datetime(2024, 5, 4, 12, 30, 45, tzinfo=timezone.utc)
|
||||
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = decode_checkpoint_value(encoded)
|
||||
|
||||
assert isinstance(decoded, datetime)
|
||||
assert decoded == original
|
||||
|
||||
|
||||
def test_roundtrip_primitives() -> None:
|
||||
"""Test that primitive types round-trip unchanged."""
|
||||
for value in ["hello", 42, 3.14, True, False, None]:
|
||||
assert decode_checkpoint_value(encode_checkpoint_value(value)) == value
|
||||
|
||||
|
||||
def test_roundtrip_dict_with_mixed_values() -> None:
|
||||
"""Test round-trip of a dict containing both primitives and complex types."""
|
||||
original = {
|
||||
"name": "test",
|
||||
"request": SampleRequest(request_id="r1", prompt="p1"),
|
||||
"count": 5,
|
||||
}
|
||||
encoded = encode_checkpoint_value(dict_with_marker_only)
|
||||
assert MODEL_MARKER in encoded
|
||||
assert "other_key" in encoded
|
||||
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = decode_checkpoint_value(encoded)
|
||||
|
||||
assert decoded["name"] == "test"
|
||||
assert decoded["count"] == 5
|
||||
assert isinstance(decoded["request"], SampleRequest)
|
||||
assert decoded["request"].request_id == "r1"
|
||||
|
||||
|
||||
def test_encode_allows_value_key_without_marker_key() -> None:
|
||||
"""Test that encoding a dict with only 'value' key (no marker) is allowed."""
|
||||
dict_with_value_only = {
|
||||
"value": {"data": "test"},
|
||||
"other_key": "test",
|
||||
}
|
||||
encoded = encode_checkpoint_value(dict_with_value_only)
|
||||
assert "value" in encoded
|
||||
assert "other_key" in encoded
|
||||
# --- Tests for decode primitives ---
|
||||
|
||||
|
||||
def test_encode_allows_marker_with_value_key() -> None:
|
||||
"""Test that encoding a dict with marker and 'value' keys is allowed.
|
||||
|
||||
This is allowed because legitimate encoded data may contain these keys,
|
||||
and security is enforced at deserialization time by validating class types.
|
||||
"""
|
||||
dict_with_both = {
|
||||
MODEL_MARKER: "some.module:SomeClass",
|
||||
"value": {"data": "test"},
|
||||
"strategy": "to_dict",
|
||||
}
|
||||
encoded = encode_checkpoint_value(dict_with_both)
|
||||
assert MODEL_MARKER in encoded
|
||||
assert "value" in encoded
|
||||
def test_decode_string() -> None:
|
||||
"""Test decoding a string passes through unchanged."""
|
||||
assert decode_checkpoint_value("hello") == "hello"
|
||||
|
||||
|
||||
class NotADataclass:
|
||||
def test_decode_integer() -> None:
|
||||
"""Test decoding an integer passes through unchanged."""
|
||||
assert decode_checkpoint_value(42) == 42
|
||||
|
||||
|
||||
def test_decode_none() -> None:
|
||||
"""Test decoding None passes through unchanged."""
|
||||
assert decode_checkpoint_value(None) is None
|
||||
|
||||
|
||||
# --- Tests for decode collections ---
|
||||
|
||||
|
||||
def test_decode_plain_dict() -> None:
|
||||
"""Test decoding a plain dictionary with primitive values."""
|
||||
data = {"a": 1, "b": "two"}
|
||||
assert decode_checkpoint_value(data) == {"a": 1, "b": "two"}
|
||||
|
||||
|
||||
def test_decode_plain_list() -> None:
|
||||
"""Test decoding a plain list with primitive values."""
|
||||
data = [1, "two", 3.0]
|
||||
assert decode_checkpoint_value(data) == [1, "two", 3.0]
|
||||
|
||||
|
||||
# --- Tests for type verification ---
|
||||
|
||||
|
||||
def test_decode_raises_on_type_mismatch() -> None:
|
||||
"""Test that decoding raises CheckpointDecodingError when type doesn't match."""
|
||||
# Encode a SampleRequest but tamper with the type marker
|
||||
encoded = encode_checkpoint_value(SampleRequest(request_id="r1", prompt="p1"))
|
||||
assert isinstance(encoded, dict)
|
||||
encoded[_TYPE_MARKER] = "nonexistent.module:FakeClass"
|
||||
|
||||
with pytest.raises(CheckpointDecodingError, match="Type mismatch"):
|
||||
decode_checkpoint_value(encoded)
|
||||
|
||||
|
||||
class NotADataclass: # noqa: B903
|
||||
"""A regular class that is not a dataclass."""
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
|
||||
def get_value(self) -> str:
|
||||
return self.value
|
||||
|
||||
def test_roundtrip_regular_class() -> None:
|
||||
"""Test that regular (non-dataclass) objects can be round-tripped via pickle."""
|
||||
original = NotADataclass(value="test_value")
|
||||
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = cast(NotADataclass, decode_checkpoint_value(encoded))
|
||||
|
||||
assert isinstance(decoded, NotADataclass)
|
||||
assert decoded.value == "test_value"
|
||||
|
||||
|
||||
class NotAModel:
|
||||
"""A regular class that does not support the model protocol."""
|
||||
def test_roundtrip_tuple() -> None:
|
||||
"""Test that tuples preserve their type through encode/decode roundtrip."""
|
||||
original = (1, "two", 3.0)
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = decode_checkpoint_value(encoded)
|
||||
|
||||
def get_value(self) -> str:
|
||||
return self.value
|
||||
assert isinstance(decoded, tuple)
|
||||
assert decoded == original
|
||||
|
||||
|
||||
def test_decode_rejects_non_dataclass_with_dataclass_marker() -> None:
|
||||
"""Test that decode returns raw value when marked class is not a dataclass."""
|
||||
# Manually construct a payload that claims NotADataclass is a dataclass
|
||||
fake_payload = {
|
||||
DATACLASS_MARKER: f"{NotADataclass.__module__}:{NotADataclass.__name__}",
|
||||
"value": {"value": "test_value"},
|
||||
}
|
||||
def test_roundtrip_set() -> None:
|
||||
"""Test that sets preserve their type through encode/decode roundtrip."""
|
||||
original = {1, 2, 3}
|
||||
|
||||
decoded = decode_checkpoint_value(fake_payload)
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = decode_checkpoint_value(encoded)
|
||||
|
||||
# Should return the raw decoded value, not an instance of NotADataclass
|
||||
assert isinstance(decoded, dict)
|
||||
assert decoded["value"] == "test_value"
|
||||
assert isinstance(decoded, set)
|
||||
assert decoded == original
|
||||
|
||||
|
||||
def test_decode_rejects_non_model_with_model_marker() -> None:
|
||||
"""Test that decode returns raw value when marked class doesn't support model protocol."""
|
||||
# Manually construct a payload that claims NotAModel supports the model protocol
|
||||
fake_payload = {
|
||||
MODEL_MARKER: f"{NotAModel.__module__}:{NotAModel.__name__}",
|
||||
"strategy": "to_dict",
|
||||
"value": {"value": "test_value"},
|
||||
}
|
||||
def test_roundtrip_nested_tuple_in_dict() -> None:
|
||||
"""Test that tuples nested inside dicts preserve their type."""
|
||||
original = {"items": (1, 2, 3), "name": "test"}
|
||||
|
||||
decoded = decode_checkpoint_value(fake_payload)
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = decode_checkpoint_value(encoded)
|
||||
|
||||
# Should return the raw decoded value, not an instance of NotAModel
|
||||
assert isinstance(decoded, dict)
|
||||
assert decoded["value"] == "test_value"
|
||||
assert isinstance(decoded["items"], tuple)
|
||||
assert decoded["items"] == (1, 2, 3)
|
||||
assert decoded["name"] == "test"
|
||||
|
||||
|
||||
def test_encode_allows_nested_dict_with_marker_keys() -> None:
|
||||
"""Test that encoding allows nested dicts containing marker patterns.
|
||||
def test_roundtrip_set_in_list() -> None:
|
||||
"""Test that sets nested inside lists preserve their type."""
|
||||
original = [{"tags": {1, 2, 3}}]
|
||||
|
||||
Security is enforced at deserialization time, not serialization time,
|
||||
so legitimate encoded data can contain markers at any nesting level.
|
||||
"""
|
||||
nested_data = {
|
||||
"outer": {
|
||||
MODEL_MARKER: "some.module:SomeClass",
|
||||
"value": {"data": "test"},
|
||||
}
|
||||
}
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = decode_checkpoint_value(encoded)
|
||||
|
||||
encoded = encode_checkpoint_value(nested_data)
|
||||
assert "outer" in encoded
|
||||
assert MODEL_MARKER in encoded["outer"]
|
||||
assert isinstance(decoded[0]["tags"], set)
|
||||
assert decoded[0]["tags"] == {1, 2, 3}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from agent_framework._workflows._checkpoint_encoding import (
|
||||
_CYCLE_SENTINEL,
|
||||
DATACLASS_MARKER,
|
||||
MODEL_MARKER,
|
||||
_PICKLE_MARKER,
|
||||
_TYPE_MARKER,
|
||||
encode_checkpoint_value,
|
||||
)
|
||||
|
||||
@@ -41,23 +42,6 @@ class ModelWithToDict:
|
||||
return cls(data=d["data"])
|
||||
|
||||
|
||||
class ModelWithToJson:
|
||||
"""A class that implements to_json/from_json protocol."""
|
||||
|
||||
def __init__(self, data: str) -> None:
|
||||
self.data = data
|
||||
|
||||
def to_json(self) -> str:
|
||||
return f'{{"data": "{self.data}"}}'
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> "ModelWithToJson":
|
||||
import json
|
||||
|
||||
d = json.loads(json_str)
|
||||
return cls(data=d["data"])
|
||||
|
||||
|
||||
class UnknownObject:
|
||||
"""A class that doesn't support any serialization protocol."""
|
||||
|
||||
@@ -68,43 +52,37 @@ class UnknownObject:
|
||||
return f"UnknownObject({self.value})"
|
||||
|
||||
|
||||
# --- Tests for primitive encoding ---
|
||||
# --- Tests for primitive encoding (pass-through) ---
|
||||
|
||||
|
||||
def test_encode_string() -> None:
|
||||
"""Test encoding a string value."""
|
||||
result = encode_checkpoint_value("hello")
|
||||
assert result == "hello"
|
||||
assert encode_checkpoint_value("hello") == "hello"
|
||||
|
||||
|
||||
def test_encode_integer() -> None:
|
||||
"""Test encoding an integer value."""
|
||||
result = encode_checkpoint_value(42)
|
||||
assert result == 42
|
||||
assert encode_checkpoint_value(42) == 42
|
||||
|
||||
|
||||
def test_encode_float() -> None:
|
||||
"""Test encoding a float value."""
|
||||
result = encode_checkpoint_value(3.14)
|
||||
assert result == 3.14
|
||||
assert encode_checkpoint_value(3.14) == 3.14
|
||||
|
||||
|
||||
def test_encode_boolean_true() -> None:
|
||||
"""Test encoding a True boolean value."""
|
||||
result = encode_checkpoint_value(True)
|
||||
assert result is True
|
||||
assert encode_checkpoint_value(True) is True
|
||||
|
||||
|
||||
def test_encode_boolean_false() -> None:
|
||||
"""Test encoding a False boolean value."""
|
||||
result = encode_checkpoint_value(False)
|
||||
assert result is False
|
||||
assert encode_checkpoint_value(False) is False
|
||||
|
||||
|
||||
def test_encode_none() -> None:
|
||||
"""Test encoding a None value."""
|
||||
result = encode_checkpoint_value(None)
|
||||
assert result is None
|
||||
assert encode_checkpoint_value(None) is None
|
||||
|
||||
|
||||
# --- Tests for collection encoding ---
|
||||
@@ -112,8 +90,7 @@ def test_encode_none() -> None:
|
||||
|
||||
def test_encode_empty_dict() -> None:
|
||||
"""Test encoding an empty dictionary."""
|
||||
result = encode_checkpoint_value({})
|
||||
assert result == {}
|
||||
assert encode_checkpoint_value({}) == {}
|
||||
|
||||
|
||||
def test_encode_simple_dict() -> None:
|
||||
@@ -132,8 +109,7 @@ def test_encode_dict_with_non_string_keys() -> None:
|
||||
|
||||
def test_encode_empty_list() -> None:
|
||||
"""Test encoding an empty list."""
|
||||
result = encode_checkpoint_value([])
|
||||
assert result == []
|
||||
assert encode_checkpoint_value([]) == []
|
||||
|
||||
|
||||
def test_encode_simple_list() -> None:
|
||||
@@ -144,29 +120,26 @@ def test_encode_simple_list() -> None:
|
||||
|
||||
|
||||
def test_encode_tuple() -> None:
|
||||
"""Test encoding a tuple (converted to list)."""
|
||||
"""Test encoding a tuple (pickled to preserve type)."""
|
||||
data = (1, 2, 3)
|
||||
result = encode_checkpoint_value(data)
|
||||
assert result == [1, 2, 3]
|
||||
assert isinstance(result, dict)
|
||||
assert _PICKLE_MARKER in result
|
||||
assert _TYPE_MARKER in result
|
||||
|
||||
|
||||
def test_encode_set() -> None:
|
||||
"""Test encoding a set (converted to list)."""
|
||||
"""Test encoding a set (pickled to preserve type)."""
|
||||
data = {1, 2, 3}
|
||||
result = encode_checkpoint_value(data)
|
||||
assert isinstance(result, list)
|
||||
assert sorted(result) == [1, 2, 3]
|
||||
assert isinstance(result, dict)
|
||||
assert _PICKLE_MARKER in result
|
||||
assert _TYPE_MARKER in result
|
||||
|
||||
|
||||
def test_encode_nested_dict() -> None:
|
||||
"""Test encoding a nested dictionary structure."""
|
||||
data = {
|
||||
"outer": {
|
||||
"inner": {
|
||||
"value": 42,
|
||||
}
|
||||
}
|
||||
}
|
||||
data = {"outer": {"inner": {"value": 42}}}
|
||||
result = encode_checkpoint_value(data)
|
||||
assert result == {"outer": {"inner": {"value": 42}}}
|
||||
|
||||
@@ -178,18 +151,18 @@ def test_encode_list_of_dicts() -> None:
|
||||
assert result == [{"a": 1}, {"b": 2}]
|
||||
|
||||
|
||||
# --- Tests for dataclass encoding ---
|
||||
# --- Tests for non-JSON-native types (pickled) ---
|
||||
|
||||
|
||||
def test_encode_simple_dataclass() -> None:
|
||||
"""Test encoding a simple dataclass."""
|
||||
"""Test encoding a simple dataclass produces a pickled entry."""
|
||||
obj = SimpleDataclass(name="test", value=42)
|
||||
result = encode_checkpoint_value(obj)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert DATACLASS_MARKER in result
|
||||
assert "value" in result
|
||||
assert result["value"] == {"name": "test", "value": 42}
|
||||
assert _PICKLE_MARKER in result
|
||||
assert _TYPE_MARKER in result
|
||||
assert isinstance(result[_PICKLE_MARKER], str) # base64 string
|
||||
|
||||
|
||||
def test_encode_nested_dataclass() -> None:
|
||||
@@ -199,12 +172,8 @@ def test_encode_nested_dataclass() -> None:
|
||||
result = encode_checkpoint_value(outer)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert DATACLASS_MARKER in result
|
||||
assert "value" in result
|
||||
|
||||
outer_value = result["value"]
|
||||
assert outer_value["outer_name"] == "outer"
|
||||
assert DATACLASS_MARKER in outer_value["inner"]
|
||||
assert _PICKLE_MARKER in result
|
||||
assert _TYPE_MARKER in result
|
||||
|
||||
|
||||
def test_encode_list_of_dataclasses() -> None:
|
||||
@@ -218,7 +187,7 @@ def test_encode_list_of_dataclasses() -> None:
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
for item in result:
|
||||
assert DATACLASS_MARKER in item
|
||||
assert _PICKLE_MARKER in item
|
||||
|
||||
|
||||
def test_encode_dict_with_dataclass_values() -> None:
|
||||
@@ -230,169 +199,77 @@ def test_encode_dict_with_dataclass_values() -> None:
|
||||
result = encode_checkpoint_value(data)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert DATACLASS_MARKER in result["item1"]
|
||||
assert DATACLASS_MARKER in result["item2"]
|
||||
|
||||
|
||||
# --- Tests for model protocol encoding ---
|
||||
assert _PICKLE_MARKER in result["item1"]
|
||||
assert _PICKLE_MARKER in result["item2"]
|
||||
|
||||
|
||||
def test_encode_model_with_to_dict() -> None:
|
||||
"""Test encoding an object implementing to_dict/from_dict protocol."""
|
||||
"""Test encoding an object with to_dict is pickled (not using to_dict)."""
|
||||
obj = ModelWithToDict(data="test_data")
|
||||
result = encode_checkpoint_value(obj)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert MODEL_MARKER in result
|
||||
assert result["strategy"] == "to_dict"
|
||||
assert result["value"] == {"data": "test_data"}
|
||||
assert _PICKLE_MARKER in result
|
||||
|
||||
|
||||
def test_encode_model_with_to_json() -> None:
|
||||
"""Test encoding an object implementing to_json/from_json protocol."""
|
||||
obj = ModelWithToJson(data="test_data")
|
||||
result = encode_checkpoint_value(obj)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert MODEL_MARKER in result
|
||||
assert result["strategy"] == "to_json"
|
||||
assert '"data": "test_data"' in result["value"]
|
||||
|
||||
|
||||
# --- Tests for unknown object encoding ---
|
||||
|
||||
|
||||
def test_encode_unknown_object_fallback_to_string() -> None:
|
||||
"""Test that unknown objects are encoded as strings."""
|
||||
def test_encode_unknown_object() -> None:
|
||||
"""Test that arbitrary objects are pickled."""
|
||||
obj = UnknownObject(value="test")
|
||||
result = encode_checkpoint_value(obj)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "UnknownObject" in result
|
||||
assert isinstance(result, dict)
|
||||
assert _PICKLE_MARKER in result
|
||||
|
||||
|
||||
# --- Tests for cycle detection ---
|
||||
def test_encode_datetime() -> None:
|
||||
"""Test that datetime objects are pickled."""
|
||||
dt = datetime(2024, 5, 4, 12, 30, 45, tzinfo=timezone.utc)
|
||||
result = encode_checkpoint_value(dt)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert _PICKLE_MARKER in result
|
||||
|
||||
|
||||
def test_encode_dict_with_self_reference() -> None:
|
||||
"""Test that dict self-references are detected and handled."""
|
||||
data: dict[str, Any] = {"name": "test"}
|
||||
data["self"] = data # Create circular reference
|
||||
|
||||
result = encode_checkpoint_value(data)
|
||||
assert result["name"] == "test"
|
||||
assert result["self"] == _CYCLE_SENTINEL
|
||||
# --- Tests for type marker ---
|
||||
|
||||
|
||||
def test_encode_list_with_self_reference() -> None:
|
||||
"""Test that list self-references are detected and handled."""
|
||||
data: list[Any] = [1, 2]
|
||||
data.append(data) # Create circular reference
|
||||
def test_encode_type_marker_records_type_info() -> None:
|
||||
"""Test that encoded objects include correct type information."""
|
||||
obj = SimpleDataclass(name="test", value=42)
|
||||
result = encode_checkpoint_value(obj)
|
||||
|
||||
result = encode_checkpoint_value(data)
|
||||
assert result[0] == 1
|
||||
assert result[1] == 2
|
||||
assert result[2] == _CYCLE_SENTINEL
|
||||
type_key = result[_TYPE_MARKER]
|
||||
assert "SimpleDataclass" in type_key
|
||||
|
||||
|
||||
# --- Tests for reserved keyword handling ---
|
||||
# Note: Security is enforced at deserialization time by validating class types,
|
||||
# not at serialization time. This allows legitimate encoded data to be re-encoded.
|
||||
def test_encode_type_marker_uses_module_qualname_format() -> None:
|
||||
"""Test that type marker uses module:qualname format."""
|
||||
obj = SimpleDataclass(name="test", value=42)
|
||||
result = encode_checkpoint_value(obj)
|
||||
|
||||
type_key = result[_TYPE_MARKER]
|
||||
assert ":" in type_key
|
||||
module, qualname = type_key.split(":")
|
||||
assert module # non-empty module
|
||||
assert qualname == "SimpleDataclass"
|
||||
|
||||
|
||||
def test_encode_allows_dict_with_model_marker_and_value() -> None:
|
||||
"""Test that encoding a dict with MODEL_MARKER and 'value' is allowed.
|
||||
# --- Tests for JSON serializability ---
|
||||
|
||||
Security is enforced at deserialization time, not serialization time.
|
||||
"""
|
||||
|
||||
def test_encode_result_is_json_serializable() -> None:
|
||||
"""Test that encoded output is fully JSON-serializable."""
|
||||
data = {
|
||||
MODEL_MARKER: "some.module:SomeClass",
|
||||
"value": {"data": "test"},
|
||||
"dc": SimpleDataclass(name="test", value=42),
|
||||
"model": ModelWithToDict(data="test"),
|
||||
"dt": datetime.now(timezone.utc),
|
||||
"nested": [SimpleDataclass(name="n", value=1)],
|
||||
}
|
||||
result = encode_checkpoint_value(data)
|
||||
assert MODEL_MARKER in result
|
||||
assert "value" in result
|
||||
|
||||
|
||||
def test_encode_allows_dict_with_dataclass_marker_and_value() -> None:
|
||||
"""Test that encoding a dict with DATACLASS_MARKER and 'value' is allowed.
|
||||
|
||||
Security is enforced at deserialization time, not serialization time.
|
||||
"""
|
||||
data = {
|
||||
DATACLASS_MARKER: "some.module:SomeClass",
|
||||
"value": {"field": "test"},
|
||||
}
|
||||
result = encode_checkpoint_value(data)
|
||||
assert DATACLASS_MARKER in result
|
||||
assert "value" in result
|
||||
|
||||
|
||||
def test_encode_allows_nested_dict_with_marker_keys() -> None:
|
||||
"""Test that encoding nested dict with marker keys is allowed.
|
||||
|
||||
Security is enforced at deserialization time, not serialization time.
|
||||
"""
|
||||
nested_data = {
|
||||
"outer": {
|
||||
MODEL_MARKER: "some.module:SomeClass",
|
||||
"value": {"data": "test"},
|
||||
}
|
||||
}
|
||||
result = encode_checkpoint_value(nested_data)
|
||||
assert "outer" in result
|
||||
assert MODEL_MARKER in result["outer"]
|
||||
|
||||
|
||||
def test_encode_allows_marker_without_value() -> None:
|
||||
"""Test that a dict with marker key but without 'value' key is allowed."""
|
||||
data = {
|
||||
MODEL_MARKER: "some.module:SomeClass",
|
||||
"other_key": "allowed",
|
||||
}
|
||||
result = encode_checkpoint_value(data)
|
||||
assert MODEL_MARKER in result
|
||||
assert result["other_key"] == "allowed"
|
||||
|
||||
|
||||
def test_encode_allows_value_without_marker() -> None:
|
||||
"""Test that a dict with 'value' key but without marker is allowed."""
|
||||
data = {
|
||||
"value": {"nested": "data"},
|
||||
"other_key": "allowed",
|
||||
}
|
||||
result = encode_checkpoint_value(data)
|
||||
assert "value" in result
|
||||
assert result["other_key"] == "allowed"
|
||||
|
||||
|
||||
# --- Tests for max depth protection ---
|
||||
|
||||
|
||||
def test_encode_deep_nesting_triggers_max_depth() -> None:
|
||||
"""Test that very deep nesting triggers max depth protection."""
|
||||
# Create a deeply nested structure (over 100 levels)
|
||||
data: dict[str, Any] = {"level": 0}
|
||||
current = data
|
||||
for i in range(105):
|
||||
current["nested"] = {"level": i + 1}
|
||||
current = current["nested"]
|
||||
|
||||
result = encode_checkpoint_value(data)
|
||||
|
||||
# Navigate to find the max_depth sentinel
|
||||
current_result = result
|
||||
found_max_depth = False
|
||||
for _ in range(110):
|
||||
if isinstance(current_result, dict) and "nested" in current_result:
|
||||
current_result = current_result["nested"]
|
||||
if current_result == "<max_depth>":
|
||||
found_max_depth = True
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
assert found_max_depth, "Expected <max_depth> sentinel to be found in deeply nested structure"
|
||||
# Should not raise
|
||||
json_str = json.dumps(result)
|
||||
assert isinstance(json_str, str)
|
||||
|
||||
|
||||
# --- Tests for mixed complex structures ---
|
||||
@@ -413,6 +290,7 @@ def test_encode_complex_mixed_structure() -> None:
|
||||
|
||||
result = encode_checkpoint_value(data)
|
||||
|
||||
# Primitives and collections pass through
|
||||
assert result["string_value"] == "hello"
|
||||
assert result["int_value"] == 42
|
||||
assert result["float_value"] == 3.14
|
||||
@@ -420,4 +298,17 @@ def test_encode_complex_mixed_structure() -> None:
|
||||
assert result["none_value"] is None
|
||||
assert result["list_value"] == [1, 2, 3]
|
||||
assert result["nested_dict"] == {"a": 1, "b": 2}
|
||||
assert DATACLASS_MARKER in result["dataclass_value"]
|
||||
# Dataclass is pickled
|
||||
assert _PICKLE_MARKER in result["dataclass_value"]
|
||||
|
||||
|
||||
def test_encode_preserves_dict_with_pickle_marker_key() -> None:
|
||||
"""Test that regular dicts containing _PICKLE_MARKER key are recursively encoded."""
|
||||
data = {
|
||||
_PICKLE_MARKER: "some_value",
|
||||
"other_key": "test",
|
||||
}
|
||||
result = encode_checkpoint_value(data)
|
||||
assert _PICKLE_MARKER in result
|
||||
assert result[_PICKLE_MARKER] == "some_value"
|
||||
assert result["other_key"] == "test"
|
||||
|
||||
@@ -44,7 +44,7 @@ async def test_resume_fails_when_graph_mismatch() -> None:
|
||||
# Run once to create checkpoints
|
||||
_ = [event async for event in workflow.run("hello", stream=True)] # noqa: F841
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
|
||||
assert checkpoints, "expected at least one checkpoint to be created"
|
||||
target_checkpoint = checkpoints[-1]
|
||||
|
||||
@@ -67,7 +67,7 @@ async def test_resume_succeeds_when_graph_matches() -> None:
|
||||
workflow = build_workflow(storage, finish_id="finish")
|
||||
_ = [event async for event in workflow.run("hello", stream=True)] # noqa: F841
|
||||
|
||||
checkpoints = sorted(await storage.list_checkpoints(), key=lambda c: c.timestamp)
|
||||
checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow.name), key=lambda c: c.timestamp)
|
||||
target_checkpoint = checkpoints[0]
|
||||
|
||||
resumed_workflow = build_workflow(storage, finish_id="finish")
|
||||
@@ -126,7 +126,7 @@ async def test_resume_succeeds_when_sub_workflow_matches() -> None:
|
||||
|
||||
_ = [event async for event in workflow.run("hello", stream=True)]
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
|
||||
assert checkpoints, "expected at least one checkpoint to be created"
|
||||
target_checkpoint = checkpoints[-1]
|
||||
|
||||
@@ -150,7 +150,7 @@ async def test_resume_fails_when_sub_workflow_changes() -> None:
|
||||
|
||||
_ = [event async for event in workflow.run("hello", stream=True)]
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
|
||||
assert checkpoints, "expected at least one checkpoint to be created"
|
||||
target_checkpoint = checkpoints[-1]
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework import (
|
||||
FileCheckpointStorage,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
@@ -323,90 +322,3 @@ class TestRequestInfoAndResponse:
|
||||
assert completed
|
||||
# Should not have any calculations performed due to invalid input
|
||||
assert len(executor.calculations_performed) == 0
|
||||
|
||||
async def test_checkpoint_with_pending_request_info_events(self):
|
||||
"""Test that request info events are properly serialized in checkpoints and can be restored."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Use file-based storage to test full serialization
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
# Create workflow with checkpointing enabled
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
|
||||
|
||||
# Step 1: Run workflow to completion to ensure checkpoints are created
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run("checkpoint test operation", stream=True):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
|
||||
# Verify request was emitted
|
||||
assert request_info_event is not None
|
||||
assert isinstance(request_info_event.data, UserApprovalRequest)
|
||||
assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation"
|
||||
assert request_info_event.source_executor_id == "approval_executor"
|
||||
|
||||
# Step 2: List checkpoints to find the one with our pending request
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert len(checkpoints) > 0, "No checkpoints were created during workflow execution"
|
||||
|
||||
# Find the checkpoint with our pending request
|
||||
checkpoint_with_request = None
|
||||
for checkpoint in checkpoints:
|
||||
if request_info_event.request_id in checkpoint.pending_request_info_events:
|
||||
checkpoint_with_request = checkpoint
|
||||
break
|
||||
|
||||
assert checkpoint_with_request is not None, "No checkpoint found with pending request info event"
|
||||
|
||||
# Step 3: Verify the pending request info event was properly serialized
|
||||
serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id]
|
||||
assert "data" in serialized_event
|
||||
assert "request_id" in serialized_event
|
||||
assert "source_executor_id" in serialized_event
|
||||
assert "request_type" in serialized_event
|
||||
assert serialized_event["request_id"] == request_info_event.request_id
|
||||
assert serialized_event["source_executor_id"] == "approval_executor"
|
||||
|
||||
# Step 4: Create a fresh workflow and restore from checkpoint
|
||||
new_executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
|
||||
|
||||
# Step 5: Resume from checkpoint and verify the request can be continued
|
||||
completed = False
|
||||
restored_request_event: WorkflowEvent | None = None
|
||||
async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True):
|
||||
# Should re-emit the pending request info event
|
||||
if event.type == "request_info" and event.request_id == request_info_event.request_id:
|
||||
restored_request_event = event
|
||||
elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
completed = True
|
||||
|
||||
assert completed, "Workflow should reach idle with pending requests state after restoration"
|
||||
assert restored_request_event is not None, "Restored request info event should be emitted"
|
||||
|
||||
# Verify the restored event matches the original
|
||||
assert restored_request_event.source_executor_id == request_info_event.source_executor_id
|
||||
assert isinstance(restored_request_event.data, UserApprovalRequest)
|
||||
assert restored_request_event.data.prompt == request_info_event.data.prompt
|
||||
assert restored_request_event.data.context == request_info_event.data.context
|
||||
|
||||
# Step 6: Provide response to the restored request and complete the workflow
|
||||
final_completed = False
|
||||
async for event in restored_workflow.run(
|
||||
stream=True,
|
||||
responses={
|
||||
request_info_event.request_id: True # Approve the request
|
||||
},
|
||||
):
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
final_completed = True
|
||||
|
||||
assert final_completed, "Workflow should complete after providing response to restored request"
|
||||
|
||||
# Step 7: Verify the executor state was properly restored and response was processed
|
||||
assert new_executor.approval_received is True
|
||||
expected_result = "Operation approved: Please approve the operation: checkpoint test operation"
|
||||
assert new_executor.final_result == expected_result
|
||||
|
||||
@@ -4,14 +4,27 @@ import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import InMemoryCheckpointStorage, InProcRunnerContext
|
||||
from agent_framework._workflows._checkpoint_encoding import DATACLASS_MARKER, encode_checkpoint_value
|
||||
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
|
||||
from agent_framework import (
|
||||
FileCheckpointStorage,
|
||||
InMemoryCheckpointStorage,
|
||||
InProcRunnerContext,
|
||||
WorkflowBuilder,
|
||||
WorkflowRunState,
|
||||
)
|
||||
from agent_framework._workflows._checkpoint_encoding import (
|
||||
_PICKLE_MARKER,
|
||||
encode_checkpoint_value,
|
||||
)
|
||||
from agent_framework._workflows._events import WorkflowEvent
|
||||
from agent_framework._workflows._state import State
|
||||
|
||||
from .test_request_info_and_response import (
|
||||
ApprovalRequiredExecutor,
|
||||
CalculationRequest,
|
||||
MultiRequestExecutor,
|
||||
UserApprovalRequest,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockRequest: ...
|
||||
@@ -46,13 +59,13 @@ async def test_rehydrate_request_info_event() -> None:
|
||||
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
await runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
|
||||
checkpoint_id = await runner_context.create_checkpoint("test_name", "test_hash", State(), None, iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
assert checkpoint is not None
|
||||
assert checkpoint.pending_request_info_events
|
||||
assert "request-123" in checkpoint.pending_request_info_events
|
||||
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
|
||||
assert checkpoint.pending_request_info_events["request-123"].request_type is MockRequest
|
||||
|
||||
# Rehydrate the context
|
||||
await runner_context.apply_checkpoint(checkpoint)
|
||||
@@ -67,97 +80,6 @@ async def test_rehydrate_request_info_event() -> None:
|
||||
assert isinstance(rehydrated_event.data, MockRequest)
|
||||
|
||||
|
||||
async def test_rehydrate_fails_when_request_type_missing() -> None:
|
||||
"""Rehydration should fail is the request type is missing or fails to import."""
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_data=MockRequest(),
|
||||
response_type=bool,
|
||||
)
|
||||
|
||||
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
await runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
assert checkpoint is not None
|
||||
assert checkpoint.pending_request_info_events
|
||||
assert "request-123" in checkpoint.pending_request_info_events
|
||||
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
|
||||
|
||||
# Modify the checkpoint to simulate missing request type
|
||||
checkpoint.pending_request_info_events["request-123"]["request_type"] = "nonexistent.module:MissingRequest"
|
||||
|
||||
# Rehydrate the context
|
||||
with pytest.raises(ImportError):
|
||||
await runner_context.apply_checkpoint(checkpoint)
|
||||
|
||||
|
||||
async def test_rehydrate_fails_when_request_type_mismatch() -> None:
|
||||
"""Rehydration should fail if the request type is mismatched."""
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_data=MockRequest(),
|
||||
response_type=bool,
|
||||
)
|
||||
|
||||
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
await runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
assert checkpoint is not None
|
||||
assert checkpoint.pending_request_info_events
|
||||
assert "request-123" in checkpoint.pending_request_info_events
|
||||
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
|
||||
|
||||
# Modify the checkpoint to simulate mismatched request type in the serialized data
|
||||
checkpoint.pending_request_info_events["request-123"]["data"][DATACLASS_MARKER] = (
|
||||
"nonexistent.module:MissingRequest"
|
||||
)
|
||||
|
||||
# Rehydrate the context
|
||||
with pytest.raises(TypeError):
|
||||
await runner_context.apply_checkpoint(checkpoint)
|
||||
|
||||
|
||||
async def test_pending_requests_in_summary() -> None:
|
||||
"""Test that pending requests are correctly summarized in the checkpoint summary."""
|
||||
request_info_event = WorkflowEvent.request_info(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_data=MockRequest(),
|
||||
response_type=bool,
|
||||
)
|
||||
|
||||
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
await runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
assert checkpoint is not None
|
||||
summary = get_checkpoint_summary(checkpoint)
|
||||
|
||||
assert summary.checkpoint_id == checkpoint_id
|
||||
assert summary.status == "awaiting request response"
|
||||
|
||||
assert len(summary.pending_request_info_events) == 1
|
||||
pending_event = summary.pending_request_info_events[0]
|
||||
assert isinstance(pending_event, WorkflowEvent)
|
||||
assert pending_event.type == "request_info"
|
||||
assert pending_event.request_id == "request-123"
|
||||
|
||||
assert pending_event.source_executor_id == "review_gateway"
|
||||
assert pending_event.request_type is MockRequest
|
||||
assert pending_event.response_type is bool
|
||||
assert isinstance(pending_event.data, MockRequest)
|
||||
|
||||
|
||||
async def test_request_info_event_serializes_non_json_payloads() -> None:
|
||||
req_1 = WorkflowEvent.request_info(
|
||||
request_id="req-1",
|
||||
@@ -176,20 +98,260 @@ async def test_request_info_event_serializes_non_json_payloads() -> None:
|
||||
await runner_context.add_request_info_event(req_1)
|
||||
await runner_context.add_request_info_event(req_2)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
|
||||
checkpoint_id = await runner_context.create_checkpoint("test_name", "test_hash", State(), None, iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
# Should be JSON serializable despite datetime/slots
|
||||
serialized = json.dumps(encode_checkpoint_value(checkpoint))
|
||||
assert isinstance(serialized, str)
|
||||
|
||||
# Verify the structure contains pickled data for the request data fields
|
||||
deserialized = json.loads(serialized)
|
||||
assert _PICKLE_MARKER in deserialized # checkpoint itself is pickled
|
||||
|
||||
assert "value" in deserialized
|
||||
deserialized = deserialized["value"]
|
||||
# Verify we can rehydrate the checkpoint correctly
|
||||
await runner_context.apply_checkpoint(checkpoint)
|
||||
pending = await runner_context.get_pending_request_info_events()
|
||||
|
||||
assert "pending_request_info_events" in deserialized
|
||||
pending_request_info_events = deserialized["pending_request_info_events"]
|
||||
assert "req-1" in pending_request_info_events
|
||||
assert isinstance(pending_request_info_events["req-1"]["data"]["value"]["issued_at"], str)
|
||||
assert "req-1" in pending
|
||||
rehydrated_1 = pending["req-1"]
|
||||
assert isinstance(rehydrated_1.data, TimedApproval)
|
||||
assert rehydrated_1.data.issued_at == datetime(2024, 5, 4, 12, 30, 45)
|
||||
|
||||
assert "req-2" in pending_request_info_events
|
||||
assert pending_request_info_events["req-2"]["data"]["value"]["note"] == "slot-based"
|
||||
assert "req-2" in pending
|
||||
rehydrated_2 = pending["req-2"]
|
||||
assert isinstance(rehydrated_2.data, SlottedApproval)
|
||||
assert rehydrated_2.data.note == "slot-based"
|
||||
|
||||
|
||||
async def test_checkpoint_with_pending_request_info_events():
|
||||
"""Test that request info events are properly serialized in checkpoints and can be restored."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Use file-based storage to test full serialization
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
# Create workflow with checkpointing enabled
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
|
||||
|
||||
# Step 1: Run workflow to completion to ensure checkpoints are created
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run("checkpoint test operation", stream=True):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
|
||||
# Verify request was emitted
|
||||
assert request_info_event is not None
|
||||
assert isinstance(request_info_event.data, UserApprovalRequest)
|
||||
assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation"
|
||||
assert request_info_event.source_executor_id == "approval_executor"
|
||||
|
||||
# Step 2: List checkpoints to find the one with our pending request
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
|
||||
assert len(checkpoints) > 0, "No checkpoints were created during workflow execution"
|
||||
|
||||
# Find the checkpoint with our pending request
|
||||
checkpoint_with_request = None
|
||||
for checkpoint in checkpoints:
|
||||
if request_info_event.request_id in checkpoint.pending_request_info_events:
|
||||
checkpoint_with_request = checkpoint
|
||||
break
|
||||
|
||||
assert checkpoint_with_request is not None, "No checkpoint found with pending request info event"
|
||||
|
||||
# Step 3: Verify the pending request info event was properly serialized
|
||||
serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id]
|
||||
assert serialized_event.data
|
||||
assert serialized_event.request_type is UserApprovalRequest
|
||||
assert serialized_event.request_id == request_info_event.request_id
|
||||
assert serialized_event.source_executor_id == "approval_executor"
|
||||
|
||||
# Step 4: Create a fresh workflow and restore from checkpoint
|
||||
new_executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
|
||||
|
||||
# Step 5: Resume from checkpoint and verify the request can be continued
|
||||
completed = False
|
||||
restored_request_event: WorkflowEvent | None = None
|
||||
async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True):
|
||||
# Should re-emit the pending request info event
|
||||
if event.type == "request_info" and event.request_id == request_info_event.request_id:
|
||||
restored_request_event = event
|
||||
elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
completed = True
|
||||
|
||||
assert completed, "Workflow should reach idle with pending requests state after restoration"
|
||||
assert restored_request_event is not None, "Restored request info event should be emitted"
|
||||
|
||||
# Verify the restored event matches the original
|
||||
assert restored_request_event.source_executor_id == request_info_event.source_executor_id
|
||||
assert isinstance(restored_request_event.data, UserApprovalRequest)
|
||||
assert restored_request_event.data.prompt == request_info_event.data.prompt
|
||||
assert restored_request_event.data.context == request_info_event.data.context
|
||||
|
||||
# Step 6: Provide response to the restored request and complete the workflow
|
||||
final_completed = False
|
||||
async for event in restored_workflow.run(
|
||||
stream=True,
|
||||
responses={
|
||||
request_info_event.request_id: True # Approve the request
|
||||
},
|
||||
):
|
||||
if event.type == "status" and event.state == WorkflowRunState.IDLE:
|
||||
final_completed = True
|
||||
|
||||
assert final_completed, "Workflow should complete after providing response to restored request"
|
||||
|
||||
# Step 7: Verify the executor state was properly restored and response was processed
|
||||
assert new_executor.approval_received is True
|
||||
expected_result = "Operation approved: Please approve the operation: checkpoint test operation"
|
||||
assert new_executor.final_result == expected_result
|
||||
|
||||
|
||||
async def test_checkpoint_restore_with_responses_does_not_reemit_handled_requests():
|
||||
"""Test that request_info events are not re-emitted when responses are provided with checkpoint restore.
|
||||
|
||||
When calling run(checkpoint_id=..., responses=...), the workflow restores from a checkpoint
|
||||
that contains pending request_info events. Because responses are provided for those events,
|
||||
they should NOT be re-emitted in the event stream - they are considered "handled".
|
||||
|
||||
Note: The workflow's internal state tracking still sees the request_info events (before filtering),
|
||||
so the final status may be IDLE_WITH_PENDING_REQUESTS even though the requests were handled.
|
||||
The key behavior we're testing is that the CALLER doesn't see the request_info events.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Use file-based storage to test full serialization
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
# Create workflow with checkpointing enabled
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
|
||||
|
||||
# Step 1: Run workflow until it emits a request_info event
|
||||
request_info_event: WorkflowEvent | None = None
|
||||
async for event in workflow.run("test pending request suppression", stream=True):
|
||||
if event.type == "request_info":
|
||||
request_info_event = event
|
||||
|
||||
assert request_info_event is not None
|
||||
request_id = request_info_event.request_id
|
||||
|
||||
# Step 2: Find the checkpoint with the pending request
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
|
||||
checkpoint_with_request = None
|
||||
for checkpoint in checkpoints:
|
||||
if request_id in checkpoint.pending_request_info_events:
|
||||
checkpoint_with_request = checkpoint
|
||||
break
|
||||
|
||||
assert checkpoint_with_request is not None
|
||||
|
||||
# Step 3: Create a fresh workflow and restore from checkpoint WITH responses in one call
|
||||
new_executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
|
||||
|
||||
# Track all emitted events
|
||||
emitted_events: list[WorkflowEvent] = []
|
||||
async for event in restored_workflow.run(
|
||||
checkpoint_id=checkpoint_with_request.checkpoint_id,
|
||||
responses={request_id: True}, # Provide response for the pending request
|
||||
stream=True,
|
||||
):
|
||||
emitted_events.append(event)
|
||||
|
||||
# Step 4: Verify the request_info event was NOT re-emitted to the caller
|
||||
reemitted_request_info_events = [
|
||||
e for e in emitted_events if e.type == "request_info" and e.request_id == request_id
|
||||
]
|
||||
assert len(reemitted_request_info_events) == 0, (
|
||||
f"request_info event should NOT be re-emitted when response is provided. "
|
||||
f"Found {len(reemitted_request_info_events)} request_info events with request_id={request_id}"
|
||||
)
|
||||
|
||||
# Step 5: Verify the response was processed by checking executor state
|
||||
assert new_executor.approval_received is True, "Response should have been processed by the executor"
|
||||
assert new_executor.final_result == (
|
||||
"Operation approved: Please approve the operation: test pending request suppression"
|
||||
)
|
||||
|
||||
|
||||
async def test_checkpoint_restore_with_partial_responses_reemits_unhandled_requests():
|
||||
"""Test that only unhandled request_info events are re-emitted when partial responses are provided.
|
||||
|
||||
When calling run(checkpoint_id=..., responses=...) with responses for only some of the
|
||||
pending requests, only the unhandled request_info events should be re-emitted.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
# Create workflow with multiple requests
|
||||
executor = MultiRequestExecutor(id="multi_executor")
|
||||
workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
|
||||
|
||||
# Step 1: Run workflow until it emits multiple request_info events
|
||||
request_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run("start batch", stream=True):
|
||||
if event.type == "request_info":
|
||||
request_events.append(event)
|
||||
|
||||
assert len(request_events) == 2
|
||||
|
||||
# Find the approval and calculation requests
|
||||
approval_event = next((e for e in request_events if isinstance(e.data, UserApprovalRequest)), None)
|
||||
calc_event = next((e for e in request_events if isinstance(e.data, CalculationRequest)), None)
|
||||
assert approval_event is not None
|
||||
assert calc_event is not None
|
||||
|
||||
# Step 2: Find the checkpoint with pending requests
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
|
||||
checkpoint_with_requests = None
|
||||
for checkpoint in checkpoints:
|
||||
has_approval = approval_event.request_id in checkpoint.pending_request_info_events
|
||||
has_calc = calc_event.request_id in checkpoint.pending_request_info_events
|
||||
if has_approval and has_calc:
|
||||
checkpoint_with_requests = checkpoint
|
||||
break
|
||||
|
||||
assert checkpoint_with_requests is not None
|
||||
|
||||
# Step 3: Restore from checkpoint with ONLY the approval response (not the calculation)
|
||||
new_executor = MultiRequestExecutor(id="multi_executor")
|
||||
restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
|
||||
|
||||
emitted_events: list[WorkflowEvent] = []
|
||||
async for event in restored_workflow.run(
|
||||
checkpoint_id=checkpoint_with_requests.checkpoint_id,
|
||||
responses={approval_event.request_id: True}, # Only respond to approval
|
||||
stream=True,
|
||||
):
|
||||
emitted_events.append(event)
|
||||
|
||||
# Step 4: Verify the approval request_info was NOT re-emitted
|
||||
reemitted_approval_events = [
|
||||
e for e in emitted_events if e.type == "request_info" and e.request_id == approval_event.request_id
|
||||
]
|
||||
assert len(reemitted_approval_events) == 0, (
|
||||
"Approval request_info should NOT be re-emitted since response was provided"
|
||||
)
|
||||
|
||||
# Step 5: Verify the calculation request_info WAS re-emitted (no response provided)
|
||||
reemitted_calc_events = [
|
||||
e for e in emitted_events if e.type == "request_info" and e.request_id == calc_event.request_id
|
||||
]
|
||||
assert len(reemitted_calc_events) == 1, (
|
||||
"Calculation request_info SHOULD be re-emitted since no response was provided"
|
||||
)
|
||||
|
||||
# Step 6: Verify workflow is in IDLE_WITH_PENDING_REQUESTS state (calc still pending)
|
||||
status_events = [e for e in emitted_events if e.type == "status"]
|
||||
final_status = status_events[-1] if status_events else None
|
||||
assert final_status is not None
|
||||
assert final_status.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, (
|
||||
f"Workflow should be IDLE_WITH_PENDING_REQUESTS, got {final_status.state}"
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -9,6 +10,9 @@ from agent_framework import (
|
||||
AgentExecutorResponse,
|
||||
AgentResponse,
|
||||
Executor,
|
||||
InMemoryCheckpointStorage,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
@@ -16,6 +20,7 @@ from agent_framework import (
|
||||
WorkflowRunState,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._const import EXECUTOR_STATE_KEY
|
||||
from agent_framework._workflows._edge import SingleEdgeGroup
|
||||
from agent_framework._workflows._runner import Runner
|
||||
from agent_framework._workflows._runner_context import (
|
||||
@@ -61,7 +66,14 @@ def test_create_runner():
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
|
||||
runner = Runner(edge_groups, executors, state=State(), ctx=InProcRunnerContext())
|
||||
runner = Runner(
|
||||
edge_groups,
|
||||
executors,
|
||||
state=State(),
|
||||
ctx=InProcRunnerContext(),
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
)
|
||||
|
||||
assert runner.context is not None and isinstance(runner.context, RunnerContext)
|
||||
|
||||
@@ -84,7 +96,7 @@ async def test_runner_run_until_convergence():
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx)
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
result: int | None = None
|
||||
await executor_a.execute(
|
||||
@@ -122,7 +134,7 @@ async def test_runner_run_until_convergence_not_completed():
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, max_iterations=5)
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash", max_iterations=5)
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
@@ -156,7 +168,7 @@ async def test_runner_already_running():
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx)
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
@@ -176,7 +188,7 @@ async def test_runner_already_running():
|
||||
|
||||
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {}, State(), ctx)
|
||||
runner = Runner([], {}, State(), ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await ctx.send_message(
|
||||
WorkflowMessage(
|
||||
@@ -228,7 +240,7 @@ async def test_runner_cancellation_stops_active_executor():
|
||||
shared_state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner(edges, executors, shared_state, ctx)
|
||||
runner = Runner(edges, executors, shared_state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
@@ -259,3 +271,579 @@ async def test_runner_cancellation_stops_active_executor():
|
||||
assert executor_a.completed_count == 1
|
||||
assert executor_b.started_count == 1
|
||||
assert executor_b.completed_count == 0 # Should NOT have completed due to cancellation
|
||||
|
||||
|
||||
class FailingExecutor(Executor):
|
||||
"""An executor that fails during execution."""
|
||||
|
||||
def __init__(self, id: str, fail_on_data: int = 5):
|
||||
super().__init__(id=id)
|
||||
self.fail_on_data = fail_on_data
|
||||
|
||||
@handler
|
||||
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
|
||||
if message.data == self.fail_on_data:
|
||||
raise RuntimeError("Simulated executor failure")
|
||||
await ctx.send_message(MockMessage(data=message.data + 1))
|
||||
|
||||
|
||||
async def test_runner_iteration_exception_drains_events():
|
||||
"""Test that when an executor raises an exception, events are drained before propagating."""
|
||||
executor_a = FailingExecutor(id="executor_a", fail_on_data=2)
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
["START"],
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
|
||||
events: list[WorkflowEvent] = []
|
||||
with pytest.raises(RuntimeError, match="Simulated executor failure"):
|
||||
async for event in runner.run_until_convergence():
|
||||
events.append(event)
|
||||
|
||||
# There should be some events emitted before the failure
|
||||
assert len(events) > 0
|
||||
|
||||
|
||||
async def test_runner_reset_iteration_count():
|
||||
"""Test that reset_iteration_count works correctly."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
runner._iteration = 10
|
||||
|
||||
runner.reset_iteration_count()
|
||||
|
||||
assert runner._iteration == 0
|
||||
|
||||
|
||||
class CheckpointingContext(InProcRunnerContext):
|
||||
"""A context that supports checkpointing for testing."""
|
||||
|
||||
def __init__(self, storage: InMemoryCheckpointStorage | None = None):
|
||||
super().__init__()
|
||||
self._storage = storage or InMemoryCheckpointStorage()
|
||||
self._checkpointing_enabled = True
|
||||
|
||||
def has_checkpointing(self) -> bool:
|
||||
return self._checkpointing_enabled
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: str | None,
|
||||
iteration: int,
|
||||
) -> str:
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_name=workflow_name,
|
||||
graph_signature_hash=graph_signature_hash,
|
||||
state=state.export(),
|
||||
previous_checkpoint_id=previous_checkpoint_id,
|
||||
iteration_count=iteration,
|
||||
)
|
||||
return await self._storage.save(checkpoint)
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
try:
|
||||
return await self._storage.load(checkpoint_id)
|
||||
except WorkflowCheckpointException:
|
||||
return None
|
||||
|
||||
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
# Restore messages from checkpoint
|
||||
for source_id, messages in checkpoint.messages.items():
|
||||
for msg_data in messages:
|
||||
await self.send_message(WorkflowMessage(data=msg_data, source_id=source_id))
|
||||
|
||||
|
||||
class FailingCheckpointContext(InProcRunnerContext):
|
||||
"""A context that fails during checkpoint creation."""
|
||||
|
||||
def has_checkpointing(self) -> bool:
|
||||
return True
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: str | None,
|
||||
iteration: int,
|
||||
) -> str:
|
||||
raise RuntimeError("Simulated checkpoint failure")
|
||||
|
||||
|
||||
async def test_runner_checkpoint_creation_failure():
|
||||
"""Test that checkpoint creation failure is handled gracefully."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
ctx = FailingCheckpointContext()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
["START"],
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
|
||||
# Should complete without raising, even though checkpointing fails
|
||||
result: int | None = None
|
||||
async for event in runner.run_until_convergence():
|
||||
if event.type == "output":
|
||||
result = event.data
|
||||
|
||||
assert result == 10
|
||||
|
||||
|
||||
async def test_runner_restore_from_checkpoint_with_external_storage():
|
||||
"""Test restoring from checkpoint using external storage when context has no checkpointing."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
ctx = InProcRunnerContext() # No checkpointing enabled
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Create a checkpoint manually
|
||||
storage = InMemoryCheckpointStorage()
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
state={"test_key": "test_value"},
|
||||
iteration_count=5,
|
||||
)
|
||||
checkpoint_id = await storage.save(checkpoint)
|
||||
|
||||
# Restore using external storage
|
||||
await runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage=storage)
|
||||
|
||||
assert runner._resumed_from_checkpoint is True
|
||||
assert runner._iteration == 5
|
||||
assert state.get("test_key") == "test_value"
|
||||
|
||||
|
||||
async def test_runner_restore_from_checkpoint_no_storage():
|
||||
"""Test that restore fails when no checkpointing and no external storage."""
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="Cannot load checkpoint"):
|
||||
await runner.restore_from_checkpoint("nonexistent-id")
|
||||
|
||||
|
||||
async def test_runner_restore_from_checkpoint_not_found():
|
||||
"""Test that restore fails when checkpoint is not found."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
state = State()
|
||||
|
||||
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="not found"):
|
||||
await runner.restore_from_checkpoint("nonexistent-id")
|
||||
|
||||
|
||||
async def test_runner_restore_from_checkpoint_graph_hash_mismatch():
|
||||
"""Test that restore fails when graph hash doesn't match."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
state = State()
|
||||
|
||||
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="current_hash")
|
||||
|
||||
# Create a checkpoint with a different graph hash
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="different_hash",
|
||||
state={},
|
||||
iteration_count=5,
|
||||
)
|
||||
checkpoint_id = await storage.save(checkpoint)
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"):
|
||||
await runner.restore_from_checkpoint(checkpoint_id)
|
||||
|
||||
|
||||
async def test_runner_restore_from_checkpoint_generic_exception():
|
||||
"""Test that generic exceptions during restore are wrapped in WorkflowCheckpointException."""
|
||||
state = State()
|
||||
|
||||
# Create a mock context that raises a generic exception
|
||||
mock_ctx = MagicMock(spec=InProcRunnerContext)
|
||||
mock_ctx.has_checkpointing.return_value = True
|
||||
mock_ctx.load_checkpoint = AsyncMock(side_effect=ValueError("Unexpected error"))
|
||||
|
||||
runner = Runner([], {}, state, mock_ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="Failed to restore from checkpoint"):
|
||||
await runner.restore_from_checkpoint("some-id")
|
||||
|
||||
|
||||
async def test_runner_restore_executor_states_invalid_states_type():
|
||||
"""Test that restore fails when executor states is not a dict."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
state.set(EXECUTOR_STATE_KEY, "not_a_dict")
|
||||
state.commit()
|
||||
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="not a dictionary"):
|
||||
await runner._restore_executor_states()
|
||||
|
||||
|
||||
async def test_runner_restore_executor_states_invalid_executor_id_type():
|
||||
"""Test that restore fails when executor ID is not a string."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
state.set(EXECUTOR_STATE_KEY, {123: {"key": "value"}}) # Non-string key
|
||||
state.commit()
|
||||
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="not a string"):
|
||||
await runner._restore_executor_states()
|
||||
|
||||
|
||||
async def test_runner_restore_executor_states_invalid_state_type():
|
||||
"""Test that restore fails when executor state is not a dict[str, Any]."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
state.set(EXECUTOR_STATE_KEY, {"executor_a": "not_a_dict"})
|
||||
state.commit()
|
||||
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="not a dict"):
|
||||
await runner._restore_executor_states()
|
||||
|
||||
|
||||
async def test_runner_restore_executor_states_invalid_state_keys():
|
||||
"""Test that restore fails when executor state dict has non-string keys."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
state.set(EXECUTOR_STATE_KEY, {"executor_a": {123: "value"}}) # Non-string key in state
|
||||
state.commit()
|
||||
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="not a dict"):
|
||||
await runner._restore_executor_states()
|
||||
|
||||
|
||||
async def test_runner_restore_executor_states_missing_executor():
|
||||
"""Test that restore fails when executor is not found."""
|
||||
state = State()
|
||||
state.set(EXECUTOR_STATE_KEY, {"missing_executor": {"key": "value"}})
|
||||
state.commit()
|
||||
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="not found during state restoration"):
|
||||
await runner._restore_executor_states()
|
||||
|
||||
|
||||
async def test_runner_set_executor_state_invalid_existing_states():
|
||||
"""Test that _set_executor_state fails when existing states is not a dict."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
state.set(EXECUTOR_STATE_KEY, "not_a_dict")
|
||||
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowCheckpointException, match="not a dictionary"):
|
||||
await runner._set_executor_state("executor_a", {"key": "value"})
|
||||
|
||||
|
||||
async def test_runner_with_pre_loop_events():
|
||||
"""Test that pre-loop events are yielded correctly."""
|
||||
ctx = InProcRunnerContext()
|
||||
state = State()
|
||||
|
||||
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Add an event before running
|
||||
await ctx.add_event(WorkflowEvent.output(executor_id="test_executor", data="pre-loop-output"))
|
||||
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in runner.run_until_convergence():
|
||||
events.append(event)
|
||||
|
||||
# Should have the pre-loop output event
|
||||
output_events = [e for e in events if e.type == "output"]
|
||||
assert len(output_events) == 1
|
||||
assert output_events[0].data == "pre-loop-output"
|
||||
|
||||
|
||||
class EventEmittingExecutor(Executor):
|
||||
"""An executor that emits events during execution."""
|
||||
|
||||
@handler
|
||||
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
|
||||
# Emit event during processing
|
||||
await ctx.yield_output(f"processed-{message.data}")
|
||||
if message.data < 3:
|
||||
await ctx.send_message(MockMessage(data=message.data + 1))
|
||||
|
||||
|
||||
async def test_runner_drains_straggler_events():
|
||||
"""Test that events emitted at the end of iteration are drained."""
|
||||
executor_a = EventEmittingExecutor(id="executor_a")
|
||||
executor_b = EventEmittingExecutor(id="executor_b")
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
["START"],
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in runner.run_until_convergence():
|
||||
events.append(event)
|
||||
|
||||
# Should have output events from both executors
|
||||
output_events = [e for e in events if e.type == "output"]
|
||||
assert len(output_events) > 0
|
||||
|
||||
|
||||
async def test_runner_restore_executor_states_no_states():
|
||||
"""Test that restore does nothing when there are no executor states."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
state = State() # No executor states set
|
||||
state.commit()
|
||||
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Should complete without error when no executor states exist
|
||||
await runner._restore_executor_states()
|
||||
|
||||
|
||||
async def test_runner_checkpoint_with_resumed_flag():
|
||||
"""Test that resumed flag prevents initial checkpoint creation."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
runner._mark_resumed(5)
|
||||
|
||||
# Add a message to trigger the checkpoint creation path
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START"))
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=8),
|
||||
["START"],
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
|
||||
# Run until convergence
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
# After completing, resumed flag should be reset
|
||||
assert runner._resumed_from_checkpoint is False
|
||||
|
||||
|
||||
class ExecutorThatFailsWithEvents(Executor):
|
||||
"""An executor that emits events and then raises an exception after receiving messages."""
|
||||
|
||||
def __init__(self, id: str, runner_ctx: RunnerContext, fail_on_iteration: int = 1):
|
||||
super().__init__(id=id)
|
||||
self._runner_ctx = runner_ctx
|
||||
self._fail_on_iteration = fail_on_iteration
|
||||
self._iteration_count = 0
|
||||
|
||||
@handler
|
||||
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
|
||||
self._iteration_count += 1
|
||||
# First emit an output event to the workflow context
|
||||
await ctx.yield_output(f"output-before-failure-{message.data}")
|
||||
# Add some events directly to the runner context
|
||||
await self._runner_ctx.add_event(WorkflowEvent.output(executor_id=self.id, data="pending-event"))
|
||||
# Fail on the specified iteration
|
||||
if self._iteration_count >= self._fail_on_iteration:
|
||||
raise RuntimeError("Executor failed with pending events")
|
||||
# Otherwise, send to next
|
||||
await ctx.send_message(MockMessage(data=message.data + 1))
|
||||
|
||||
|
||||
class PassthroughExecutor(Executor):
|
||||
"""An executor that passes messages through to the failing executor."""
|
||||
|
||||
@handler
|
||||
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
|
||||
await ctx.send_message(MockMessage(data=message.data))
|
||||
|
||||
|
||||
async def test_runner_drains_events_on_iteration_exception():
|
||||
"""Test that events are drained when iteration task raises an exception (lines 128-129)."""
|
||||
ctx = InProcRunnerContext()
|
||||
# executor_b will fail with pending events after receiving a message
|
||||
executor_a = PassthroughExecutor(id="executor_a")
|
||||
executor_b = ExecutorThatFailsWithEvents(id="executor_b", runner_ctx=ctx, fail_on_iteration=1)
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Execute through executor_a which will pass to executor_b during the runner iteration
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
["START"],
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
|
||||
events: list[WorkflowEvent] = []
|
||||
with pytest.raises(RuntimeError, match="Executor failed with pending events"):
|
||||
async for event in runner.run_until_convergence():
|
||||
events.append(event)
|
||||
|
||||
# Events should include the ones emitted before the exception
|
||||
output_events = [e for e in events if e.type == "output"]
|
||||
# Should have drained the pending events before propagating the exception
|
||||
assert len(output_events) >= 1
|
||||
|
||||
|
||||
class SlowEventEmittingExecutor(Executor):
|
||||
"""An executor that emits events with delays to test straggler event draining."""
|
||||
|
||||
def __init__(self, id: str, iterations_to_emit: int = 2):
|
||||
super().__init__(id=id)
|
||||
self.iterations_to_emit = iterations_to_emit
|
||||
self.current_iteration = 0
|
||||
|
||||
@handler
|
||||
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
|
||||
self.current_iteration += 1
|
||||
# Emit output event
|
||||
await ctx.yield_output(f"iteration-{self.current_iteration}")
|
||||
# Continue sending messages until we reach the target iterations
|
||||
if self.current_iteration < self.iterations_to_emit:
|
||||
await ctx.send_message(MockMessage(data=message.data + 1))
|
||||
|
||||
|
||||
async def test_runner_drains_straggler_events_at_iteration_end():
|
||||
"""Test that events emitted at the very end of iteration are drained (lines 135-136)."""
|
||||
# Create executors that ping-pong messages and emit events
|
||||
executor_a = SlowEventEmittingExecutor(id="executor_a", iterations_to_emit=3)
|
||||
executor_b = SlowEventEmittingExecutor(id="executor_b", iterations_to_emit=3)
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
["START"],
|
||||
state,
|
||||
ctx,
|
||||
)
|
||||
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in runner.run_until_convergence():
|
||||
events.append(event)
|
||||
|
||||
# Check that output events were collected (including straggler events)
|
||||
output_events = [e for e in events if e.type == "output"]
|
||||
# We should have output events from both executors
|
||||
assert len(output_events) >= 2
|
||||
|
||||
@@ -647,12 +647,11 @@ class TestSerializationWorkflowClasses:
|
||||
# Test 2: Without name and description (defaults)
|
||||
workflow2 = WorkflowBuilder(start_executor=SampleExecutor(id="e2")).build()
|
||||
|
||||
assert workflow2.name is None
|
||||
assert workflow2.name is not None
|
||||
assert workflow2.description is None
|
||||
|
||||
data2 = workflow2.to_dict()
|
||||
assert "name" not in data2 # Should not include None values
|
||||
assert "description" not in data2
|
||||
assert "description" not in data2 # Should not include None values
|
||||
|
||||
# Test 3: With only name (no description)
|
||||
workflow3 = WorkflowBuilder(name="Named Only", start_executor=SampleExecutor(id="e3")).build()
|
||||
|
||||
@@ -595,7 +595,7 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
|
||||
assert first_request_id is not None
|
||||
|
||||
# Get checkpoint
|
||||
checkpoints = await storage.list_checkpoints(workflow1.id)
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow1.name)
|
||||
checkpoint_id = max(checkpoints, key=lambda cp: cp.iteration_count).checkpoint_id
|
||||
|
||||
# Step 2: Resume workflow from checkpoint
|
||||
|
||||
@@ -335,12 +335,9 @@ async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint(
|
||||
)
|
||||
|
||||
# Attempt to run from non-existent checkpoint should fail
|
||||
try:
|
||||
with pytest.raises(WorkflowCheckpointException, match="No checkpoint found with ID nonexistent_checkpoint_id"):
|
||||
async for _ in workflow.run(checkpoint_id="nonexistent_checkpoint_id", stream=True):
|
||||
pass
|
||||
raise AssertionError("Expected WorkflowCheckpointException to be raised")
|
||||
except WorkflowCheckpointException as e:
|
||||
assert str(e) == "Checkpoint nonexistent_checkpoint_id not found"
|
||||
|
||||
|
||||
async def test_workflow_run_stream_from_checkpoint_with_external_storage(
|
||||
@@ -354,12 +351,14 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage(
|
||||
from agent_framework import WorkflowCheckpoint
|
||||
|
||||
test_checkpoint = WorkflowCheckpoint(
|
||||
workflow_id="test-workflow",
|
||||
workflow_name="test-workflow",
|
||||
graph_signature_hash="test-graph-signature",
|
||||
previous_checkpoint_id=None,
|
||||
messages={},
|
||||
state={},
|
||||
iteration_count=0,
|
||||
)
|
||||
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
|
||||
checkpoint_id = await storage.save(test_checkpoint)
|
||||
|
||||
# Create a workflow WITHOUT checkpointing
|
||||
workflow_without_checkpointing = (
|
||||
@@ -385,17 +384,6 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
# Create a test checkpoint manually in storage
|
||||
from agent_framework import WorkflowCheckpoint
|
||||
|
||||
test_checkpoint = WorkflowCheckpoint(
|
||||
workflow_id="test-workflow",
|
||||
messages={},
|
||||
state={},
|
||||
iteration_count=0,
|
||||
)
|
||||
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
|
||||
|
||||
# Build workflow with checkpointing
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
|
||||
@@ -403,6 +391,19 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
|
||||
.build()
|
||||
)
|
||||
|
||||
# Create a test checkpoint manually in storage
|
||||
from agent_framework import WorkflowCheckpoint
|
||||
|
||||
test_checkpoint = WorkflowCheckpoint(
|
||||
workflow_name=workflow.name,
|
||||
graph_signature_hash=workflow.graph_signature_hash,
|
||||
previous_checkpoint_id=None,
|
||||
messages={},
|
||||
state={},
|
||||
iteration_count=0,
|
||||
)
|
||||
checkpoint_id = await storage.save(test_checkpoint)
|
||||
|
||||
# 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
|
||||
@@ -416,11 +417,19 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
# Build workflow with checkpointing
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
|
||||
.add_edge(simple_executor, simple_executor)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Create a test checkpoint manually in storage
|
||||
from agent_framework import WorkflowCheckpoint
|
||||
|
||||
test_checkpoint = WorkflowCheckpoint(
|
||||
workflow_id="test-workflow",
|
||||
workflow_name=workflow.name,
|
||||
graph_signature_hash=workflow.graph_signature_hash,
|
||||
messages={},
|
||||
state={},
|
||||
pending_request_info_events={
|
||||
@@ -429,18 +438,11 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
|
||||
source_executor_id=simple_executor.id,
|
||||
request_data="Mock",
|
||||
response_type=str,
|
||||
).to_dict(),
|
||||
),
|
||||
},
|
||||
iteration_count=0,
|
||||
)
|
||||
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
|
||||
|
||||
# Build workflow with checkpointing
|
||||
workflow = (
|
||||
WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
|
||||
.add_edge(simple_executor, simple_executor)
|
||||
.build()
|
||||
)
|
||||
checkpoint_id = await storage.save(test_checkpoint)
|
||||
|
||||
# Resume from checkpoint - pending request events should be emitted
|
||||
events: list[WorkflowEvent] = []
|
||||
@@ -542,7 +544,7 @@ async def test_workflow_checkpoint_runtime_only_configuration(
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Verify checkpoints were created
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
|
||||
assert len(checkpoints) > 0
|
||||
|
||||
# Find a superstep checkpoint to resume from
|
||||
@@ -592,8 +594,8 @@ async def test_workflow_checkpoint_runtime_overrides_buildtime(
|
||||
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()
|
||||
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=workflow.name)
|
||||
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=workflow.name)
|
||||
|
||||
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
|
||||
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
|
||||
|
||||
@@ -607,7 +607,7 @@ class TestWorkflowAgent:
|
||||
|
||||
# Drain workflow events to get checkpoint
|
||||
# The workflow should have created checkpoints
|
||||
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
|
||||
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
|
||||
assert len(checkpoints) > 0, "Checkpoints should have been created when checkpoint_storage is provided"
|
||||
|
||||
async def test_agent_executor_output_response_false_filters_streaming_events(self):
|
||||
|
||||
@@ -306,8 +306,8 @@ async def test_end_to_end_workflow_tracing(span_exporter: InMemorySpanExporter)
|
||||
assert len(build_spans_with_metadata) == 1
|
||||
metadata_build_span = build_spans_with_metadata[0]
|
||||
assert metadata_build_span.attributes is not None
|
||||
assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_NAME) == "Test Pipeline"
|
||||
assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_DESCRIPTION) == "Test workflow description"
|
||||
assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_BUILDER_NAME) == "Test Pipeline"
|
||||
assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_BUILDER_DESCRIPTION) == "Test workflow description"
|
||||
|
||||
# Clear spans to separate build from run tracing
|
||||
span_exporter.clear()
|
||||
@@ -451,14 +451,14 @@ async def test_message_trace_context_serialization(span_exporter: InMemorySpanEx
|
||||
await ctx.send_message(message)
|
||||
|
||||
# Create a checkpoint that includes the message
|
||||
checkpoint_id = await ctx.create_checkpoint(State(), 0)
|
||||
checkpoint_id = await ctx.create_checkpoint("test_name", "test_hash", State(), None, 0)
|
||||
checkpoint = await ctx.load_checkpoint(checkpoint_id)
|
||||
assert checkpoint is not None
|
||||
|
||||
# Check serialized message includes trace context
|
||||
serialized_msg = checkpoint.messages["source"][0]
|
||||
assert serialized_msg["trace_contexts"] == [{"traceparent": "00-trace-span-01"}]
|
||||
assert serialized_msg["source_span_ids"] == ["span123"]
|
||||
assert serialized_msg.trace_contexts == [{"traceparent": "00-trace-span-01"}]
|
||||
assert serialized_msg.source_span_ids == ["span123"]
|
||||
|
||||
# Test deserialization
|
||||
await ctx.apply_checkpoint(checkpoint)
|
||||
|
||||
Reference in New Issue
Block a user