Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c5e6a77
Move runner state management out of Workflow
TaoChenOSU Jun 5, 2026
65522bd
Add reset to workflow
TaoChenOSU Jun 8, 2026
5910a6f
Add reset to hosted workflow
TaoChenOSU Jun 8, 2026
598ad23
Add sample
TaoChenOSU Jun 8, 2026
568afdd
Fix tests and address comments
TaoChenOSU Jun 8, 2026
6eb8547
Remove lifecycle flag
TaoChenOSU Jun 9, 2026
4e623d5
Merge branch 'main' into local-branch-python-add-reset-to-workflow
TaoChenOSU Jun 9, 2026
47012f1
Address comments
TaoChenOSU Jun 9, 2026
0e38311
Fix checkpoint ancestry bug
TaoChenOSU Jun 10, 2026
6534a73
Add create checkpoint to workflow interface
TaoChenOSU Jun 11, 2026
9da8334
Remove reset
TaoChenOSU Jun 11, 2026
ed27241
Add tests
TaoChenOSU Jun 11, 2026
b0d0224
Address comments
TaoChenOSU Jun 11, 2026
96af0cd
Merge branch 'main' into local-branch-python-add-reset-to-workflow
TaoChenOSU Jun 11, 2026
422f7e7
Address comments
TaoChenOSU Jun 11, 2026
d08cec5
Addres race condition when stream is dropped midway
TaoChenOSU Jun 15, 2026
ea052ab
Merge branch 'main' into local-branch-python-add-reset-to-workflow
TaoChenOSU Jun 15, 2026
c7aac7c
Merge branch 'main' into local-branch-python-add-reset-to-workflow
TaoChenOSU Jun 16, 2026
d4cf991
Remove merge error
TaoChenOSU Jun 16, 2026
0aa80f6
Capture initial checkpoint in memory
TaoChenOSU Jun 17, 2026
1e7b9f6
Add runner back to public API for backward comp but with deprecation …
TaoChenOSU Jun 17, 2026
fee3840
Merge branch 'main' into local-branch-python-add-reset-to-workflow
TaoChenOSU Jun 23, 2026
f3f1d4f
fix unit tests
TaoChenOSU Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions python/packages/core/agent_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"""

import importlib.metadata
from typing import Final
from typing import TYPE_CHECKING, Any, Final

try:
_version = importlib.metadata.version(__name__)
Expand Down Expand Up @@ -264,6 +264,7 @@
)
from ._workflows._agent_utils import resolve_agent_id
from ._workflows._checkpoint import (
CheckpointID,
CheckpointStorage,
FileCheckpointStorage,
InMemoryCheckpointStorage,
Expand Down Expand Up @@ -307,7 +308,6 @@
workflow,
)
from ._workflows._request_info_mixin import response_handler
from ._workflows._runner import Runner
from ._workflows._runner_context import (
InProcRunnerContext,
RunnerContext,
Expand Down Expand Up @@ -405,6 +405,7 @@
"ChatResponse",
"ChatResponseUpdate",
"CheckResult",
"CheckpointID",
"CheckpointStorage",
"ClassSkill",
"CompactionProvider",
Expand Down Expand Up @@ -618,3 +619,20 @@
"validate_workflow_graph",
"workflow",
]

if TYPE_CHECKING:
from ._workflows._runner import Runner


def __getattr__(name: str) -> Any:
"""Lazily resolve deprecated public names, emitting a ``DeprecationWarning``.

``Runner`` remains importable from ``agent_framework`` for backward
compatibility but is deprecated and slated for removal from the public API.
"""
if name == "Runner":
from ._workflows._runner import Runner, warn_runner_deprecated

warn_runner_deprecated()
return Runner
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
304 changes: 213 additions & 91 deletions python/packages/core/agent_framework/_workflows/_runner.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -403,12 +403,14 @@ async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoi
def reset_for_new_run(self) -> None:
"""Reset the context for a new workflow run.

This clears messages, events, and resets streaming flag.
Runtime checkpoint storage is NOT cleared here as it's managed at the workflow level.
Clears messages, the pending event queue, the pending request_info
correlation map, and the streaming flag. Runtime checkpoint storage is
NOT cleared here as it's managed at the workflow level.
"""
self._messages.clear()
# Clear any pending events (best-effort) by recreating the queue
self._event_queue = asyncio.Queue()
self._pending_request_info_events.clear()
Comment thread
TaoChenOSU marked this conversation as resolved.
self._streaming = False # Reset streaming flag

async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
Expand Down
234 changes: 154 additions & 80 deletions python/packages/core/agent_framework/_workflows/_workflow.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,10 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
self._execution_contexts = execution_contexts
self._request_to_execution = request_to_execution

# Reset the sub workflow to its initial state. This must be done before pumping
# the request info events back into the sub workflow.
await self.workflow.reset()

# Add the `request_info_event`s back to the sub workflow.
# This is only a temporary solution to rehydrate the sub workflow with the requests.
# The proper way would be to rehydrate the workflow from a checkpoint on a Workflow
Expand Down
91 changes: 91 additions & 0 deletions python/packages/core/tests/workflow/test_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,97 @@ async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
)


async def test_workflow_checkpoint_ancestry_preserved_after_resume():
"""Resuming from a checkpoint must preserve ancestry: future checkpoints chain back to the resumed one."""
from typing_extensions import Never

from agent_framework import WorkflowBuilder, WorkflowContext, handler
from agent_framework._workflows._executor import Executor

class StartExecutor(Executor):
@handler
async def run(self, message: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(message, target_id="middle")

class MiddleExecutor(Executor):
@handler
async def process(self, message: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(message + "-processed", target_id="finish")

class FinishExecutor(Executor):
@handler
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(message + "-done")

storage = InMemoryCheckpointStorage()

def _build_workflow() -> Any:
start = StartExecutor(id="start")
middle = MiddleExecutor(id="middle")
finish = FinishExecutor(id="finish")
return (
WorkflowBuilder(
name="resume-ancestry-test",
max_iterations=10,
start_executor=start,
checkpoint_storage=storage,
)
.add_edge(start, middle)
.add_edge(middle, finish)
.build()
)

# First run: produce an initial chain of checkpoints
workflow = _build_workflow()
workflow_name = workflow.name
_ = [event async for event in workflow.run("hello", stream=True)]

initial_checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow_name), key=lambda c: c.timestamp)
assert len(initial_checkpoints) >= 3, (
f"Need at least 3 initial checkpoints to pick a middle one, got {len(initial_checkpoints)}"
)
initial_ids = {cp.checkpoint_id for cp in initial_checkpoints}

# Pick an intermediate checkpoint to resume from (not the first, not the last)
resume_from = initial_checkpoints[len(initial_checkpoints) // 2]

# Resume on a fresh workflow instance (same graph signature) and run to completion
resumed_workflow = _build_workflow()
assert resumed_workflow.name == workflow_name
_ = [event async for event in resumed_workflow.run(checkpoint_id=resume_from.checkpoint_id, stream=True)]

# Inspect new checkpoints created after resuming
all_checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow_name), key=lambda c: c.timestamp)
new_checkpoints = [cp for cp in all_checkpoints if cp.checkpoint_id not in initial_ids]
assert new_checkpoints, "Resuming from an intermediate checkpoint should produce new checkpoints"

# The very first checkpoint created after resuming must chain back to the resumed checkpoint
assert new_checkpoints[0].previous_checkpoint_id == resume_from.checkpoint_id, (
"First post-resume checkpoint must chain to the checkpoint that was resumed from; "
f"got previous_checkpoint_id={new_checkpoints[0].previous_checkpoint_id!r}, "
f"expected {resume_from.checkpoint_id!r}"
)

# Subsequent post-resume checkpoints must continue chaining
for i in range(1, len(new_checkpoints)):
assert new_checkpoints[i].previous_checkpoint_id == new_checkpoints[i - 1].checkpoint_id, (
f"Post-resume checkpoint {i} should chain to checkpoint {i - 1}"
)

# Walking the chain backwards from the most recent checkpoint must reach the original root
# without breaks (i.e. the full ancestry across the resume boundary is intact).
checkpoints_by_id = {cp.checkpoint_id: cp for cp in all_checkpoints}
chain: list[str] = []
cursor: str | None = new_checkpoints[-1].checkpoint_id
while cursor is not None:
chain.append(cursor)
cursor = checkpoints_by_id[cursor].previous_checkpoint_id
# Chain must include the resumed-from checkpoint and terminate at the original root
assert resume_from.checkpoint_id in chain
assert chain[-1] == initial_checkpoints[0].checkpoint_id
assert checkpoints_by_id[chain[-1]].previous_checkpoint_id is None


async def test_memory_checkpoint_storage_roundtrip_json_native_types():
"""Test that JSON-native types (str, int, float, bool, None) roundtrip correctly."""
storage = InMemoryCheckpointStorage()
Expand Down
157 changes: 132 additions & 25 deletions python/packages/core/tests/workflow/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
WorkflowRunnerException,
WorkflowRunState,
handler,
)
Expand Down Expand Up @@ -305,40 +304,62 @@ async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, i
assert probe_target.call_count == 1


async def test_runner_already_running():
"""Test that running the runner while it is already running raises an error."""
async def test_runner_run_until_convergence_runs_sequentially():
"""run_until_convergence can be invoked back-to-back on the same Runner.

The Runner itself does not enforce concurrency; that responsibility lives on
:class:`Workflow`. This test simply confirms the Runner is reusable across
sequential runs.
"""
runner = _make_runner()
async for _ in runner.run_until_convergence():
pass
async for _ in runner.run_until_convergence():
pass


def _make_runner() -> Runner:
"""Build a minimal runner for runner-level tests."""
return Runner(
[],
{},
State(),
InProcRunnerContext(),
"test_name",
graph_signature_hash="test_hash",
)


async def test_runner_accepts_new_run_after_previous_failure():
"""A failed run must not leave the Runner unable to start a new run.

After the first run raises, ``run_until_convergence()`` must be callable
again and not surface any lifecycle-related rejection.
"""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")

# Create a loop
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]

executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
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", max_iterations=2)

runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")

await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
state, # state
ctx, # runner_context
)
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)

with pytest.raises(WorkflowRunnerException, match="Runner is already running."):

async def _run():
async for _ in runner.run_until_convergence():
pass
with pytest.raises(WorkflowConvergenceException):
async for _ in runner.run_until_convergence():
pass

await asyncio.gather(_run(), _run())
# A second run on the same Runner must not be blocked by stale lifecycle
# state from the failed run.
try:
async for _ in runner.run_until_convergence():
pass
except Exception as exc:
assert "Runner is already running" not in str(exc), "Runner stayed locked after a failed run"


async def test_runner_emits_runner_completion_for_agent_response_without_targets():
Expand Down Expand Up @@ -862,7 +883,13 @@ async def test_runner_checkpoint_with_resumed_flag():
state = State()

runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
runner._mark_resumed(5) # pyright: ignore[reportPrivateUsage]
resumed_checkpoint = WorkflowCheckpoint(
checkpoint_id="resumed-cp",
workflow_name="test_name",
graph_signature_hash="test_hash",
iteration_count=5,
)
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]

# Add a message to trigger the checkpoint creation path
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START"))
Expand All @@ -882,6 +909,86 @@ async def test_runner_checkpoint_with_resumed_flag():
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]


async def test_runner_mark_resumed_sets_previous_checkpoint_id():
"""_mark_resumed must populate _previous_checkpoint_id so future checkpoints chain back to the resume point."""
runner = Runner(
[],
{},
State(),
InProcRunnerContext(),
"test_name",
graph_signature_hash="test_hash",
)

# Pre-condition: nothing to chain back to
assert runner._previous_checkpoint_id is None # pyright: ignore[reportPrivateUsage]

resumed_checkpoint = WorkflowCheckpoint(
checkpoint_id="resumed-cp-id",
workflow_name="test_name",
graph_signature_hash="test_hash",
iteration_count=3,
)
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]

assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
assert runner._iteration == 3 # pyright: ignore[reportPrivateUsage]
assert runner._previous_checkpoint_id == "resumed-cp-id" # pyright: ignore[reportPrivateUsage]


async def test_runner_post_resume_checkpoint_chains_to_resumed_checkpoint():
"""After resuming, the next checkpoint created must reference the resumed checkpoint as its parent."""
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")

# Simulate having resumed from a prior checkpoint
resumed_checkpoint = WorkflowCheckpoint(
checkpoint_id="parent-checkpoint-id",
workflow_name="test_name",
graph_signature_hash="test_hash",
iteration_count=1,
)
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]

# Seed a message so the runner has work to do (and creates checkpoints at superstep boundaries)
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=executor_a.id))

async for _ in runner.run_until_convergence():
pass

# Find the first checkpoint created after the resume point (across all workflows tracked by storage)
new_checkpoints = sorted(
await storage.list_checkpoints(workflow_name="test_name"),
key=lambda c: c.timestamp,
)
assert new_checkpoints, "Resuming and running should produce at least one new checkpoint"

# The first new checkpoint must chain to the resumed-from checkpoint, not to None
assert new_checkpoints[0].previous_checkpoint_id == "parent-checkpoint-id", (
"First post-resume checkpoint must chain to the resumed checkpoint id; "
f"got {new_checkpoints[0].previous_checkpoint_id!r}"
)

# Subsequent post-resume checkpoints continue the chain
for i in range(1, len(new_checkpoints)):
assert new_checkpoints[i].previous_checkpoint_id == new_checkpoints[i - 1].checkpoint_id


class ExecutorThatFailsWithEvents(Executor):
"""An executor that emits events and then raises an exception after receiving messages."""

Expand Down
Loading
Loading