test
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
Python - Merge - Tests / paths-filter (push) Has been cancelled
Python - Merge - Tests / Python Tests - Core (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / Python Tests - Azure AI (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / python-integration-tests-check (push) Has been cancelled
Python - Lab Tests / paths-filter (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.14) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.14) (push) Has been cancelled
Check .md links / markdown-link-check (push) Has been cancelled

This commit is contained in:
2026-01-24 03:05:12 +11:00
parent f78f2388b3
commit 539852f81c
2584 changed files with 287471 additions and 0 deletions

View File

@@ -0,0 +1,351 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from pathlib import Path
from typing import Any, override
# NOTE: the Azure client imports above are real dependencies. When running this
# sample outside of Azure-enabled environments you may wish to swap in the
# `agent_framework.builtin` chat client or mock the writer executor. We keep the
# concrete import here so readers can see an end-to-end configuration.
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
ChatMessage,
Executor,
FileCheckpointStorage,
RequestInfoEvent,
Role,
Workflow,
WorkflowBuilder,
WorkflowCheckpoint,
WorkflowContext,
WorkflowOutputEvent,
WorkflowStatusEvent,
get_checkpoint_summary,
handler,
response_handler,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""
Sample: Checkpoint + human-in-the-loop quickstart.
This getting-started sample keeps the moving pieces to a minimum:
1. A brief is turned into a consistent prompt for an AI copywriter.
2. The copywriter (an `AgentExecutor`) drafts release notes.
3. A reviewer gateway sends a request for approval for every draft.
4. The workflow records checkpoints between each superstep so you can stop the
program, restart later, and optionally pre-supply human answers on resume.
Key concepts demonstrated
-------------------------
- Minimal executor pipeline with checkpoint persistence.
- Human-in-the-loop pause/resume with checkpoint restoration.
Typical pause/resume flow
-------------------------
1. Run the workflow until a human approval request is emitted.
2. If the human is offline, exit the program. A checkpoint with
``status=awaiting human response`` now exists.
3. Later, restart the script, select that checkpoint, and provide the stored
human decision when prompted to pre-supply responses.
Doing so applies the answer immediately on resume, so the system does **not**
re-emit the same `RequestInfoEvent`.
"""
# Directory used for the sample's temporary checkpoint files. We isolate the
# demo artefacts so that repeated runs do not collide with other samples and so
# the clean-up step at the end of the script can simply delete the directory.
TEMP_DIR = Path(__file__).with_suffix("").parent / "tmp" / "checkpoints_hitl"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
class BriefPreparer(Executor):
"""Normalises the user brief and sends a single AgentExecutorRequest."""
# The first executor in the workflow. By keeping it tiny we make it easier
# to reason about the state that will later be captured in the checkpoint.
# It is responsible for tidying the human-provided brief and kicking off the
# agent run with a deterministic prompt structure.
def __init__(self, id: str, agent_id: str) -> None:
super().__init__(id=id)
self._agent_id = agent_id
@handler
async def prepare(self, brief: str, ctx: WorkflowContext[AgentExecutorRequest, str]) -> None:
# Collapse errant whitespace so the prompt is stable between runs.
normalized = " ".join(brief.split()).strip()
if not normalized.endswith("."):
normalized += "."
# Persist the cleaned brief in shared state so downstream executors and
# future checkpoints can recover the original intent.
await ctx.set_shared_state("brief", normalized)
prompt = (
"You are drafting product release notes. Summarise the brief below in two sentences. "
"Keep it positive and end with a call to action.\n\n"
f"BRIEF: {normalized}"
)
# Hand the prompt to the writer agent. We always route through the
# workflow context so the runtime can capture messages for checkpointing.
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True),
target_id=self._agent_id,
)
@dataclass
class HumanApprovalRequest:
"""Request sent to the human reviewer."""
# These fields are intentionally simple because they are serialised into
# checkpoints. Keeping them primitive types guarantees the new
# `pending_requests_from_checkpoint` helper can reconstruct them on resume.
prompt: str = ""
draft: str = ""
iteration: int = 0
class ReviewGateway(Executor):
"""Routes agent drafts to humans and optionally back for revisions."""
def __init__(self, id: str, writer_id: str) -> None:
super().__init__(id=id)
self._writer_id = writer_id
self._iteration = 0
@handler
async def on_agent_response(self, response: AgentExecutorResponse, ctx: WorkflowContext) -> None:
# Capture the agent output so we can surface it to the reviewer and persist iterations.
self._iteration += 1
# Emit a human approval request.
await ctx.request_info(
request_data=HumanApprovalRequest(
prompt="Review the draft. Reply 'approve' or provide edit instructions.",
draft=response.agent_response.text,
iteration=self._iteration,
),
response_type=str,
)
@response_handler
async def on_human_feedback(
self,
original_request: HumanApprovalRequest,
feedback: str,
ctx: WorkflowContext[AgentExecutorRequest | str, str],
) -> None:
# The `original_request` is the request we sent earlier that is now being answered.
reply = feedback.strip()
if len(reply) == 0 or reply.lower() == "approve":
# Workflow is completed when the human approves.
await ctx.yield_output(original_request.draft)
return
# Any other response loops us back to the writer with fresh guidance.
prompt = (
"Revise the launch note. Respond with the new copy only.\n\n"
f"Previous draft:\n{original_request.draft}\n\n"
f"Human guidance: {reply}"
)
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True),
target_id=self._writer_id,
)
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
# Save the current iteration count in executor state for checkpointing.
return {"iteration": self._iteration}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
# Restore the iteration count from executor state during checkpoint recovery.
self._iteration = state.get("iteration", 0)
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
"""Assemble the workflow graph used by both the initial run and resume."""
# Wire the workflow DAG. Edges mirror the numbered steps described in the
# module docstring. Because `WorkflowBuilder` is declarative, reading these
# edges is often the quickest way to understand execution order.
workflow_builder = (
WorkflowBuilder(max_iterations=6)
.register_agent(
lambda: AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions="Write concise, warm release notes that sound human and helpful.",
# The agent name is stable across runs which keeps checkpoints deterministic.
name="writer",
),
name="writer",
)
.register_executor(lambda: ReviewGateway(id="review_gateway", writer_id="writer"), name="review_gateway")
.register_executor(lambda: BriefPreparer(id="prepare_brief", agent_id="writer"), name="prepare_brief")
.set_start_executor("prepare_brief")
.add_edge("prepare_brief", "writer")
.add_edge("writer", "review_gateway")
.add_edge("review_gateway", "writer") # revisions loop
.with_checkpointing(checkpoint_storage=checkpoint_storage)
)
return workflow_builder.build()
def render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
"""Pretty-print saved checkpoints with the new framework summaries."""
print("\nCheckpoint summary:")
for summary in [get_checkpoint_summary(cp) for cp in sorted(checkpoints, key=lambda c: c.timestamp)]:
# Compose a single line per checkpoint so the user can scan the output
# and pick the resume point that still has outstanding human work.
line = (
f"- {summary.checkpoint_id} | timestamp={summary.timestamp} | iter={summary.iteration_count} "
f"| targets={summary.targets} | states={summary.executor_ids}"
)
if summary.status:
line += f" | status={summary.status}"
if summary.pending_request_info_events:
line += f" | pending_request_id={summary.pending_request_info_events[0].request_id}"
print(line)
def prompt_for_responses(requests: dict[str, HumanApprovalRequest]) -> dict[str, str]:
"""Interactive CLI prompt for any live RequestInfo requests."""
responses: dict[str, str] = {}
for request_id, request in requests.items():
print("\n=== Human approval needed ===")
print(f"request_id: {request_id}")
print(f"Iteration: {request.iteration}")
print(request.prompt)
print("Draft: \n---\n" + request.draft + "\n---")
response = input("Type 'approve' or enter revision guidance (or 'exit' to quit): ").strip()
if response.lower() == "exit":
raise SystemExit("Stopped by user.")
responses[request_id] = response
return responses
async def run_interactive_session(
workflow: Workflow,
initial_message: str | None = None,
checkpoint_id: str | None = None,
) -> str:
"""Run the workflow until it either finishes or pauses for human input."""
requests: dict[str, HumanApprovalRequest] = {}
responses: dict[str, str] | None = None
completed_output: str | None = None
while True:
if responses:
event_stream = workflow.send_responses_streaming(responses)
requests.clear()
responses = None
else:
if initial_message:
print(f"\nStarting workflow with brief: {initial_message}\n")
event_stream = workflow.run_stream(message=initial_message)
elif checkpoint_id:
print("\nStarting workflow from checkpoint...\n")
event_stream = workflow.run_stream(checkpoint_id=checkpoint_id)
else:
raise ValueError("Either initial_message or checkpoint_id must be provided")
async for event in event_stream:
if isinstance(event, WorkflowStatusEvent):
print(event)
if isinstance(event, WorkflowOutputEvent):
completed_output = event.data
if isinstance(event, RequestInfoEvent):
if isinstance(event.data, HumanApprovalRequest):
requests[event.request_id] = event.data
else:
raise ValueError("Unexpected request data type")
if completed_output:
break
if requests:
responses = prompt_for_responses(requests)
continue
raise RuntimeError("Workflow stopped without completing or requesting input")
return completed_output
async def main() -> None:
"""Entry point used by both the initial run and subsequent resumes."""
for file in TEMP_DIR.glob("*.json"):
# Start each execution with a clean slate so the demonstration is
# deterministic even if the directory had stale checkpoints.
file.unlink()
storage = FileCheckpointStorage(storage_path=TEMP_DIR)
workflow = create_workflow(checkpoint_storage=storage)
brief = (
"Introduce our limited edition smart coffee grinder. Mention the $249 price, highlight the "
"sensor that auto-adjusts the grind, and invite customers to pre-order on the website."
)
print("Running workflow (human approval required)...")
result = await run_interactive_session(workflow, initial_message=brief)
print(f"Workflow completed with: {result}")
checkpoints = await storage.list_checkpoints()
if not checkpoints:
print("No checkpoints recorded.")
return
# Show the user what is available before we prompt for the index. The
# summary helper keeps this output consistent with other tooling.
render_checkpoint_summary(checkpoints)
sorted_cps = sorted(checkpoints, key=lambda c: c.timestamp)
print("\nAvailable checkpoints:")
for idx, cp in enumerate(sorted_cps):
print(f" [{idx}] id={cp.checkpoint_id} iter={cp.iteration_count}")
# For the pause/resume demo we typically pick the latest checkpoint whose summary
# status reads "awaiting human response" - that is the saved state that proves the
# workflow can rehydrate, collect the pending answer, and continue after a break.
selection = input("\nResume from which checkpoint? (press Enter to skip): ").strip() # noqa: ASYNC250
if not selection:
print("No resume selected. Exiting.")
return
try:
idx = int(selection)
except ValueError:
print("Invalid input; exiting.")
return
if not 0 <= idx < len(sorted_cps):
print("Index out of range; exiting.")
return
chosen = sorted_cps[idx]
summary = get_checkpoint_summary(chosen)
if summary.status == "completed":
print("Selected checkpoint already reflects a completed workflow; nothing to resume.")
return
new_workflow = create_workflow(checkpoint_storage=storage)
# Resume with a fresh workflow instance. The checkpoint carries the
# persistent state while this object holds the runtime wiring.
result = await run_interactive_session(new_workflow, checkpoint_id=chosen.checkpoint_id)
print(f"Workflow completed with: {result}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,156 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Sample: Checkpointing and Resuming a Workflow
Purpose:
This sample shows how to enable checkpointing for a long-running workflow
that can be paused and resumed.
What you learn:
- How to configure checkpointing storage (InMemoryCheckpointStorage for testing)
- How to resume a workflow from a checkpoint after interruption
- How to implement executor state management with checkpoint hooks
- How to handle workflow interruptions and automatic recovery
Pipeline:
This sample shows a workflow that computes factor pairs for numbers up to a given limit:
1) A start executor that receives the upper limit and creates the initial task
2) A worker executor that processes each number to find its factor pairs
3) The worker uses checkpoint hooks to save/restore its internal state
Prerequisites:
- Basic understanding of workflow concepts, including executors, edges, events, etc.
"""
import asyncio
from dataclasses import dataclass
from random import random
from typing import Any, override
from agent_framework import (
Executor,
InMemoryCheckpointStorage,
SuperStepCompletedEvent,
WorkflowBuilder,
WorkflowCheckpoint,
WorkflowContext,
WorkflowOutputEvent,
handler,
)
@dataclass
class ComputeTask:
"""Task containing the list of numbers remaining to be processed."""
remaining_numbers: list[int]
class StartExecutor(Executor):
"""Initiates the workflow by providing the upper limit for factor pair computation."""
@handler
async def start(self, upper_limit: int, ctx: WorkflowContext[ComputeTask]) -> None:
"""Start the workflow with a list of numbers to process."""
print(f"StartExecutor: Starting factor pair computation up to {upper_limit}")
await ctx.send_message(ComputeTask(remaining_numbers=list(range(1, upper_limit + 1))))
class WorkerExecutor(Executor):
"""Processes numbers to compute their factor pairs and manages executor state for checkpointing."""
def __init__(self, id: str) -> None:
super().__init__(id=id)
self._composite_number_pairs: dict[int, list[tuple[int, int]]] = {}
@handler
async def compute(
self,
task: ComputeTask,
ctx: WorkflowContext[ComputeTask, dict[int, list[tuple[int, int]]]],
) -> None:
"""Process the next number in the task, computing its factor pairs."""
next_number = task.remaining_numbers.pop(0)
print(f"WorkerExecutor: Computing factor pairs for {next_number}")
pairs: list[tuple[int, int]] = []
for i in range(1, next_number):
if next_number % i == 0:
pairs.append((i, next_number // i))
self._composite_number_pairs[next_number] = pairs
if not task.remaining_numbers:
# All numbers processed - output the results
await ctx.yield_output(self._composite_number_pairs)
else:
# More numbers to process - continue with remaining task
await ctx.send_message(task)
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
"""Save the executor's internal state for checkpointing."""
return {"composite_number_pairs": self._composite_number_pairs}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
"""Restore the executor's internal state from a checkpoint."""
self._composite_number_pairs = state.get("composite_number_pairs", {})
async def main():
# Build workflow with checkpointing enabled
workflow_builder = (
WorkflowBuilder()
.register_executor(lambda: StartExecutor(id="start"), name="start")
.register_executor(lambda: WorkerExecutor(id="worker"), name="worker")
.set_start_executor("start")
.add_edge("start", "worker")
.add_edge("worker", "worker") # Self-loop for iterative processing
)
checkpoint_storage = InMemoryCheckpointStorage()
workflow_builder = workflow_builder.with_checkpointing(checkpoint_storage=checkpoint_storage)
# Run workflow with automatic checkpoint recovery
latest_checkpoint: WorkflowCheckpoint | None = None
while True:
workflow = workflow_builder.build()
# Start from checkpoint or fresh execution
print(f"\n** Workflow {workflow.id} started **")
event_stream = (
workflow.run_stream(message=10)
if latest_checkpoint is None
else workflow.run_stream(checkpoint_id=latest_checkpoint.checkpoint_id)
)
output: str | None = None
async for event in event_stream:
if isinstance(event, WorkflowOutputEvent):
output = event.data
break
if isinstance(event, SuperStepCompletedEvent) and random() < 0.5:
# Randomly simulate system interruptions
# The `SuperStepCompletedEvent` ensures we only interrupt after
# the current super-step is fully complete and checkpointed.
# If we interrupt mid-step, the workflow may resume from an earlier point.
print("\n** Simulating workflow interruption. Stopping execution. **")
break
# Find the latest checkpoint to resume from
all_checkpoints = await checkpoint_storage.list_checkpoints()
if not all_checkpoints:
raise RuntimeError("No checkpoints available to resume from.")
latest_checkpoint = all_checkpoints[-1]
print(
f"Checkpoint {latest_checkpoint.checkpoint_id}: "
f"(iter={latest_checkpoint.iteration_count}, messages={latest_checkpoint.messages})"
)
if output is not None:
print(f"\nWorkflow completed successfully with output: {output}")
break
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,398 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import logging
from pathlib import Path
from typing import cast
from agent_framework import (
ChatAgent,
ChatMessage,
FileCheckpointStorage,
FunctionApprovalRequestContent,
HandoffBuilder,
HandoffUserInputRequest,
RequestInfoEvent,
Workflow,
WorkflowOutputEvent,
WorkflowStatusEvent,
ai_function,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""
Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume
Demonstrates the two-step pattern for resuming a handoff workflow from a checkpoint
while handling both HandoffUserInputRequest prompts and FunctionApprovalRequestContent
for tool calls (e.g., submit_refund).
Scenario:
1. User starts a conversation with the workflow.
2. Agents may emit user input requests or tool approval requests.
3. Workflow writes a checkpoint capturing pending requests and pauses.
4. Process can exit/restart.
5. On resume: Load the checkpoint, surface pending approvals/user prompts, and provide responses.
6. Workflow continues from the saved state.
Pattern:
- Step 1: workflow.run_stream(checkpoint_id=...) to restore checkpoint and pending requests.
- Step 2: workflow.send_responses_streaming(responses) to supply human replies and approvals.
- Two-step approach is required because send_responses_streaming does not accept checkpoint_id.
Prerequisites:
- Azure CLI authentication (az login).
- Environment variables configured for AzureOpenAIChatClient.
"""
CHECKPOINT_DIR = Path(__file__).parent / "tmp" / "handoff_checkpoints"
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
@ai_function(approval_mode="always_require")
def submit_refund(refund_description: str, amount: str, order_id: str) -> str:
"""Capture a refund request for manual review before processing."""
return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}"
def create_agents(client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]:
"""Create a simple handoff scenario: triage, refund, and order specialists."""
triage = client.as_agent(
name="triage_agent",
instructions=(
"You are a customer service triage agent. Listen to customer issues and determine "
"if they need refund help or order tracking. Use handoff_to_refund_agent or "
"handoff_to_order_agent to transfer them."
),
)
refund = client.as_agent(
name="refund_agent",
instructions=(
"You are a refund specialist. Help customers with refund requests. "
"Be empathetic and ask for order numbers if not provided. "
"When the user confirms they want a refund and supplies order details, call submit_refund "
"to record the request before continuing."
),
tools=[submit_refund],
)
order = client.as_agent(
name="order_agent",
instructions=(
"You are an order tracking specialist. Help customers track their orders. "
"Ask for order numbers and provide shipping updates."
),
)
return triage, refund, order
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> tuple[Workflow, ChatAgent, ChatAgent, ChatAgent]:
"""Build the handoff workflow with checkpointing enabled."""
client = AzureOpenAIChatClient(credential=AzureCliCredential())
triage, refund, order = create_agents(client)
workflow = (
HandoffBuilder(
name="checkpoint_handoff_demo",
participants=[triage, refund, order],
)
.set_coordinator("triage_agent")
.with_checkpointing(checkpoint_storage)
.with_termination_condition(
# Terminate after 5 user messages for this demo
lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 5
)
.build()
)
return workflow, triage, refund, order
def _print_handoff_request(request: HandoffUserInputRequest, request_id: str) -> None:
"""Log pending handoff request details for debugging."""
print(f"\n{'=' * 60}")
print("WORKFLOW PAUSED - User input needed")
print(f"Request ID: {request_id}")
print(f"Awaiting agent: {request.awaiting_agent_id}")
print(f"Prompt: {request.prompt}")
# Note: After checkpoint restore, conversation may be empty because it's not serialized
# to prevent duplication (the conversation is preserved in the coordinator's state).
# See issue #2667.
if request.conversation:
print("\nConversation so far:")
for msg in request.conversation[-3:]:
author = msg.author_name or msg.role.value
snippet = msg.text[:120] + "..." if len(msg.text) > 120 else msg.text
print(f" {author}: {snippet}")
else:
print("\n(Conversation restored from checkpoint - context preserved in workflow state)")
print(f"{'=' * 60}\n")
def _print_function_approval_request(request: FunctionApprovalRequestContent, request_id: str) -> None:
"""Log pending tool approval details for debugging."""
args = request.function_call.parse_arguments() or {}
print(f"\n{'=' * 60}")
print("WORKFLOW PAUSED - Tool approval required")
print(f"Request ID: {request_id}")
print(f"Function: {request.function_call.name}")
print(f"Arguments:\n{json.dumps(args, indent=2)}")
print(f"{'=' * 60}\n")
def _build_responses_for_requests(
pending_requests: list[RequestInfoEvent],
*,
user_response: str | None,
approve_tools: bool | None,
) -> dict[str, object]:
"""Create response payloads for each pending request."""
responses: dict[str, object] = {}
for request in pending_requests:
if isinstance(request.data, HandoffUserInputRequest):
if user_response is None:
raise ValueError("User response is required for HandoffUserInputRequest")
responses[request.request_id] = user_response
elif isinstance(request.data, FunctionApprovalRequestContent):
if approve_tools is None:
raise ValueError("Approval decision is required for FunctionApprovalRequestContent")
responses[request.request_id] = request.data.create_response(approved=approve_tools)
else:
raise ValueError(f"Unsupported request type: {type(request.data)}")
return responses
async def run_until_user_input_needed(
workflow: Workflow,
initial_message: str | None = None,
checkpoint_id: str | None = None,
) -> tuple[list[RequestInfoEvent], str | None]:
"""
Run the workflow until it needs user input or approval, or completes.
Returns:
Tuple of (pending_requests, checkpoint_id_to_use_for_resume)
"""
pending_requests: list[RequestInfoEvent] = []
latest_checkpoint_id: str | None = checkpoint_id
if initial_message:
print(f"\nStarting workflow with: {initial_message}\n")
event_stream = workflow.run_stream(message=initial_message) # type: ignore[attr-defined]
elif checkpoint_id:
print(f"\nResuming workflow from checkpoint: {checkpoint_id}\n")
event_stream = workflow.run_stream(checkpoint_id=checkpoint_id) # type: ignore[attr-defined]
else:
raise ValueError("Must provide either initial_message or checkpoint_id")
async for event in event_stream:
if isinstance(event, WorkflowStatusEvent):
print(f"[Status] {event.state}")
elif isinstance(event, RequestInfoEvent):
pending_requests.append(event)
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, FunctionApprovalRequestContent):
_print_function_approval_request(event.data, event.request_id)
elif isinstance(event, WorkflowOutputEvent):
print("\n[Workflow Completed]")
if event.data:
print(f"Final conversation length: {len(event.data)} messages")
return [], None
# Workflow paused with pending requests
# The latest checkpoint was created at the end of the last superstep
# We'll use the checkpoint storage to find it
return pending_requests, latest_checkpoint_id
async def resume_with_responses(
workflow: Workflow,
checkpoint_storage: FileCheckpointStorage,
user_response: str | None = None,
approve_tools: bool | None = None,
) -> tuple[list[RequestInfoEvent], str | None]:
"""
Two-step resume pattern (answers customer questions and tool approvals):
Step 1: Restore checkpoint to load pending requests into workflow state
Step 2: Send user responses using send_responses_streaming
This is the current pattern required because send_responses_streaming
doesn't accept a checkpoint_id parameter.
"""
print(f"\n{'=' * 60}")
print("RESUMING WORKFLOW WITH HUMAN INPUT")
if user_response is not None:
print(f"User says: {user_response}")
if approve_tools is not None:
print(f"Approve tools: {approve_tools}")
print(f"{'=' * 60}\n")
# Get the latest checkpoint
checkpoints = await checkpoint_storage.list_checkpoints()
if not checkpoints:
raise RuntimeError("No checkpoints found to resume from")
# Sort by timestamp to get latest
checkpoints.sort(key=lambda cp: cp.timestamp, reverse=True)
latest_checkpoint = checkpoints[0]
print(f"Step 1: Restoring checkpoint {latest_checkpoint.checkpoint_id}")
# Step 1: Restore the checkpoint to load pending requests into memory
# The checkpoint restoration re-emits pending RequestInfoEvents
restored_requests: list[RequestInfoEvent] = []
async for event in workflow.run_stream(checkpoint_id=latest_checkpoint.checkpoint_id): # type: ignore[attr-defined]
if isinstance(event, RequestInfoEvent):
restored_requests.append(event)
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, FunctionApprovalRequestContent):
_print_function_approval_request(event.data, event.request_id)
if not restored_requests:
raise RuntimeError("No pending requests found after checkpoint restoration")
responses = _build_responses_for_requests(
restored_requests,
user_response=user_response,
approve_tools=approve_tools,
)
print(f"Step 2: Sending responses for {len(responses)} request(s)")
new_pending_requests: list[RequestInfoEvent] = []
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, WorkflowStatusEvent):
print(f"[Status] {event.state}")
elif isinstance(event, WorkflowOutputEvent):
print("\n[Workflow Output Event - Conversation Update]")
if event.data and isinstance(event.data, list) and all(isinstance(msg, ChatMessage) for msg in event.data):
# Now safe to cast event.data to list[ChatMessage]
conversation = cast(list[ChatMessage], event.data)
for msg in conversation[-3:]: # Show last 3 messages
author = msg.author_name or msg.role.value
text = msg.text[:100] + "..." if len(msg.text) > 100 else msg.text
print(f" {author}: {text}")
elif isinstance(event, RequestInfoEvent):
new_pending_requests.append(event)
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, FunctionApprovalRequestContent):
_print_function_approval_request(event.data, event.request_id)
return new_pending_requests, latest_checkpoint.checkpoint_id
async def main() -> None:
"""
Demonstrate the checkpoint-based pause/resume pattern for handoff workflows.
This sample shows:
1. Starting a workflow and getting a HandoffUserInputRequest
2. Pausing (checkpoint is saved automatically)
3. Resuming from checkpoint with a user response or tool approval (two-step pattern)
4. Continuing the conversation until completion
"""
# Enable INFO logging to see workflow progress
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(name)s: %(message)s",
)
# Clean up old checkpoints
for file in CHECKPOINT_DIR.glob("*.json"):
file.unlink()
for file in CHECKPOINT_DIR.glob("*.json.tmp"):
file.unlink()
storage = FileCheckpointStorage(storage_path=CHECKPOINT_DIR)
workflow, _, _, _ = create_workflow(checkpoint_storage=storage)
print("=" * 60)
print("HANDOFF WORKFLOW CHECKPOINT DEMO")
print("=" * 60)
# Scenario: User needs help with a damaged order
initial_request = "Hi, my order 12345 arrived damaged. I need a refund."
# Phase 1: Initial run - workflow will pause when it needs user input
pending_requests, _ = await run_until_user_input_needed(
workflow,
initial_message=initial_request,
)
if not pending_requests:
print("Workflow completed without needing user input")
return
print("\n>>> Workflow paused. You could exit the process here.")
print(f">>> Checkpoint was saved. Pending requests: {len(pending_requests)}")
# Scripted human input for demo purposes
handoff_responses = [
(
"The headphones in order 12345 arrived cracked. "
"Please submit the refund for $89.99 and send a replacement to my original address."
),
"Yes, that covers the damage and refund request.",
"That's everything I needed for the refund.",
"Thanks for handling the refund.",
]
approval_decisions = [True, True, True]
handoff_index = 0
approval_index = 0
while pending_requests:
print("\n>>> Simulating process restart...\n")
workflow_step, _, _, _ = create_workflow(checkpoint_storage=storage)
needs_user_input = any(isinstance(req.data, HandoffUserInputRequest) for req in pending_requests)
needs_tool_approval = any(isinstance(req.data, FunctionApprovalRequestContent) for req in pending_requests)
user_response = None
if needs_user_input:
if handoff_index < len(handoff_responses):
user_response = handoff_responses[handoff_index]
handoff_index += 1
else:
user_response = handoff_responses[-1]
print(f">>> Responding to handoff request with: {user_response}")
approval_response = None
if needs_tool_approval:
if approval_index < len(approval_decisions):
approval_response = approval_decisions[approval_index]
approval_index += 1
else:
approval_response = approval_decisions[-1]
print(">>> Approving pending tool calls from the agent.")
pending_requests, _ = await resume_with_responses(
workflow_step,
storage,
user_response=user_response,
approve_tools=approval_response,
)
print("\n" + "=" * 60)
print("DEMO COMPLETE")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,416 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import contextlib
import json
import uuid
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, override
from agent_framework import (
Executor,
FileCheckpointStorage,
RequestInfoEvent,
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
response_handler,
)
CHECKPOINT_DIR = Path(__file__).with_suffix("").parent / "tmp" / "sub_workflow_checkpoints"
"""
Sample: Checkpointing for workflows that embed sub-workflows.
This sample shows how a parent workflow that wraps a sub-workflow can:
- run until the sub-workflow emits a human approval request
- persist a checkpoint that captures the pending request (including complex payloads)
- resume later, supplying the human decision directly at restore time
It is intentionally similar in spirit to the orchestration checkpoint sample but
uses ``WorkflowExecutor`` so we exercise the full parent/sub-workflow round-trip.
"""
def _utc_now() -> datetime:
return datetime.now()
# ---------------------------------------------------------------------------
# Messages exchanged inside the sub-workflow
# ---------------------------------------------------------------------------
@dataclass
class DraftTask:
"""Task handed from the parent to the sub-workflow writer."""
topic: str
due: datetime
iteration: int = 1
@dataclass
class DraftPackage:
"""Intermediate draft produced by the sub-workflow writer."""
topic: str
content: str
iteration: int
created_at: datetime = field(default_factory=_utc_now)
@dataclass
class FinalDraft:
"""Final deliverable returned to the parent workflow."""
topic: str
content: str
iterations: int
approved_at: datetime
@dataclass
class ReviewRequest:
"""Human approval request surfaced via `request_info`."""
id: str = str(uuid.uuid4())
topic: str = ""
iteration: int = 1
draft_excerpt: str = ""
due_iso: str = ""
reviewer_guidance: list[str] = field(default_factory=list) # type: ignore
@dataclass
class ReviewDecision:
"""The review decision to be sent to downstream executors along with the original request."""
decision: str
original_request: ReviewRequest
# ---------------------------------------------------------------------------
# Sub-workflow executors
# ---------------------------------------------------------------------------
class DraftWriter(Executor):
"""Produces an initial draft for the supplied topic."""
def __init__(self) -> None:
super().__init__(id="draft_writer")
@handler
async def create_draft(self, task: DraftTask, ctx: WorkflowContext[DraftPackage]) -> None:
draft = DraftPackage(
topic=task.topic,
content=(
f"Launch plan for {task.topic}.\n\n"
"- Outline the customer message.\n"
"- Highlight three differentiators.\n"
"- Close with a next-step CTA.\n"
f"(iteration {task.iteration})"
),
iteration=task.iteration,
)
await ctx.send_message(draft, target_id="draft_review")
class DraftReviewRouter(Executor):
"""Turns draft packages into human approval requests."""
def __init__(self) -> None:
super().__init__(id="draft_review")
@handler
async def request_review(self, draft: DraftPackage, ctx: WorkflowContext) -> None:
"""Request a review upon receiving a draft."""
excerpt = draft.content.splitlines()[0]
request = ReviewRequest(
topic=draft.topic,
iteration=draft.iteration,
draft_excerpt=excerpt,
due_iso=draft.created_at.isoformat(),
reviewer_guidance=[
"Ensure tone matches launch messaging",
"Confirm CTA is action-oriented",
],
)
await ctx.request_info(request_data=request, response_type=str)
@response_handler
async def forward_decision(
self,
original_request: ReviewRequest,
decision: str,
ctx: WorkflowContext[ReviewDecision],
) -> None:
"""Route the decision to the next executor."""
await ctx.send_message(ReviewDecision(decision=decision, original_request=original_request))
class DraftFinaliser(Executor):
"""Applies the human decision and emits the final draft."""
def __init__(self) -> None:
super().__init__(id="draft_finaliser")
@handler
async def on_review_decision(
self,
review_decision: ReviewDecision,
ctx: WorkflowContext[DraftTask, FinalDraft],
) -> None:
reply = review_decision.decision.strip().lower()
original = review_decision.original_request
topic = original.topic if original else "unknown topic"
iteration = original.iteration if original else 1
if reply != "approve":
# Loop back with a follow-up task. In a real workflow you would
# incorporate the human guidance; here we just increment the counter.
next_task = DraftTask(
topic=topic,
due=_utc_now() + timedelta(hours=1),
iteration=iteration + 1,
)
await ctx.send_message(next_task, target_id="draft_writer")
return
final = FinalDraft(
topic=topic,
content=f"Approved launch narrative for {topic} (iteration {iteration}).",
iterations=iteration,
approved_at=_utc_now(),
)
await ctx.yield_output(final)
# ---------------------------------------------------------------------------
# Parent workflow executors
# ---------------------------------------------------------------------------
class LaunchCoordinator(Executor):
"""Owns the top-level workflow and collects the final draft."""
def __init__(self) -> None:
super().__init__(id="launch_coordinator")
# Track pending requests to match responses
self._pending_requests: dict[str, SubWorkflowRequestMessage] = {}
@handler
async def kick_off(self, topic: str, ctx: WorkflowContext[DraftTask]) -> None:
task = DraftTask(topic=topic, due=_utc_now() + timedelta(hours=2))
await ctx.send_message(task)
@handler
async def collect_final(self, draft: FinalDraft, ctx: WorkflowContext[None, FinalDraft]) -> None:
approved_at = draft.approved_at
normalised = draft
if isinstance(approved_at, str):
with contextlib.suppress(ValueError):
parsed = datetime.fromisoformat(approved_at)
normalised = replace(draft, approved_at=parsed)
approved_at = parsed
approved_display = approved_at.isoformat() if hasattr(approved_at, "isoformat") else str(approved_at)
print("\n>>> Parent workflow received approved draft:")
print(f"- Topic: {normalised.topic}")
print(f"- Iterations: {normalised.iterations}")
print(f"- Approved at: {approved_display}")
print(f"- Content: {normalised.content}\n")
await ctx.yield_output(normalised)
@handler
async def handler_sub_workflow_request(
self,
request: SubWorkflowRequestMessage,
ctx: WorkflowContext,
) -> None:
"""Handle requests from the sub-workflow.
Note that the message type must be SubWorkflowRequestMessage to intercept the request.
"""
if not isinstance(request.source_event.data, ReviewRequest):
raise TypeError(f"Expected 'ReviewRequest', got {type(request.source_event.data)}")
# Record the request for response matching
review_request = request.source_event.data
self._pending_requests[review_request.id] = request
# Send the request without modification
await ctx.request_info(request_data=review_request, response_type=str)
@response_handler
async def handle_request_response(
self,
original_request: ReviewRequest,
response: str,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Process the response and send it back to the sub-workflow.
Note that the response must be sent back using SubWorkflowResponseMessage to route
the response back to the sub-workflow.
"""
request_message = self._pending_requests.pop(original_request.id, None)
if request_message is None:
raise ValueError("No matching pending request found for the resource response")
await ctx.send_message(request_message.create_response(response))
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
"""Capture any additional state needed for checkpointing."""
return {
"pending_requests": self._pending_requests,
}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
"""Restore any additional state needed from checkpointing."""
self._pending_requests = state.get("pending_requests", {})
# ---------------------------------------------------------------------------
# Workflow construction helpers
# ---------------------------------------------------------------------------
def build_sub_workflow() -> WorkflowExecutor:
"""Assemble the sub-workflow used by the parent workflow executor."""
sub_workflow = (
WorkflowBuilder()
.register_executor(DraftWriter, name="writer")
.register_executor(DraftReviewRouter, name="router")
.register_executor(DraftFinaliser, name="finaliser")
.set_start_executor("writer")
.add_edge("writer", "router")
.add_edge("router", "finaliser")
.add_edge("finaliser", "writer") # permits revision loops
.build()
)
return WorkflowExecutor(sub_workflow, id="launch_subworkflow")
def build_parent_workflow(storage: FileCheckpointStorage) -> Workflow:
"""Assemble the parent workflow that embeds the sub-workflow."""
return (
WorkflowBuilder()
.register_executor(LaunchCoordinator, name="coordinator")
.register_executor(build_sub_workflow, name="sub_executor")
.set_start_executor("coordinator")
.add_edge("coordinator", "sub_executor")
.add_edge("sub_executor", "coordinator")
.with_checkpointing(storage)
.build()
)
async def main() -> None:
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
for file in CHECKPOINT_DIR.glob("*.json"):
file.unlink()
storage = FileCheckpointStorage(CHECKPOINT_DIR)
workflow = build_parent_workflow(storage)
print("\n=== Stage 1: run until sub-workflow requests human review ===")
request_id: str | None = None
async for event in workflow.run_stream("Contoso Gadget Launch"):
if isinstance(event, RequestInfoEvent) and request_id is None:
request_id = event.request_id
print(f"Captured review request id: {request_id}")
if isinstance(event, WorkflowStatusEvent) and event.state is WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
break
if request_id is None:
raise RuntimeError("Sub-workflow completed without requesting review.")
checkpoints = await storage.list_checkpoints(workflow.id)
if not checkpoints:
raise RuntimeError("No checkpoints found.")
# Print the checkpoint to show pending requests
# We didn't handle the request above so the request is still pending the last checkpoint
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[-1]
print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}")
checkpoint_path = storage.storage_path / f"{resume_checkpoint.checkpoint_id}.json"
if checkpoint_path.exists():
checkpoint_content_dict = json.loads(checkpoint_path.read_text())
print(f"Pending review requests: {checkpoint_content_dict.get('pending_request_info_events', {})}")
print("\n=== Stage 2: resume from checkpoint ===")
# Rebuild fresh instances to mimic a separate process resuming
workflow2 = build_parent_workflow(storage)
request_info_event: RequestInfoEvent | None = None
async for event in workflow2.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
if isinstance(event, RequestInfoEvent):
request_info_event = event
if request_info_event is None:
raise RuntimeError("No request_info_event captured.")
print("\n=== Stage 3: approve draft ==")
approval_response = "approve"
output_event: WorkflowOutputEvent | None = None
async for event in workflow2.send_responses_streaming({request_info_event.request_id: approval_response}):
if isinstance(event, WorkflowOutputEvent):
output_event = event
if output_event is None:
raise RuntimeError("Workflow did not complete after resume.")
output = output_event.data
print("\n=== Final Draft (from resumed run) ===")
print(output)
""""
Sample Output:
=== Stage 1: run until sub-workflow requests human review ===
Captured review request id: 032c9f3a-ad1b-4a52-89be-a168d6663011
Using checkpoint 54f376c2-f849-44e4-9d8d-e627fd27ab96 at iteration 2
Pending review requests (sub executor snapshot): []
Pending review requests (parent executor snapshot): ['032c9f3a-ad1b-4a52-89be-a168d6663011']
=== Stage 2: resume from checkpoint and approve draft ===
>>> Parent workflow received approved draft:
- Topic: Contoso Gadget Launch
- Iterations: 1
- Approved at: 2025-09-25T14:29:34.479164
- Content: Approved launch narrative for Contoso Gadget Launch (iteration 1).
=== Final Draft (from resumed run) ===
FinalDraft(topic='Contoso Gadget Launch', content='Approved launch narrative for Contoso
Gadget Launch (iteration 1).', iterations=1, approved_at=datetime.datetime(2025, 9, 25, 14, 29, 34, 479164))
Coordinator stored final draft successfully.
"""
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,163 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Sample: Workflow as Agent with Checkpointing
Purpose:
This sample demonstrates how to use checkpointing with a workflow wrapped as an agent.
It shows how to enable checkpoint storage when calling agent.run() or agent.run_stream(),
allowing workflow execution state to be persisted and potentially resumed.
What you learn:
- How to pass checkpoint_storage to WorkflowAgent.run() and run_stream()
- How checkpoints are created during workflow-as-agent execution
- How to combine thread conversation history with workflow checkpointing
- How to resume a workflow-as-agent from a checkpoint
Key concepts:
- Thread (AgentThread): Maintains conversation history across agent invocations
- Checkpoint: Persists workflow execution state for pause/resume capability
- These are complementary: threads track conversation, checkpoints track workflow state
Prerequisites:
- OpenAI environment variables configured for OpenAIChatClient
"""
import asyncio
from agent_framework import (
AgentThread,
ChatAgent,
ChatMessageStore,
InMemoryCheckpointStorage,
SequentialBuilder,
)
from agent_framework.openai import OpenAIChatClient
async def basic_checkpointing() -> None:
"""Demonstrate basic checkpoint storage with workflow-as-agent."""
print("=" * 60)
print("Basic Checkpointing with Workflow as Agent")
print("=" * 60)
chat_client = OpenAIChatClient()
def create_assistant() -> ChatAgent:
return chat_client.as_agent(
name="assistant",
instructions="You are a helpful assistant. Keep responses brief.",
)
def create_reviewer() -> ChatAgent:
return chat_client.as_agent(
name="reviewer",
instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.",
)
# Build sequential workflow with participant factories
workflow = SequentialBuilder().register_participants([create_assistant, create_reviewer]).build()
agent = workflow.as_agent(name="CheckpointedAgent")
# Create checkpoint storage
checkpoint_storage = InMemoryCheckpointStorage()
# Run with checkpointing enabled
query = "What are the benefits of renewable energy?"
print(f"\nUser: {query}")
response = await agent.run(query, checkpoint_storage=checkpoint_storage)
for msg in response.messages:
speaker = msg.author_name or msg.role.value
print(f"[{speaker}]: {msg.text}")
# Show checkpoints that were created
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
print(f"\nCheckpoints created: {len(checkpoints)}")
for i, cp in enumerate(checkpoints[:5], 1):
print(f" {i}. {cp.checkpoint_id}")
async def checkpointing_with_thread() -> None:
"""Demonstrate combining thread history with checkpointing."""
print("\n" + "=" * 60)
print("Checkpointing with Thread Conversation History")
print("=" * 60)
chat_client = OpenAIChatClient()
def create_assistant() -> ChatAgent:
return chat_client.as_agent(
name="memory_assistant",
instructions="You are a helpful assistant with good memory. Reference previous conversation when relevant.",
)
workflow = SequentialBuilder().register_participants([create_assistant]).build()
agent = workflow.as_agent(name="MemoryAgent")
# Create both thread (for conversation) and checkpoint storage (for workflow state)
thread = AgentThread(message_store=ChatMessageStore())
checkpoint_storage = InMemoryCheckpointStorage()
# First turn
query1 = "My favorite color is blue. Remember that."
print(f"\n[Turn 1] User: {query1}")
response1 = await agent.run(query1, thread=thread, checkpoint_storage=checkpoint_storage)
if response1.messages:
print(f"[assistant]: {response1.messages[0].text}")
# Second turn - agent should remember from thread history
query2 = "What's my favorite color?"
print(f"\n[Turn 2] User: {query2}")
response2 = await agent.run(query2, thread=thread, checkpoint_storage=checkpoint_storage)
if response2.messages:
print(f"[assistant]: {response2.messages[0].text}")
# Show accumulated state
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
print(f"\nTotal checkpoints across both turns: {len(checkpoints)}")
if thread.message_store:
history = await thread.message_store.list_messages()
print(f"Messages in thread history: {len(history)}")
async def streaming_with_checkpoints() -> None:
"""Demonstrate streaming with checkpoint storage."""
print("\n" + "=" * 60)
print("Streaming with Checkpointing")
print("=" * 60)
chat_client = OpenAIChatClient()
def create_assistant() -> ChatAgent:
return chat_client.as_agent(
name="streaming_assistant",
instructions="You are a helpful assistant.",
)
workflow = SequentialBuilder().register_participants([create_assistant]).build()
agent = workflow.as_agent(name="StreamingCheckpointAgent")
checkpoint_storage = InMemoryCheckpointStorage()
query = "List three interesting facts about the ocean."
print(f"\nUser: {query}")
print("[assistant]: ", end="", flush=True)
# Stream with checkpointing
async for update in agent.run_stream(query, checkpoint_storage=checkpoint_storage):
if update.text:
print(update.text, end="", flush=True)
print() # Newline after streaming
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
print(f"\nCheckpoints created during stream: {len(checkpoints)}")
if __name__ == "__main__":
asyncio.run(basic_checkpointing())
asyncio.run(checkpointing_with_thread())
asyncio.run(streaming_with_checkpoints())