[BREAKING] Python: Refactor SharedState to State with sync methods and superstep caching (#3667)

* Refactor SharedState to State with sync methods and superstep caching

* Fixes

* Address PR feedback

* Remove dead links

* Fix lab test import
This commit is contained in:
Evan Mattson
2026-02-05 10:42:52 +09:00
committed by GitHub
Unverified
parent 4e25917644
commit 10afb86213
48 changed files with 1971 additions and 1724 deletions
@@ -330,7 +330,7 @@ class AgentExecutor(Executor):
Returns:
The complete AgentResponse, or None if waiting for user input.
"""
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
response = await self._agent.run(
self._cache,
@@ -357,7 +357,7 @@ class AgentExecutor(Executor):
Returns:
The complete AgentResponse, or None if waiting for user input.
"""
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {}
updates: list[AgentResponseUpdate] = []
user_input_requests: list[Content] = []
@@ -26,15 +26,17 @@ class WorkflowCheckpoint:
workflow_id: Identifier of the workflow this checkpoint belongs to
timestamp: ISO 8601 timestamp when checkpoint was created
messages: Messages exchanged between executors
shared_state: Complete shared state including user data and executor states.
Executor states are stored under the reserved key '_executor_state'.
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'.
iteration_count: Current iteration number when checkpoint was created
metadata: Additional metadata (e.g., superstep info, graph signature)
version: Checkpoint format version
Note:
The shared_state dict may contain reserved keys managed by the framework.
See SharedState class documentation for details on reserved keys.
The state dict may contain reserved keys managed by the framework.
See State class documentation for details on reserved keys.
"""
checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4()))
@@ -43,7 +45,7 @@ class WorkflowCheckpoint:
# Core workflow state
messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc]
shared_state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
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]
# Runtime state
@@ -25,7 +25,7 @@ class WorkflowCheckpointSummary:
def get_checkpoint_summary(checkpoint: WorkflowCheckpoint) -> WorkflowCheckpointSummary:
targets = sorted(checkpoint.messages.keys())
executor_ids = sorted(checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {}).keys())
executor_ids = sorted(checkpoint.state.get(EXECUTOR_STATE_KEY, {}).keys())
pending_request_info_events = [
RequestInfoEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
]
@@ -3,13 +3,13 @@
# Default maximum iterations for workflow execution.
DEFAULT_MAX_ITERATIONS = 100
# Key used to store executor state in shared state.
# Key used to store executor state in state.
EXECUTOR_STATE_KEY = "_executor_state"
# Source identifier for internal workflow messages.
INTERNAL_SOURCE_PREFIX = "internal"
# SharedState key for storing run kwargs that should be passed to agent invocations.
# State key for storing run kwargs that should be passed to agent invocations.
# Used by all orchestration patterns (Sequential, Concurrent, GroupChat, Handoff, Magentic)
# to pass kwargs from workflow.run_stream() through to agent.run_stream() and @tool functions.
WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs"
@@ -19,7 +19,7 @@ from ._edge import (
)
from ._executor import Executor
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
from ._state import State
logger = logging.getLogger(__name__)
@@ -38,12 +38,12 @@ class EdgeRunner(ABC):
self._executors = executors
@abstractmethod
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
async def send_message(self, message: Message, state: State, ctx: RunnerContext) -> bool:
"""Send a message through the edge group.
Args:
message: The message to send.
shared_state: The shared state to use for holding data.
state: The workflow state.
ctx: The context for the runner.
Returns:
@@ -63,7 +63,7 @@ class EdgeRunner(ABC):
target_id: str,
source_ids: list[str],
message: Message,
shared_state: SharedState,
state: State,
ctx: RunnerContext,
) -> None:
"""Execute a message on a target executor with trace context."""
@@ -76,7 +76,7 @@ class EdgeRunner(ABC):
await target_executor.execute(
message,
source_ids, # source_executor_ids
shared_state, # shared_state
state, # state
ctx, # runner_context
trace_contexts=message.trace_contexts, # Pass trace contexts
source_span_ids=message.source_span_ids, # Pass source span IDs for linking
@@ -90,7 +90,7 @@ class SingleEdgeRunner(EdgeRunner):
super().__init__(edge_group, executors)
self._edge = edge_group.edges[0]
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
async def send_message(self, message: Message, state: State, ctx: RunnerContext) -> bool:
"""Send a message through the single edge."""
should_execute = False
target_id: str | None = None
@@ -144,7 +144,7 @@ class SingleEdgeRunner(EdgeRunner):
# Execute outside the span
if should_execute and target_id and source_id:
await self._execute_on_target(target_id, [source_id], message, shared_state, ctx)
await self._execute_on_target(target_id, [source_id], message, state, ctx)
return True
return False
@@ -162,7 +162,7 @@ class FanOutEdgeRunner(EdgeRunner):
Callable[[Any, list[str]], list[str]] | None, getattr(edge_group, "selection_func", None)
)
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
async def send_message(self, message: Message, state: State, ctx: RunnerContext) -> bool:
"""Send a message through all edges in the fan-out edge group."""
deliverable_edges: list[Edge] = []
single_target_edge: Edge | None = None
@@ -253,14 +253,14 @@ class FanOutEdgeRunner(EdgeRunner):
# Execute outside the span
if single_target_edge:
await self._execute_on_target(
single_target_edge.target_id, [single_target_edge.source_id], message, shared_state, ctx
single_target_edge.target_id, [single_target_edge.source_id], message, state, ctx
)
return True
if deliverable_edges:
async def send_to_edge(edge: Edge) -> bool:
await self._execute_on_target(edge.target_id, [edge.source_id], message, shared_state, ctx)
await self._execute_on_target(edge.target_id, [edge.source_id], message, state, ctx)
return True
tasks = [send_to_edge(edge) for edge in deliverable_edges]
@@ -285,7 +285,7 @@ class FanInEdgeRunner(EdgeRunner):
# Key is the source executor ID, value is a list of messages
self._buffer: dict[str, list[Message]] = defaultdict(list)
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
async def send_message(self, message: Message, state: State, ctx: RunnerContext) -> bool:
"""Send a message through all edges in the fan-in edge group."""
execution_data: dict[str, Any] | None = None
with create_edge_group_processing_span(
@@ -362,7 +362,7 @@ class FanInEdgeRunner(EdgeRunner):
# Execute outside the span if needed
if execution_data:
await self._execute_on_target(
execution_data["target_id"], execution_data["source_ids"], execution_data["message"], shared_state, ctx
execution_data["target_id"], execution_data["source_ids"], execution_data["message"], state, ctx
)
return True
@@ -20,7 +20,7 @@ from ._events import (
from ._model_utils import DictConvertible
from ._request_info_mixin import RequestInfoMixin
from ._runner_context import Message, MessageType, RunnerContext
from ._shared_state import SharedState
from ._state import State
from ._typing_utils import is_instance_of, normalize_type_to_list, resolve_type_annotation
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
@@ -221,7 +221,7 @@ class Executor(RequestInfoMixin, DictConvertible):
self,
message: Any,
source_executor_ids: list[str],
shared_state: SharedState,
state: State,
runner_context: RunnerContext,
trace_contexts: list[dict[str, str]] | None = None,
source_span_ids: list[str] | None = None,
@@ -234,7 +234,7 @@ class Executor(RequestInfoMixin, DictConvertible):
Args:
message: The message to be processed by the executor.
source_executor_ids: The IDs of the source executors that sent messages to this executor.
shared_state: The shared state for the workflow.
state: The state for the workflow.
runner_context: The runner context that provides methods to send messages and events.
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
source_span_ids: Optional source span IDs from multiple sources for linking.
@@ -262,7 +262,7 @@ class Executor(RequestInfoMixin, DictConvertible):
# Create the appropriate WorkflowContext based on handler specs
context = self._create_context_for_handler(
source_executor_ids=source_executor_ids,
shared_state=shared_state,
state=state,
runner_context=runner_context,
trace_contexts=trace_contexts,
source_span_ids=source_span_ids,
@@ -295,7 +295,7 @@ class Executor(RequestInfoMixin, DictConvertible):
def _create_context_for_handler(
self,
source_executor_ids: list[str],
shared_state: SharedState,
state: State,
runner_context: RunnerContext,
trace_contexts: list[dict[str, str]] | None = None,
source_span_ids: list[str] | None = None,
@@ -305,7 +305,7 @@ class Executor(RequestInfoMixin, DictConvertible):
Args:
source_executor_ids: The IDs of the source executors that sent messages to this executor.
shared_state: The shared state for the workflow.
state: The state for the workflow.
runner_context: The runner context that provides methods to send messages and events.
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
source_span_ids: Optional source span IDs from multiple sources for linking.
@@ -318,7 +318,7 @@ class Executor(RequestInfoMixin, DictConvertible):
return WorkflowContext(
executor=self,
source_executor_ids=source_executor_ids,
shared_state=shared_state,
state=state,
runner_context=runner_context,
trace_contexts=trace_contexts,
source_span_ids=source_span_ids,
@@ -27,7 +27,7 @@ from ._runner_context import (
Message,
RunnerContext,
)
from ._shared_state import SharedState
from ._state import State
logger = logging.getLogger(__name__)
@@ -39,17 +39,17 @@ class Runner:
self,
edge_groups: Sequence[EdgeGroup],
executors: dict[str, Executor],
shared_state: SharedState,
state: State,
ctx: RunnerContext,
max_iterations: int = 100,
workflow_id: str | None = None,
) -> None:
"""Initialize the runner with edges, shared state, and context.
"""Initialize the runner with edges, state, and context.
Args:
edge_groups: The edge groups of the workflow.
executors: Map of executor IDs to executor instances.
shared_state: The shared state for the workflow.
state: The state for the workflow.
ctx: The runner context for the workflow.
max_iterations: The maximum number of iterations to run.
workflow_id: The workflow ID for checkpointing.
@@ -60,7 +60,7 @@ class Runner:
self._ctx = ctx
self._iteration = 0
self._max_iterations = max_iterations
self._shared_state = shared_state
self._state = state
self._workflow_id = workflow_id
self._running = False
self._resumed_from_checkpoint = False # Track whether we resumed
@@ -141,6 +141,9 @@ class Runner:
logger.info(f"Completed superstep {self._iteration}")
# Commit pending state changes at superstep boundary
self._state.commit()
# Create checkpoint after each superstep iteration
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
@@ -164,7 +167,7 @@ class Runner:
async def _deliver_message_inner(edge_runner: EdgeRunner, message: Message) -> bool:
"""Inner loop to deliver a single message through an edge runner."""
return await edge_runner.send_message(message, self._shared_state, self._ctx)
return await edge_runner.send_message(message, self._state, self._ctx)
def _normalize_message_payload(message: Message) -> None:
data = message.data
@@ -212,7 +215,7 @@ class Runner:
if self.graph_signature_hash:
metadata["graph_signature"] = self.graph_signature_hash
checkpoint_id = await self._ctx.create_checkpoint(
self._shared_state,
self._state,
self._iteration,
metadata=metadata,
)
@@ -271,9 +274,9 @@ class Runner:
)
self._workflow_id = checkpoint.workflow_id
# Restore shared state
await self._shared_state.import_state(decode_checkpoint_value(checkpoint.shared_state))
# Restore executor states using the restored shared state
# Restore state
self._state.import_state(decode_checkpoint_value(checkpoint.state))
# Restore executor states using the restored state
await self._restore_executor_states()
# Apply the checkpoint to the context
await self._ctx.apply_checkpoint(checkpoint)
@@ -346,11 +349,11 @@ class Runner:
This method will try the backward compatibility behavior first; if that does not restore state,
it falls back to the updated behavior.
"""
has_executor_states = await self._shared_state.has(EXECUTOR_STATE_KEY)
has_executor_states = self._state.has(EXECUTOR_STATE_KEY)
if not has_executor_states:
return
executor_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
executor_states = self._state.get(EXECUTOR_STATE_KEY)
if not isinstance(executor_states, dict):
raise WorkflowCheckpointException("Executor states in shared state is not a dictionary. Unable to restore.")
@@ -416,19 +419,15 @@ class Runner:
self._iteration = iteration
async def _set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
"""Store executor state in shared state under a reserved key.
"""Store executor state in state under a reserved key.
Executors call this with a JSON-serializable dict capturing the minimal
state needed to resume. It replaces any previously stored state.
"""
has_existing_states = await self._shared_state.has(EXECUTOR_STATE_KEY)
if has_existing_states:
existing_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
else:
existing_states = {}
existing_states = self._state.get(EXECUTOR_STATE_KEY, {})
if not isinstance(existing_states, dict):
raise WorkflowCheckpointException("Existing executor states in shared state is not a dictionary.")
raise WorkflowCheckpointException("Existing executor states in state is not a dictionary.")
existing_states[executor_id] = state
await self._shared_state.set(EXECUTOR_STATE_KEY, existing_states)
self._state.set(EXECUTOR_STATE_KEY, existing_states)
@@ -13,7 +13,7 @@ from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import INTERNAL_SOURCE_ID
from ._events import RequestInfoEvent, WorkflowEvent
from ._shared_state import SharedState
from ._state import State
from ._typing_utils import is_instance_of
if sys.version_info >= (3, 11):
@@ -104,7 +104,7 @@ class _WorkflowState(TypedDict):
"""
messages: dict[str, list[dict[str, Any]]]
shared_state: dict[str, Any]
state: dict[str, Any]
iteration_count: int
pending_request_info_events: dict[str, dict[str, Any]]
@@ -217,16 +217,16 @@ class RunnerContext(Protocol):
async def create_checkpoint(
self,
shared_state: SharedState,
state: State,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
"""Create a checkpoint of the current workflow state.
Args:
shared_state: The shared state to include in the checkpoint.
This is needed to capture the full state of the workflow.
The shared state is not managed by the context itself.
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.
iteration_count: The current iteration count of the workflow.
metadata: Optional metadata to associate with the checkpoint.
@@ -374,7 +374,7 @@ class InProcRunnerContext:
async def create_checkpoint(
self,
shared_state: SharedState,
state: State,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
@@ -383,14 +383,14 @@ class InProcRunnerContext:
raise ValueError("Checkpoint storage not configured")
self._workflow_id = self._workflow_id or str(uuid.uuid4())
state = await self._get_serialized_workflow_state(shared_state, iteration_count)
workflow_state = self._get_serialized_workflow_state(state, iteration_count)
checkpoint = WorkflowCheckpoint(
workflow_id=self._workflow_id,
messages=state["messages"],
shared_state=state["shared_state"],
pending_request_info_events=state["pending_request_info_events"],
iteration_count=state["iteration_count"],
messages=workflow_state["messages"],
state=workflow_state["state"],
pending_request_info_events=workflow_state["pending_request_info_events"],
iteration_count=workflow_state["iteration_count"],
metadata=metadata or {},
)
checkpoint_id = await storage.save_checkpoint(checkpoint)
@@ -454,7 +454,7 @@ class InProcRunnerContext:
"""
return self._streaming
async def _get_serialized_workflow_state(self, shared_state: SharedState, iteration_count: int) -> _WorkflowState:
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]
@@ -465,7 +465,7 @@ class InProcRunnerContext:
return {
"messages": serialized_messages,
"shared_state": encode_checkpoint_value(await shared_state.export_state()),
"state": encode_checkpoint_value(state.export_state()),
"iteration_count": iteration_count,
"pending_request_info_events": serialized_pending_request_info_events,
}
@@ -1,101 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
class SharedState:
"""A class to manage shared state in a workflow.
SharedState provides thread-safe access to workflow state data that needs to be
shared across executors during workflow execution.
Reserved Keys:
The following keys are reserved for internal framework use and should not be
modified by user code:
- `_executor_state`: Stores executor state for checkpointing (managed by Runner)
Warning:
Do not use keys starting with underscore (_) as they may be reserved for
internal framework operations.
"""
def __init__(self) -> None:
"""Initialize the shared state."""
self._state: dict[str, Any] = {}
self._shared_state_lock = asyncio.Lock()
async def set(self, key: str, value: Any) -> None:
"""Set a value in the shared state."""
async with self._shared_state_lock:
await self.set_within_hold(key, value)
async def get(self, key: str) -> Any:
"""Get a value from the shared state."""
async with self._shared_state_lock:
return await self.get_within_hold(key)
async def has(self, key: str) -> bool:
"""Check if a key exists in the shared state."""
async with self._shared_state_lock:
return await self.has_within_hold(key)
async def delete(self, key: str) -> None:
"""Delete a key from the shared state."""
async with self._shared_state_lock:
await self.delete_within_hold(key)
async def clear(self) -> None:
"""Clear the entire shared state."""
async with self._shared_state_lock:
self._state.clear()
async def export_state(self) -> dict[str, Any]:
"""Get a serialized copy of the entire shared state."""
async with self._shared_state_lock:
return dict(self._state)
async def import_state(self, state: dict[str, Any]) -> None:
"""Populate the shared state from a serialized state dictionary.
This replaces the entire current state with the provided state.
"""
async with self._shared_state_lock:
self._state.update(state)
@asynccontextmanager
async def hold(self) -> AsyncIterator["SharedState"]:
"""Context manager to hold the shared state lock for multiple operations.
Usage:
async with shared_state.hold():
await shared_state.set_within_hold("key", value)
value = await shared_state.get_within_hold("key")
"""
async with self._shared_state_lock:
yield self
# Unsafe methods that don't acquire locks (for use within hold() context)
async def set_within_hold(self, key: str, value: Any) -> None:
"""Set a value without acquiring the lock (unsafe - use within hold() context)."""
self._state[key] = value
async def get_within_hold(self, key: str) -> Any:
"""Get a value without acquiring the lock (unsafe - use within hold() context)."""
if key not in self._state:
raise KeyError(f"Key '{key}' not found in shared state.")
return self._state[key]
async def has_within_hold(self, key: str) -> bool:
"""Check if a key exists without acquiring the lock (unsafe - use within hold() context)."""
return key in self._state
async def delete_within_hold(self, key: str) -> None:
"""Delete a key without acquiring the lock (unsafe - use within hold() context)."""
if key in self._state:
del self._state[key]
else:
raise KeyError(f"Key '{key}' not found in shared state.")
@@ -0,0 +1,127 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
class State:
"""Manages shared state across executors within a workflow.
State provides access to workflow state data that is shared across executors
during workflow execution. It implements superstep caching semantics where
writes are staged in a pending buffer and only committed to the actual state
at superstep boundaries.
Superstep Semantics:
- `set()` writes to a pending buffer, not directly to committed state
- `get()` checks pending buffer first, then committed state
- `commit()` moves all pending changes to committed state (called by Runner at superstep boundary)
- `discard()` clears pending changes without committing
Reserved Keys:
Keys starting with underscore (_) are reserved for internal framework use.
Do not use these in user code.
"""
def __init__(self) -> None:
"""Initialize the state."""
self._committed: dict[str, Any] = {}
self._pending: dict[str, Any] = {}
def set(self, key: str, value: Any) -> None:
"""Set a value in the pending state buffer.
The value will be visible to subsequent `get()` calls but won't be
committed to the actual state until `commit()` is called.
Note:
When multiple executors run concurrently within the same superstep,
each executor's writes go to the same pending buffer. The last write
for a given key wins when commit() is called. This is consistent with
the .NET behavior and the superstep execution model where all executors
in a superstep see the same committed state at the start.
"""
self._pending[key] = value
def get(self, key: str, default: Any = None) -> Any:
"""Get a value from state, checking pending first then committed.
Args:
key: The key to retrieve.
default: Value to return if key is not found. Defaults to None.
Returns:
The value if found, otherwise the default value.
"""
if key in self._pending:
value = self._pending[key]
if value is _DeleteSentinel:
return default
return value
return self._committed.get(key, default)
def has(self, key: str) -> bool:
"""Check if a key exists in pending or committed state."""
if key in self._pending:
return self._pending[key] is not _DeleteSentinel
return key in self._committed
def delete(self, key: str) -> None:
"""Mark a key for deletion.
If the key exists in committed state, a sentinel is stored in pending
to indicate deletion at commit time. If it only exists in pending,
it is removed from pending.
"""
if key not in self._pending and key not in self._committed:
raise KeyError(f"Key '{key}' not found in state.")
if key in self._committed:
# Mark for deletion from committed state at commit time
self._pending[key] = _DeleteSentinel
elif key in self._pending:
# Only exists in pending, safe to just remove
del self._pending[key]
def clear(self) -> None:
"""Clear both committed and pending state."""
self._committed.clear()
self._pending.clear()
def commit(self) -> None:
"""Commit pending changes to the committed state.
Called by the Runner at superstep boundaries after successful execution.
"""
for key, value in self._pending.items():
if value is _DeleteSentinel:
self._committed.pop(key, None)
else:
self._committed[key] = value
self._pending.clear()
def discard(self) -> None:
"""Discard all pending changes without committing."""
self._pending.clear()
def export_state(self) -> dict[str, Any]:
"""Export a serialized copy of the committed state.
Note: Does not include pending changes.
"""
return dict(self._committed)
def import_state(self, state: dict[str, Any]) -> None:
"""Import state from a serialized dictionary.
Merges into committed state. Does not affect pending changes.
"""
self._committed.update(state)
class _DeleteSentinelType:
"""Sentinel type to mark keys for deletion in pending state."""
pass
_DeleteSentinel = _DeleteSentinelType()
@@ -33,7 +33,7 @@ from ._executor import Executor
from ._model_utils import DictConvertible
from ._runner import Runner
from ._runner_context import RunnerContext
from ._shared_state import SharedState
from ._state import State
from ._typing_utils import is_instance_of
logger = logging.getLogger(__name__)
@@ -211,11 +211,11 @@ class Workflow(DictConvertible):
# Store non-serializable runtime objects as private attributes
self._runner_context = runner_context
self._shared_state = SharedState()
self._state = State()
self._runner: Runner = Runner(
self.edge_groups,
self.executors,
self._shared_state,
self._state,
runner_context,
max_iterations=max_iterations,
workflow_id=self.id,
@@ -309,7 +309,7 @@ class Workflow(DictConvertible):
initial_executor_fn: Optional function to execute initial executor
reset_context: Whether to reset the context for a new run
streaming: Whether to enable streaming mode for agents
run_kwargs: Optional kwargs to store in SharedState for agent invocations
run_kwargs: Optional kwargs to store in State for agent invocations
Yields:
WorkflowEvent: The events generated during the workflow execution.
@@ -342,11 +342,12 @@ class Workflow(DictConvertible):
if reset_context:
self._runner.reset_iteration_count()
self._runner.context.reset_for_new_run()
await self._shared_state.clear()
self._state.clear()
# Store run kwargs in SharedState so executors can access them
# Store run kwargs in State so executors can access them
# Always store (even empty dict) so retrieval is deterministic
await self._shared_state.set(WORKFLOW_RUN_KWARGS_KEY, run_kwargs or {})
self._state.set(WORKFLOW_RUN_KWARGS_KEY, run_kwargs or {})
self._state.commit() # Commit immediately so kwargs are available
# Set streaming mode after reset
self._runner_context.set_streaming(streaming)
@@ -440,7 +441,7 @@ class Workflow(DictConvertible):
await executor.execute(
message,
[self.__class__.__name__],
self._shared_state,
self._state,
self._runner.context,
trace_contexts=None,
source_span_ids=None,
@@ -469,7 +470,7 @@ class Workflow(DictConvertible):
- Without checkpoint_id: Enables checkpointing for this run, overriding
build-time configuration
**kwargs: Additional keyword arguments to pass through to agent invocations.
These are stored in SharedState and accessible in @tool functions
These are stored in State and accessible in @tool functions
via the **kwargs parameter.
Yields:
@@ -607,7 +608,7 @@ class Workflow(DictConvertible):
build-time configuration
include_status_events: Whether to include WorkflowStatusEvent instances in the result list.
**kwargs: Additional keyword arguments to pass through to agent invocations.
These are stored in SharedState and accessible in @tool functions
These are stored in State and accessible in @tool functions
via the **kwargs parameter.
Returns:
@@ -9,10 +9,9 @@ from typing import TYPE_CHECKING, Any, Generic, Union, cast, get_args, get_origi
from opentelemetry.propagate import inject
from opentelemetry.trace import SpanKind
from typing_extensions import Never, TypeVar, deprecated
from typing_extensions import Never, TypeVar
from ..observability import OtelAttr, create_workflow_span
from ._const import EXECUTOR_STATE_KEY
from ._events import (
RequestInfoEvent,
WorkflowEvent,
@@ -26,7 +25,7 @@ from ._events import (
_framework_event_origin, # type: ignore
)
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
from ._state import State
if TYPE_CHECKING:
from ._executor import Executor
@@ -267,7 +266,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
self,
executor: "Executor",
source_executor_ids: list[str],
shared_state: SharedState,
state: State,
runner_context: RunnerContext,
trace_contexts: list[dict[str, str]] | None = None,
source_span_ids: list[str] | None = None,
@@ -280,7 +279,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
source_executor_ids: The IDs of the source executors that sent messages to this executor.
This is a list to support fan_in scenarios where multiple sources send aggregated
messages to the same executor.
shared_state: The shared state for the workflow.
state: The workflow state.
runner_context: The runner context that provides methods to send messages and events.
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
source_span_ids: Optional source span IDs from multiple sources for linking (not for nesting).
@@ -290,7 +289,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
self._executor_id = executor.id
self._source_executor_ids = source_executor_ids
self._runner_context = runner_context
self._shared_state = shared_state
self._state = state
# Track messages sent via send_message() for ExecutorCompletedEvent
self._sent_messages: list[Any] = []
@@ -410,13 +409,13 @@ class WorkflowContext(Generic[OutT, W_OutT]):
)
await self._runner_context.add_request_info_event(request_info_event)
async def get_shared_state(self, key: str) -> Any:
"""Get a value from the shared state."""
return await self._shared_state.get(key)
def get_state(self, key: str, default: Any = None) -> Any:
"""Get a value from the workflow state."""
return self._state.get(key, default)
async def set_shared_state(self, key: str, value: Any) -> None:
"""Set a value in the shared state."""
await self._shared_state.set(key, value)
def set_state(self, key: str, value: Any) -> None:
"""Set a value in the workflow state."""
self._state.set(key, value)
def get_source_executor_id(self) -> str:
"""Get the ID of the source executor that sent the message to this executor.
@@ -437,9 +436,9 @@ class WorkflowContext(Generic[OutT, W_OutT]):
return self._source_executor_ids
@property
def shared_state(self) -> SharedState:
"""Get the shared state."""
return self._shared_state
def state(self) -> State:
"""Get the workflow state."""
return self._state
def get_sent_messages(self) -> list[Any]:
"""Get all messages sent via send_message() during this handler execution.
@@ -457,46 +456,6 @@ class WorkflowContext(Generic[OutT, W_OutT]):
"""
return self._yielded_outputs.copy()
@deprecated(
"Override `on_checkpoint_save()` methods instead. "
"For cross-executor state sharing, use set_shared_state() instead. "
"This API will be removed after 12/01/2025."
)
async def set_executor_state(self, state: dict[str, Any]) -> None:
"""Store executor state in shared state under a reserved key.
Executors call this with a JSON-serializable dict capturing the minimal
state needed to resume. It replaces any previously stored state.
"""
has_existing_states = await self._shared_state.has(EXECUTOR_STATE_KEY)
if has_existing_states:
existing_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
else:
existing_states = {}
if not isinstance(existing_states, dict):
raise ValueError("Existing executor states in shared state is not a dictionary.")
existing_states[self._executor_id] = state
await self._shared_state.set(EXECUTOR_STATE_KEY, existing_states)
@deprecated(
"Override `on_checkpoint_restore()` methods instead. "
"For cross-executor state sharing, use get_shared_state() instead. "
"This API will be removed after 12/01/2025."
)
async def get_executor_state(self) -> dict[str, Any] | None:
"""Retrieve previously persisted state for this executor, if any."""
has_existing_states = await self._shared_state.has(EXECUTOR_STATE_KEY)
if not has_existing_states:
return None
existing_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
if not isinstance(existing_states, dict):
raise ValueError("Existing executor states in shared state is not a dictionary.")
return existing_states.get(self._executor_id) # type: ignore
def is_streaming(self) -> bool:
"""Check if the workflow is running in streaming mode.
@@ -386,8 +386,8 @@ class WorkflowExecutor(Executor):
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id} execution {execution_id}")
try:
# Get kwargs from parent workflow's SharedState to propagate to subworkflow
parent_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY) or {}
# Get kwargs from parent workflow's State to propagate to subworkflow
parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {}
# Run the sub-workflow and collect all events, passing parent kwargs
result = await self.workflow.run(input_data, **parent_kwargs)
@@ -93,8 +93,8 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
)
# Verify checkpoint contains executor state with both cache and thread
assert "_executor_state" in restore_checkpoint.shared_state
executor_states = restore_checkpoint.shared_state["_executor_state"]
assert "_executor_state" in restore_checkpoint.state
executor_states = restore_checkpoint.state["_executor_state"]
assert isinstance(executor_states, dict)
assert executor.id in executor_states
@@ -19,7 +19,7 @@ def test_workflow_checkpoint_default_values():
assert checkpoint.workflow_id == ""
assert checkpoint.timestamp != ""
assert checkpoint.messages == {}
assert checkpoint.shared_state == {}
assert checkpoint.state == {}
assert checkpoint.pending_request_info_events == {}
assert checkpoint.iteration_count == 0
assert checkpoint.metadata == {}
@@ -34,7 +34,7 @@ def test_workflow_checkpoint_custom_values():
timestamp=custom_timestamp,
messages={"executor1": [{"data": "test"}]},
pending_request_info_events={"req123": {"data": "test"}},
shared_state={"key": "value"},
state={"key": "value"},
iteration_count=5,
metadata={"test": True},
version="2.0",
@@ -44,7 +44,7 @@ def test_workflow_checkpoint_custom_values():
assert checkpoint.workflow_id == "test-workflow-456"
assert checkpoint.timestamp == custom_timestamp
assert checkpoint.messages == {"executor1": [{"data": "test"}]}
assert checkpoint.shared_state == {"key": "value"}
assert checkpoint.state == {"key": "value"}
assert checkpoint.pending_request_info_events == {"req123": {"data": "test"}}
assert checkpoint.iteration_count == 5
assert checkpoint.metadata == {"test": True}
@@ -159,7 +159,7 @@ async def test_file_checkpoint_storage_save_and_load():
checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
messages={"executor1": [{"data": "hello", "source_id": "test", "target_id": None}]},
shared_state={"key": "value"},
state={"key": "value"},
pending_request_info_events={"req123": {"data": "test"}},
)
@@ -177,7 +177,7 @@ async def test_file_checkpoint_storage_save_and_load():
assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id
assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
assert loaded_checkpoint.messages == checkpoint.messages
assert loaded_checkpoint.shared_state == checkpoint.shared_state
assert loaded_checkpoint.state == checkpoint.state
assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events
@@ -293,7 +293,7 @@ async def test_file_checkpoint_storage_json_serialization():
checkpoint = WorkflowCheckpoint(
workflow_id="complex-workflow",
messages={"executor1": [{"data": {"nested": {"value": 42}}, "source_id": "test", "target_id": None}]},
shared_state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None},
state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None},
pending_request_info_events={"req123": {"data": "test"}},
)
@@ -303,7 +303,7 @@ async def test_file_checkpoint_storage_json_serialization():
assert loaded is not None
assert loaded.messages == checkpoint.messages
assert loaded.shared_state == checkpoint.shared_state
assert loaded.state == checkpoint.state
# Verify the JSON file is properly formatted
file_path = Path(temp_dir) / f"{checkpoint.checkpoint_id}.json"
@@ -311,9 +311,9 @@ async def test_file_checkpoint_storage_json_serialization():
data = json.load(f)
assert data["messages"]["executor1"][0]["data"]["nested"]["value"] == 42
assert data["shared_state"]["list"] == [1, 2, 3]
assert data["shared_state"]["bool"] is True
assert data["shared_state"]["null"] is None
assert data["state"]["list"] == [1, 2, 3]
assert data["state"]["bool"] is True
assert data["state"]["null"] is None
assert data["pending_request_info_events"]["req123"]["data"] == "test"
@@ -23,11 +23,9 @@ from agent_framework._workflows._edge import (
SwitchCaseEdgeGroupDefault,
)
from agent_framework._workflows._edge_runner import create_edge_runner
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._state import State
from agent_framework.observability import EdgeGroupDeliveryStatus
# Add for test
@dataclass
class MockMessage:
@@ -191,13 +189,13 @@ async def test_single_edge_group_send_message() -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
@@ -210,13 +208,13 @@ async def test_single_edge_group_send_message_with_target() -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id, target_id=target.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
@@ -229,13 +227,13 @@ async def test_single_edge_group_send_message_with_invalid_target() -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id, target_id="invalid_target")
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -248,13 +246,13 @@ async def test_single_edge_group_send_message_with_invalid_data() -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = "invalid_data"
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -268,13 +266,13 @@ async def test_single_edge_group_send_message_with_condition_pass() -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x: x.data == "test")
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
assert target.call_count == 1
assert target.last_message.data == "test"
@@ -290,13 +288,13 @@ async def test_single_edge_group_send_message_with_condition_fail() -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x: x.data == "test")
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="different")
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
# Should return True because message was processed, but condition failed
assert success is True
# Target should not be called because condition failed
@@ -312,7 +310,7 @@ async def test_single_edge_group_tracing_success(span_exporter) -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
# Create trace context and span IDs to simulate a message with tracing information
@@ -325,7 +323,7 @@ async def test_single_edge_group_tracing_success(span_exporter) -> None:
# Clear any build spans
span_exporter.clear()
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
spans = span_exporter.get_finished_spans()
@@ -361,7 +359,7 @@ async def test_single_edge_group_tracing_condition_failure(span_exporter) -> Non
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x: x.data == "pass")
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="fail")
@@ -370,7 +368,7 @@ async def test_single_edge_group_tracing_condition_failure(span_exporter) -> Non
# Clear any build spans
span_exporter.clear()
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True # Returns True but condition failed
spans = span_exporter.get_finished_spans()
@@ -395,7 +393,7 @@ async def test_single_edge_group_tracing_type_mismatch(span_exporter) -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
# Send incompatible data type
@@ -405,7 +403,7 @@ async def test_single_edge_group_tracing_type_mismatch(span_exporter) -> None:
# Clear any build spans
span_exporter.clear()
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
spans = span_exporter.get_finished_spans()
@@ -430,7 +428,7 @@ async def test_single_edge_group_tracing_target_mismatch(span_exporter) -> None:
edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
@@ -439,7 +437,7 @@ async def test_single_edge_group_tracing_target_mismatch(span_exporter) -> None:
# Clear any build spans
span_exporter.clear()
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
spans = span_exporter.get_finished_spans()
@@ -498,13 +496,13 @@ async def test_source_edge_group_send_message() -> None:
edge_group = FanOutEdgeGroup(source_id=source.id, target_ids=[target1.id, target2.id])
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
assert target1.call_count == 1
@@ -521,13 +519,13 @@ async def test_source_edge_group_send_message_with_target() -> None:
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id, target_id=target1.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
assert target1.call_count == 1
@@ -544,13 +542,13 @@ async def test_source_edge_group_send_message_with_invalid_target() -> None:
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id, target_id="invalid_target")
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -564,13 +562,13 @@ async def test_source_edge_group_send_message_with_invalid_data() -> None:
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = "invalid_data"
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -584,13 +582,13 @@ async def test_source_edge_group_send_message_only_one_successful_send() -> None
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
assert target1.call_count == 1 # target1 can handle MockMessage
@@ -633,14 +631,14 @@ async def test_source_edge_group_with_selection_func_send_message() -> None:
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id)
with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send:
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
@@ -661,14 +659,14 @@ async def test_source_edge_group_with_selection_func_send_message_with_invalid_s
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id)
with pytest.raises(RuntimeError):
await edge_runner.send_message(message, shared_state, ctx)
await edge_runner.send_message(message, state, ctx)
async def test_source_edge_group_with_selection_func_send_message_with_target() -> None:
@@ -686,14 +684,14 @@ async def test_source_edge_group_with_selection_func_send_message_with_target()
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id, target_id=target1.id)
with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send:
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
assert mock_send.call_count == 1
@@ -715,13 +713,13 @@ async def test_source_edge_group_with_selection_func_send_message_with_target_no
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source.id, target_id=target2.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -740,13 +738,13 @@ async def test_source_edge_group_with_selection_func_send_message_with_invalid_d
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = "invalid_data"
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -765,13 +763,13 @@ async def test_source_edge_group_with_selection_func_send_message_with_target_in
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = "invalid_data"
message = Message(data=data, source_id=source.id, target_id=target1.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -785,7 +783,7 @@ async def test_fan_out_edge_group_tracing_success(span_exporter) -> None:
edge_group = FanOutEdgeGroup(source_id=source.id, target_ids=[target1.id, target2.id])
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
# Create trace context and span IDs to simulate a message with tracing information
@@ -798,7 +796,7 @@ async def test_fan_out_edge_group_tracing_success(span_exporter) -> None:
# Clear any build spans
span_exporter.clear()
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
spans = span_exporter.get_finished_spans()
@@ -835,7 +833,7 @@ async def test_fan_out_edge_group_tracing_with_target(span_exporter) -> None:
edge_group = FanOutEdgeGroup(source_id=source.id, target_ids=[target1.id, target2.id])
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
# Create trace context and span IDs to simulate a message with tracing information
@@ -854,7 +852,7 @@ async def test_fan_out_edge_group_tracing_with_target(span_exporter) -> None:
# Clear any build spans
span_exporter.clear()
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
spans = span_exporter.get_finished_spans()
@@ -922,7 +920,7 @@ async def test_target_edge_group_send_message_buffer() -> None:
executors: dict[str, Executor] = {source1.id: source1, source2.id: source2, target.id: target}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
@@ -930,7 +928,7 @@ async def test_target_edge_group_send_message_buffer() -> None:
with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send:
success = await edge_runner.send_message(
Message(data=data, source_id=source1.id),
shared_state,
state,
ctx,
)
@@ -940,7 +938,7 @@ async def test_target_edge_group_send_message_buffer() -> None:
success = await edge_runner.send_message(
Message(data=data, source_id=source2.id),
shared_state,
state,
ctx,
)
assert success is True
@@ -961,13 +959,13 @@ async def test_target_edge_group_send_message_with_invalid_target() -> None:
executors: dict[str, Executor] = {source1.id: source1, source2.id: source2, target.id: target}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
message = Message(data=data, source_id=source1.id, target_id="invalid_target")
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -982,13 +980,13 @@ async def test_target_edge_group_send_message_with_invalid_data() -> None:
executors: dict[str, Executor] = {source1.id: source1, source2.id: source2, target.id: target}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = "invalid_data"
message = Message(data=data, source_id=source1.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -1002,7 +1000,7 @@ async def test_fan_in_edge_group_tracing_buffered(span_exporter) -> None:
edge_group = FanInEdgeGroup(source_ids=[source1.id, source2.id], target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
@@ -1020,7 +1018,7 @@ async def test_fan_in_edge_group_tracing_buffered(span_exporter) -> None:
# Send first message (should be buffered)
success = await edge_runner.send_message(
Message(data=data, source_id=source1.id, trace_contexts=trace_contexts1, source_span_ids=source_span_ids1),
shared_state,
state,
ctx,
)
assert success is True
@@ -1052,7 +1050,7 @@ async def test_fan_in_edge_group_tracing_buffered(span_exporter) -> None:
success = await edge_runner.send_message(
Message(data=data, source_id=source2.id, trace_contexts=trace_contexts2, source_span_ids=source_span_ids2),
shared_state,
state,
ctx,
)
assert success is True
@@ -1090,7 +1088,7 @@ async def test_fan_in_edge_group_tracing_type_mismatch(span_exporter) -> None:
edge_group = FanInEdgeGroup(source_ids=[source1.id, source2.id], target_id=target.id)
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
# Send incompatible data type
@@ -1100,7 +1098,7 @@ async def test_fan_in_edge_group_tracing_type_mismatch(span_exporter) -> None:
# Clear any build spans
span_exporter.clear()
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
spans = span_exporter.get_finished_spans()
@@ -1126,14 +1124,14 @@ async def test_fan_in_edge_group_with_multiple_message_types() -> None:
executors: dict[str, Executor] = {source1.id: source1, source2.id: source2, target.id: target}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
success = await edge_runner.send_message(
Message(data=data, source_id=source1.id),
shared_state,
state,
ctx,
)
assert success
@@ -1141,7 +1139,7 @@ async def test_fan_in_edge_group_with_multiple_message_types() -> None:
data2 = MockMessageSecondary(data="test")
success = await edge_runner.send_message(
Message(data=data2, source_id=source2.id),
shared_state,
state,
ctx,
)
assert success
@@ -1157,14 +1155,14 @@ async def test_fan_in_edge_group_with_multiple_message_types_failed() -> None:
executors: dict[str, Executor] = {source1.id: source1, source2.id: source2, target.id: target}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
success = await edge_runner.send_message(
Message(data=data, source_id=source1.id),
shared_state,
state,
ctx,
)
assert success
@@ -1178,7 +1176,7 @@ async def test_fan_in_edge_group_with_multiple_message_types_failed() -> None:
data2 = MockMessageSecondary(data="test")
_ = await edge_runner.send_message(
Message(data=data2, source_id=source2.id),
shared_state,
state,
ctx,
)
@@ -1273,14 +1271,14 @@ async def test_switch_case_edge_group_send_message() -> None:
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data=-1)
message = Message(data=data, source_id=source.id)
with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send:
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
assert mock_send.call_count == 1
@@ -1289,7 +1287,7 @@ async def test_switch_case_edge_group_send_message() -> None:
data = MockMessage(data=1)
message = Message(data=data, source_id=source.id)
with patch("agent_framework._workflows._edge_runner.EdgeRunner._execute_on_target") as mock_send:
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
assert mock_send.call_count == 1
@@ -1312,13 +1310,13 @@ async def test_switch_case_edge_group_send_message_with_invalid_target() -> None
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data=-1)
message = Message(data=data, source_id=source.id, target_id="invalid_target")
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -1339,18 +1337,18 @@ async def test_switch_case_edge_group_send_message_with_valid_target() -> None:
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = MockMessage(data=1) # Condition will fail
message = Message(data=data, source_id=source.id, target_id=target1.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
data = MockMessage(data=-1) # Condition will pass
message = Message(data=data, source_id=source.id, target_id=target1.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is True
@@ -1371,13 +1369,13 @@ async def test_switch_case_edge_group_send_message_with_invalid_data() -> None:
executors: dict[str, Executor] = {source.id: source, target1.id: target1, target2.id: target2}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
data = "invalid_data"
message = Message(data=data, source_id=source.id)
success = await edge_runner.send_message(message, shared_state, ctx)
success = await edge_runner.send_message(message, state, ctx)
assert success is False
@@ -898,7 +898,7 @@ async def test_magentic_checkpoint_restore_no_duplicate_history():
latest_checkpoint = checkpoints[-1]
# Load checkpoint and verify no duplicates in shared state
# Load checkpoint and verify no duplicates in state
checkpoint_data = await storage.load_checkpoint(latest_checkpoint.checkpoint_id)
assert checkpoint_data is not None
@@ -10,7 +10,7 @@ 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._workflows._events import RequestInfoEvent
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._state import State
@dataclass
@@ -46,7 +46,7 @@ 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(SharedState(), iteration_count=1)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
@@ -79,7 +79,7 @@ async def test_rehydrate_fails_when_request_type_missing() -> None:
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
@@ -107,7 +107,7 @@ async def test_rehydrate_fails_when_request_type_mismatch() -> None:
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
@@ -137,7 +137,7 @@ async def test_pending_requests_in_summary() -> None:
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
@@ -175,7 +175,7 @@ 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(SharedState(), iteration_count=1)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
# Should be JSON serializable despite datetime/slots
@@ -25,7 +25,7 @@ from agent_framework._workflows._runner_context import (
Message,
RunnerContext,
)
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._state import State
@dataclass
@@ -48,7 +48,7 @@ class MockExecutor(Executor):
def test_create_runner():
"""Test creating a runner with edges and shared state."""
"""Test creating a runner with edges and state."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
@@ -63,7 +63,7 @@ def test_create_runner():
executor_b.id: executor_b,
}
runner = Runner(edge_groups, executors, shared_state=SharedState(), ctx=InProcRunnerContext())
runner = Runner(edge_groups, executors, state=State(), ctx=InProcRunnerContext())
assert runner.context is not None and isinstance(runner.context, RunnerContext)
@@ -83,16 +83,16 @@ async def test_runner_run_until_convergence():
executor_a.id: executor_a,
executor_b.id: executor_b,
}
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, shared_state, ctx)
runner = Runner(edges, executors, state, ctx)
result: int | None = None
await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
shared_state, # shared_state
state, # state
ctx, # runner_context
)
async for event in runner.run_until_convergence():
@@ -121,15 +121,15 @@ async def test_runner_run_until_convergence_not_completed():
executor_a.id: executor_a,
executor_b.id: executor_b,
}
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, shared_state, ctx, max_iterations=5)
runner = Runner(edges, executors, state, ctx, max_iterations=5)
await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
shared_state, # shared_state
state, # state
ctx, # runner_context
)
with pytest.raises(
@@ -155,15 +155,15 @@ async def test_runner_already_running():
executor_a.id: executor_a,
executor_b.id: executor_b,
}
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, shared_state, ctx)
runner = Runner(edges, executors, state, ctx)
await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
shared_state, # shared_state
state, # state
ctx, # runner_context
)
@@ -178,7 +178,7 @@ async def test_runner_already_running():
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
ctx = InProcRunnerContext()
runner = Runner([], {}, SharedState(), ctx)
runner = Runner([], {}, State(), ctx)
await ctx.send_message(
Message(
@@ -227,7 +227,7 @@ async def test_runner_cancellation_stops_active_executor():
executor_a.id: executor_a,
executor_b.id: executor_b,
}
shared_state = SharedState()
shared_state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, shared_state, ctx)
@@ -623,7 +623,7 @@ class TestSerializationWorkflowClasses:
# These private runtime fields should not be in the serialized data
assert "_runner_context" not in data
assert "_shared_state" not in data
assert "_state" not in data
assert "_runner" not in data
def test_workflow_name_description_serialization(self) -> None:
@@ -760,7 +760,7 @@ def test_comprehensive_edge_groups_workflow_serialization() -> None:
# Verify that serialization excludes non-serializable fields
assert "_runner_context" not in data
assert "_shared_state" not in data
assert "_state" not in data
assert "_runner" not in data
# Test that we can identify each edge group type by examining their structure
@@ -0,0 +1,303 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for the State class superstep caching behavior."""
import pytest
from agent_framework._workflows._state import State
class TestStateBasicOperations:
"""Tests for basic State get/set/has/delete operations."""
def test_set_and_get(self) -> None:
state = State()
state.set("key", "value")
assert state.get("key") == "value"
def test_get_with_default(self) -> None:
state = State()
assert state.get("missing") is None
assert state.get("missing", "default") == "default"
def test_has_returns_true_for_existing_key(self) -> None:
state = State()
state.set("key", "value")
assert state.has("key") is True
def test_has_returns_false_for_missing_key(self) -> None:
state = State()
assert state.has("missing") is False
def test_delete_existing_key(self) -> None:
state = State()
state.set("key", "value")
state.commit()
state.delete("key")
state.commit()
assert state.has("key") is False
assert state.get("key") is None
def test_delete_missing_key_raises(self) -> None:
state = State()
with pytest.raises(KeyError, match="Key 'missing' not found"):
state.delete("missing")
def test_clear(self) -> None:
state = State()
state.set("key1", "value1")
state.commit()
state.set("key2", "value2")
state.clear()
assert state.get("key1") is None
assert state.get("key2") is None
class TestSuperstepCaching:
"""Tests for superstep caching semantics - pending vs committed state."""
def test_set_writes_to_pending_not_committed(self) -> None:
state = State()
state.set("key", "value")
# Value is in pending
assert "key" in state._pending
# Value is NOT in committed
assert "key" not in state._committed
# But get() still returns it
assert state.get("key") == "value"
def test_commit_moves_pending_to_committed(self) -> None:
state = State()
state.set("key", "value")
# Before commit: in pending, not committed
assert "key" in state._pending
assert "key" not in state._committed
state.commit()
# After commit: in committed, pending cleared
assert "key" not in state._pending
assert "key" in state._committed
assert state.get("key") == "value"
def test_discard_clears_pending_without_committing(self) -> None:
state = State()
state.set("existing", "original")
state.commit()
# Make a pending change
state.set("existing", "modified")
state.set("new_key", "new_value")
# Discard pending changes
state.discard()
# Original value is preserved, new key never committed
assert state.get("existing") == "original"
assert state.get("new_key") is None
def test_pending_overrides_committed_on_get(self) -> None:
state = State()
state.set("key", "committed_value")
state.commit()
state.set("key", "pending_value")
# get() returns pending value, not committed
assert state.get("key") == "pending_value"
# But committed still has old value
assert state._committed["key"] == "committed_value"
def test_multiple_sets_before_commit(self) -> None:
state = State()
state.set("key", "value1")
state.set("key", "value2")
state.set("key", "value3")
# Only final value is in pending
assert state.get("key") == "value3"
state.commit()
assert state.get("key") == "value3"
class TestDeleteWithSuperstepCaching:
"""Tests for delete behavior with superstep caching."""
def test_delete_pending_only_key(self) -> None:
state = State()
state.set("key", "value")
# Key only in pending, not committed
assert "key" in state._pending
assert "key" not in state._committed
state.delete("key")
# Should be removed from pending
assert "key" not in state._pending
assert state.get("key") is None
assert state.has("key") is False
def test_delete_committed_key_marks_for_deletion(self) -> None:
state = State()
state.set("key", "value")
state.commit()
state.delete("key")
# Key should be marked for deletion in pending (sentinel)
assert "key" in state._pending
# get() should return default (not the sentinel!)
assert state.get("key") is None
assert state.get("key", "default") == "default"
# has() should return False
assert state.has("key") is False
# But committed still has it until commit()
assert "key" in state._committed
def test_delete_committed_key_removed_on_commit(self) -> None:
state = State()
state.set("key", "value")
state.commit()
state.delete("key")
state.commit()
# Now it should be gone from committed too
assert "key" not in state._committed
assert "key" not in state._pending
def test_delete_key_in_both_pending_and_committed(self) -> None:
"""Test delete when key exists in both pending (modified) and committed."""
state = State()
state.set("key", "original")
state.commit()
# Modify the key (now in both pending and committed)
state.set("key", "modified")
assert state._pending["key"] == "modified"
assert state._committed["key"] == "original"
# Delete should mark for deletion from committed
state.delete("key")
# Should be marked for deletion
assert state.get("key") is None
assert state.has("key") is False
# After commit, key should be fully removed
state.commit()
assert "key" not in state._committed
assert "key" not in state._pending
def test_discard_after_delete_restores_committed_value(self) -> None:
state = State()
state.set("key", "value")
state.commit()
state.delete("key")
# Key appears deleted
assert state.has("key") is False
state.discard()
# After discard, committed value is restored
assert state.has("key") is True
assert state.get("key") == "value"
class TestFailureScenarios:
"""Tests simulating failure scenarios - pending changes should not leak to committed."""
def test_failure_before_commit_preserves_committed_state(self) -> None:
"""Simulate executor failure - pending changes should not affect committed state."""
state = State()
state.set("key1", "original1")
state.set("key2", "original2")
state.commit()
# Superstep starts - make some changes
state.set("key1", "modified1")
state.set("key3", "new_value")
state.delete("key2")
# Simulate failure - we call discard() instead of commit()
state.discard()
# All original values should be intact
assert state.get("key1") == "original1"
assert state.get("key2") == "original2"
assert state.get("key3") is None
def test_no_partial_commits(self) -> None:
"""Ensure commit is atomic - either all changes apply or none."""
state = State()
state.set("key1", "value1")
state.set("key2", "value2")
state.set("key3", "value3")
# Before commit - nothing in committed
assert len(state._committed) == 0
state.commit()
# After commit - all three values committed together
assert state._committed == {"key1": "value1", "key2": "value2", "key3": "value3"}
def test_repeated_supersteps_are_isolated(self) -> None:
"""Test that each superstep's changes are isolated until committed."""
state = State()
# Superstep 1
state.set("counter", 1)
state.commit()
assert state.get("counter") == 1
# Superstep 2
state.set("counter", 2)
state.set("temp", "should_be_discarded")
state.discard() # Simulate failure
assert state.get("counter") == 1 # Reverted to superstep 1 value
assert state.get("temp") is None
# Superstep 3
state.set("counter", 3)
state.commit()
assert state.get("counter") == 3
class TestExportImport:
"""Tests for state serialization (export/import)."""
def test_export_returns_committed_only(self) -> None:
state = State()
state.set("committed_key", "committed_value")
state.commit()
state.set("pending_key", "pending_value")
exported = state.export_state()
# Only committed state is exported
assert exported == {"committed_key": "committed_value"}
assert "pending_key" not in exported
def test_import_merges_into_committed(self) -> None:
state = State()
state.set("existing", "original")
state.commit()
state.import_state({"imported": "value", "existing": "overwritten"})
assert state.get("imported") == "value"
assert state.get("existing") == "overwritten"
def test_import_does_not_affect_pending(self) -> None:
state = State()
state.set("pending_key", "pending_value")
state.import_state({"imported": "value"})
# Pending is still there
assert state.get("pending_key") == "pending_value"
assert "pending_key" in state._pending
@@ -87,7 +87,7 @@ class MockExecutorRequestApproval(Executor):
@handler
async def mock_handler_a(self, message: NumberMessage, ctx: WorkflowContext) -> None:
"""A mock handler that requests approval."""
await ctx.set_shared_state(self.id, message.data)
ctx.set_state(self.id, message.data)
await ctx.request_info(MockRequest(prompt="Mock approval request"), ApprovalMessage)
@response_handler
@@ -98,7 +98,7 @@ class MockExecutorRequestApproval(Executor):
ctx: WorkflowContext[NumberMessage, int],
) -> None:
"""A mock handler that processes the approval response."""
data = await ctx.get_shared_state(self.id)
data = ctx.get_state(self.id)
assert isinstance(data, int)
if response.approved:
await ctx.yield_output(data)
@@ -368,7 +368,7 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage(
test_checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
messages={},
shared_state={},
state={},
iteration_count=0,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
@@ -403,7 +403,7 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
test_checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
messages={},
shared_state={},
state={},
iteration_count=0,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
@@ -436,7 +436,7 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
test_checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
messages={},
shared_state={},
state={},
pending_request_info_events={
"request_123": RequestInfoEvent(
request_id="request_123",
@@ -480,7 +480,7 @@ class StateTrackingMessage:
class StateTrackingExecutor(Executor):
"""An executor that tracks state in shared state to test context reset behavior."""
"""An executor that tracks state in workflow state to test context reset behavior."""
@handler
async def handle_message(
@@ -488,19 +488,16 @@ class StateTrackingExecutor(Executor):
message: StateTrackingMessage,
ctx: WorkflowContext[StateTrackingMessage, list[str]],
) -> None:
"""Handle the message and track it in shared state."""
# Get existing messages from shared state
try:
existing_messages = await ctx.get_shared_state("processed_messages")
except KeyError:
existing_messages = []
"""Handle the message and track it in workflow state."""
# Get existing messages from workflow state
existing_messages = ctx.get_state("processed_messages") or []
# Record this message
message_record = f"{message.run_id}:{message.data}"
existing_messages.append(message_record) # type: ignore
# Update shared state
await ctx.set_shared_state("processed_messages", existing_messages)
# Update workflow state
ctx.set_state("processed_messages", existing_messages)
# Yield output
await ctx.yield_output(existing_messages.copy()) # type: ignore
@@ -511,7 +508,7 @@ async def test_workflow_multiple_runs_no_state_collision():
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Create executor that tracks state in shared state
# Create executor that tracks state in workflow state
state_executor = StateTrackingExecutor(id="state_executor")
# Build workflow with checkpointing
@@ -41,15 +41,15 @@ async def make_context(
executor_id: str = "exec",
) -> AsyncIterator[tuple[WorkflowContext[object], "InProcRunnerContext"]]:
from agent_framework._workflows._runner_context import InProcRunnerContext
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._state import State
mock_executor = MockExecutor(executor_id)
runner_ctx = InProcRunnerContext()
shared_state = SharedState()
state = State()
workflow_ctx: WorkflowContext[object] = WorkflowContext(
mock_executor,
["source"],
shared_state,
state,
runner_ctx,
)
try:
@@ -208,48 +208,48 @@ async def test_groupchat_kwargs_flow_to_agents() -> None:
# endregion
# region SharedState Verification Tests
# region State Verification Tests
async def test_kwargs_stored_in_shared_state() -> None:
"""Test that kwargs are stored in SharedState with the correct key."""
async def test_kwargs_stored_in_state() -> None:
"""Test that kwargs are stored in State with the correct key."""
from agent_framework import Executor, WorkflowContext, handler
stored_kwargs: dict[str, Any] | None = None
class _SharedStateInspector(Executor):
class _StateInspector(Executor):
@handler
async def inspect(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
nonlocal stored_kwargs
stored_kwargs = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
stored_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY)
await ctx.send_message(msgs)
inspector = _SharedStateInspector(id="inspector")
inspector = _StateInspector(id="inspector")
workflow = SequentialBuilder().participants([inspector]).build()
async for event in workflow.run_stream("test", my_kwarg="my_value", another=123):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
assert stored_kwargs is not None, "kwargs should be stored in SharedState"
assert stored_kwargs is not None, "kwargs should be stored in State"
assert stored_kwargs.get("my_kwarg") == "my_value"
assert stored_kwargs.get("another") == 123
async def test_empty_kwargs_stored_as_empty_dict() -> None:
"""Test that empty kwargs are stored as empty dict in SharedState."""
"""Test that empty kwargs are stored as empty dict in State."""
from agent_framework import Executor, WorkflowContext, handler
stored_kwargs: Any = "NOT_CHECKED"
class _SharedStateChecker(Executor):
class _StateChecker(Executor):
@handler
async def check(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
nonlocal stored_kwargs
stored_kwargs = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
stored_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY)
await ctx.send_message(msgs)
checker = _SharedStateChecker(id="checker")
checker = _StateChecker(id="checker")
workflow = SequentialBuilder().participants([checker]).build()
# Run without any kwargs
@@ -257,7 +257,7 @@ async def test_empty_kwargs_stored_as_empty_dict() -> None:
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# SharedState should have empty dict when no kwargs provided
# State should have empty dict when no kwargs provided
assert stored_kwargs == {}, f"Expected empty dict, got: {stored_kwargs}"
@@ -420,8 +420,8 @@ async def test_magentic_kwargs_flow_to_agents() -> None:
# A more comprehensive integration test would require the manager to select an agent.
async def test_magentic_kwargs_stored_in_shared_state() -> None:
"""Test that kwargs are stored in SharedState when using MagenticWorkflow.run_stream()."""
async def test_magentic_kwargs_stored_in_state() -> None:
"""Test that kwargs are stored in State when using MagenticWorkflow.run_stream()."""
from agent_framework import MagenticBuilder
from agent_framework._workflows._magentic import (
MagenticContext,
@@ -639,10 +639,10 @@ async def test_subworkflow_kwargs_propagation() -> None:
)
async def test_subworkflow_kwargs_accessible_via_shared_state() -> None:
"""Test that kwargs are accessible via SharedState within subworkflow.
async def test_subworkflow_kwargs_accessible_via_state() -> None:
"""Test that kwargs are accessible via State within subworkflow.
Verifies that WORKFLOW_RUN_KWARGS_KEY is populated in the subworkflow's SharedState
Verifies that WORKFLOW_RUN_KWARGS_KEY is populated in the subworkflow's State
with kwargs from the parent workflow.
"""
from agent_framework import Executor, WorkflowContext, handler
@@ -650,17 +650,17 @@ async def test_subworkflow_kwargs_accessible_via_shared_state() -> None:
captured_kwargs_from_state: list[dict[str, Any]] = []
class _SharedStateReader(Executor):
"""Executor that reads kwargs from SharedState for verification."""
class _StateReader(Executor):
"""Executor that reads kwargs from State for verification."""
@handler
async def read_kwargs(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
kwargs_from_state = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
kwargs_from_state = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY)
captured_kwargs_from_state.append(kwargs_from_state or {})
await ctx.send_message(msgs)
# Build inner workflow with SharedState reader
state_reader = _SharedStateReader(id="state_reader")
# Build inner workflow with State reader
state_reader = _StateReader(id="state_reader")
inner_workflow = SequentialBuilder().participants([state_reader]).build()
# Wrap as subworkflow
@@ -679,15 +679,15 @@ async def test_subworkflow_kwargs_accessible_via_shared_state() -> None:
break
# Verify the state reader was invoked
assert len(captured_kwargs_from_state) >= 1, "SharedState reader should have been invoked"
assert len(captured_kwargs_from_state) >= 1, "State reader should have been invoked"
kwargs_in_subworkflow = captured_kwargs_from_state[0]
assert kwargs_in_subworkflow.get("my_custom_kwarg") == "should_be_propagated", (
f"Expected 'my_custom_kwarg' in subworkflow SharedState, got: {kwargs_in_subworkflow}"
f"Expected 'my_custom_kwarg' in subworkflow got: {kwargs_in_subworkflow}"
)
assert kwargs_in_subworkflow.get("another_kwarg") == 42, (
f"Expected 'another_kwarg'=42 in subworkflow SharedState, got: {kwargs_in_subworkflow}"
f"Expected 'another_kwarg'=42 in subworkflow got: {kwargs_in_subworkflow}"
)
@@ -9,7 +9,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder
from agent_framework._workflows._executor import Executor, handler
from agent_framework._workflows._runner_context import InProcRunnerContext, Message, MessageType
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._state import State
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_context import WorkflowContext
from agent_framework.observability import (
@@ -170,7 +170,7 @@ async def test_span_creation_and_attributes(span_exporter: InMemorySpanExporter)
async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> None:
"""Test trace context propagation and handling in messages and executors."""
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
executor = MockExecutor("test-executor")
@@ -180,7 +180,7 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No
workflow_ctx: WorkflowContext[str] = WorkflowContext(
executor,
["source"],
shared_state,
state,
ctx,
trace_contexts=[{"traceparent": "00-12345678901234567890123456789012-1234567890123456-01"}],
source_span_ids=["1234567890123456"],
@@ -202,7 +202,7 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No
await executor.execute(
"test message",
["source"], # source_executor_ids
shared_state, # shared_state
state, # state
ctx, # runner_context
trace_contexts=[{"traceparent": "00-12345678901234567890123456789012-1234567890123456-01"}],
source_span_ids=["1234567890123456"],
@@ -236,13 +236,13 @@ async def test_trace_context_disabled_when_tracing_disabled(
"""Test that no trace context is added when tracing is disabled."""
# Tracing should be disabled by default
executor = MockExecutor("test-executor")
shared_state = SharedState()
state = State()
ctx = InProcRunnerContext()
workflow_ctx: WorkflowContext[str] = WorkflowContext(
executor,
["source"],
shared_state,
state,
ctx,
)
@@ -452,7 +452,7 @@ 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(SharedState(), 0)
checkpoint_id = await ctx.create_checkpoint(State(), 0)
checkpoint = await ctx.load_checkpoint(checkpoint_id)
assert checkpoint is not None
@@ -19,7 +19,7 @@ from agent_framework import (
WorkflowStatusEvent,
handler,
)
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._state import State
class FailingExecutor(Executor):
@@ -62,12 +62,12 @@ async def test_executor_failed_and_workflow_failed_events_streaming():
async def test_executor_failed_event_emitted_on_direct_execute():
failing = FailingExecutor(id="f")
ctx = InProcRunnerContext()
shared_state = SharedState()
state = State()
with pytest.raises(RuntimeError, match="boom"):
await failing.execute(
0,
["START"],
shared_state,
state,
ctx,
)
drained = await ctx.drain_events()