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
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:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user