mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] Python: Rename workflow to workflows (#1007)
* Rename workflow to workflows * Update occurence of workflow to new name
This commit is contained in:
committed by
GitHub
Unverified
parent
189434dd4b
commit
b42bb700fb
+487
@@ -0,0 +1,487 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
ChatMessage,
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
# 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.
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Workflow
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
"""
|
||||
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 routes every draft through `RequestInfoExecutor` so a human
|
||||
can approve or request tweaks.
|
||||
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 by pairing `RequestInfoExecutor` with
|
||||
checkpoint restoration.
|
||||
- Supplying responses at restore time (`run_stream_from_checkpoint(..., responses=...)`).
|
||||
|
||||
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(RequestInfoMessage):
|
||||
"""Message sent to the human reviewer via RequestInfoExecutor."""
|
||||
|
||||
# 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, reviewer_id: str, writer_id: str, finalize_id: str) -> None:
|
||||
super().__init__(id=id)
|
||||
self._reviewer_id = reviewer_id
|
||||
self._writer_id = writer_id
|
||||
self._finalize_id = finalize_id
|
||||
|
||||
@handler
|
||||
async def on_agent_response(
|
||||
self,
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[HumanApprovalRequest, str],
|
||||
) -> None:
|
||||
# Capture the agent output so we can surface it to the reviewer and
|
||||
# persist iterations. The `RequestInfoExecutor` relies on this state to
|
||||
# rehydrate when checkpoints are restored.
|
||||
draft = response.agent_run_response.text or ""
|
||||
iteration = int((await ctx.get_state() or {}).get("iteration", 0)) + 1
|
||||
await ctx.set_state({"iteration": iteration, "last_draft": draft})
|
||||
# Emit a human approval request. Because this flows through
|
||||
# RequestInfoExecutor it will pause the workflow until an answer is
|
||||
# supplied either interactively or via pre-supplied responses.
|
||||
await ctx.send_message(
|
||||
HumanApprovalRequest(
|
||||
prompt="Review the draft. Reply 'approve' or provide edit instructions.",
|
||||
draft=draft,
|
||||
iteration=iteration,
|
||||
),
|
||||
target_id=self._reviewer_id,
|
||||
)
|
||||
|
||||
@handler
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[HumanApprovalRequest, str],
|
||||
ctx: WorkflowContext[AgentExecutorRequest | str, str],
|
||||
) -> None:
|
||||
# The RequestResponse wrapper gives us both the human data and the
|
||||
# original request message, even when resuming from checkpoints.
|
||||
reply = (feedback.data or "").strip()
|
||||
state = await ctx.get_state() or {}
|
||||
draft = state.get("last_draft") or (feedback.original_request.draft if feedback.original_request else "")
|
||||
|
||||
if reply.lower() == "approve":
|
||||
# When the human signs off we can short-circuit the workflow and
|
||||
# send the approved draft to the final executor.
|
||||
await ctx.send_message(draft, target_id=self._finalize_id)
|
||||
return
|
||||
|
||||
# Any other response loops us back to the writer with fresh guidance.
|
||||
guidance = reply or "Tighten the copy and emphasise customer benefit."
|
||||
iteration = int(state.get("iteration", 1)) + 1
|
||||
await ctx.set_state({"iteration": iteration, "last_draft": draft})
|
||||
prompt = (
|
||||
"Revise the launch note. Respond with the new copy only.\n\n"
|
||||
f"Previous draft:\n{draft}\n\n"
|
||||
f"Human guidance: {guidance}"
|
||||
)
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True),
|
||||
target_id=self._writer_id,
|
||||
)
|
||||
|
||||
|
||||
class FinaliseExecutor(Executor):
|
||||
"""Publishes the approved text."""
|
||||
|
||||
@handler
|
||||
async def publish(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
|
||||
# Store the output so diagnostics or a UI could fetch the final copy.
|
||||
await ctx.set_state({"published_text": text})
|
||||
# Yield the final output so the workflow completes cleanly.
|
||||
await ctx.yield_output(text)
|
||||
|
||||
|
||||
def create_workflow(*, checkpoint_storage: FileCheckpointStorage | None = None) -> "Workflow":
|
||||
"""Assemble the workflow graph used by both the initial run and resume."""
|
||||
|
||||
# The Azure client is created once so our agent executor can issue calls to
|
||||
# the hosted model. The agent id is stable across runs which keeps
|
||||
# checkpoints deterministic.
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
writer = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions="Write concise, warm release notes that sound human and helpful.",
|
||||
),
|
||||
id="writer",
|
||||
)
|
||||
# RequestInfoExecutor is the lynchpin for human-in-the-loop: every draft is
|
||||
# routed through it so checkpoints can pause while waiting for responses.
|
||||
review = RequestInfoExecutor(id="request_info")
|
||||
finalise = FinaliseExecutor(id="finalise")
|
||||
gateway = ReviewGateway(
|
||||
id="review_gateway",
|
||||
reviewer_id=review.id,
|
||||
writer_id=writer.id,
|
||||
finalize_id=finalise.id,
|
||||
)
|
||||
prepare = BriefPreparer(id="prepare_brief", agent_id=writer.id)
|
||||
|
||||
# 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.
|
||||
builder = (
|
||||
WorkflowBuilder(max_iterations=6)
|
||||
.set_start_executor(prepare)
|
||||
.add_edge(prepare, writer)
|
||||
.add_edge(writer, gateway)
|
||||
.add_edge(gateway, review)
|
||||
.add_edge(review, gateway) # human resumes loop
|
||||
.add_edge(gateway, writer) # revisions
|
||||
.add_edge(gateway, finalise)
|
||||
)
|
||||
# Opt-in to persistence when the caller provides storage. The workflow
|
||||
# object itself is identical whether or not checkpointing is enabled.
|
||||
if checkpoint_storage:
|
||||
builder = builder.with_checkpointing(checkpoint_storage=checkpoint_storage)
|
||||
return 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 [
|
||||
RequestInfoExecutor.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} | iter={summary.iteration_count} "
|
||||
f"| targets={summary.targets} | states={summary.executor_states}"
|
||||
)
|
||||
if summary.status:
|
||||
line += f" | status={summary.status}"
|
||||
if summary.draft_preview:
|
||||
line += f" | draft_preview={summary.draft_preview}"
|
||||
if summary.pending_requests:
|
||||
line += f" | pending_request_id={summary.pending_requests[0].request_id}"
|
||||
print(line)
|
||||
|
||||
|
||||
def _print_events(events: list[Any]) -> tuple[str | None, list[tuple[str, HumanApprovalRequest]]]:
|
||||
"""Echo workflow events to the console and collect outstanding requests."""
|
||||
|
||||
completed_output: str | None = None
|
||||
requests: list[tuple[str, HumanApprovalRequest]] = []
|
||||
|
||||
for event in events:
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
completed_output = event.data
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanApprovalRequest):
|
||||
# Capture pending human approvals so the caller can ask the user for
|
||||
# input after the current batch of events is processed.
|
||||
requests.append((event.request_id, event.data))
|
||||
elif isinstance(event, WorkflowStatusEvent) and event.state in {
|
||||
WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
}:
|
||||
print(f"Workflow state: {event.state.name}")
|
||||
|
||||
return completed_output, requests
|
||||
|
||||
|
||||
def _prompt_for_responses(requests: list[tuple[str, HumanApprovalRequest]]) -> dict[str, str] | None:
|
||||
"""Interactive CLI prompt for any live RequestInfo requests."""
|
||||
|
||||
if not requests:
|
||||
return None
|
||||
answers: dict[str, str] = {}
|
||||
for request_id, request in requests:
|
||||
# Keep the prompt conversational so testers can use the script without
|
||||
# memorising the workflow APIs.
|
||||
print("\n=== Human approval needed ===")
|
||||
print(f"request_id: {request_id}")
|
||||
if request.iteration:
|
||||
print(f"Iteration: {request.iteration}")
|
||||
print(request.prompt)
|
||||
print("Draft: \n---\n" + request.draft + "\n---")
|
||||
answer = input("Type 'approve' or enter revision guidance (or 'exit' to quit): ").strip() # noqa: ASYNC250
|
||||
if answer.lower() == "exit":
|
||||
raise SystemExit("Stopped by user.")
|
||||
answers[request_id] = answer
|
||||
return answers
|
||||
|
||||
|
||||
def _maybe_pre_supply_responses(cp: "WorkflowCheckpoint") -> dict[str, str] | None:
|
||||
"""Offer to collect responses before resuming a checkpoint."""
|
||||
|
||||
pending = RequestInfoExecutor.pending_requests_from_checkpoint(cp)
|
||||
if not pending:
|
||||
return None
|
||||
|
||||
print(
|
||||
"This checkpoint still has pending human input. Provide the responses now so the resume step "
|
||||
"applies them immediately and does not re-emit the original RequestInfo event."
|
||||
)
|
||||
choice = input("Pre-supply responses for this checkpoint? [y/N]: ").strip().lower() # noqa: ASYNC250
|
||||
if choice not in {"y", "yes"}:
|
||||
return None
|
||||
|
||||
answers: dict[str, str] = {}
|
||||
for item in pending:
|
||||
iteration = item.iteration or 0
|
||||
print(f"\nPending draft (iteration {iteration} | request_id={item.request_id}):")
|
||||
draft_text = (item.draft or "").strip()
|
||||
if draft_text:
|
||||
# The shortened preview in the summary may truncate text; here we
|
||||
# show the full draft so the reviewer can make an informed choice.
|
||||
print("Draft:\n---\n" + draft_text + "\n---")
|
||||
else:
|
||||
print("Draft: [not captured in checkpoint payload - refer to your notes/log]")
|
||||
prompt_text = (item.prompt or "Review the draft").strip()
|
||||
print(prompt_text)
|
||||
answer = input("Response ('approve' or guidance, 'exit' to abort): ").strip() # noqa: ASYNC250
|
||||
if answer.lower() == "exit":
|
||||
raise SystemExit("Resume aborted by user.")
|
||||
answers[item.request_id] = answer
|
||||
return answers
|
||||
|
||||
|
||||
async def _consume(stream: AsyncIterable[Any]) -> list[Any]:
|
||||
"""Materialise an async event stream into a list."""
|
||||
|
||||
return [event async for event in stream]
|
||||
|
||||
|
||||
async def run_interactive_session(workflow: "Workflow", initial_message: str) -> str | None:
|
||||
"""Run the workflow until it either finishes or pauses for human input."""
|
||||
|
||||
pending_responses: dict[str, str] | None = None
|
||||
completed_output: str | None = None
|
||||
first = True
|
||||
|
||||
while completed_output is None:
|
||||
if first:
|
||||
# Kick off the workflow with the initial brief. The returned events
|
||||
# include RequestInfo events when the agent produces a draft.
|
||||
events = await _consume(workflow.run_stream(initial_message))
|
||||
first = False
|
||||
elif pending_responses:
|
||||
# Feed any answers the user just typed back into the workflow.
|
||||
events = await _consume(workflow.send_responses_streaming(pending_responses))
|
||||
else:
|
||||
break
|
||||
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending_responses = _prompt_for_responses(requests)
|
||||
|
||||
return completed_output
|
||||
|
||||
|
||||
async def resume_from_checkpoint(
|
||||
workflow: "Workflow",
|
||||
checkpoint_id: str,
|
||||
storage: FileCheckpointStorage,
|
||||
pre_supplied: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""Resume a stored checkpoint and continue until completion or another pause."""
|
||||
|
||||
print(f"\nResuming from checkpoint: {checkpoint_id}")
|
||||
events = await _consume(
|
||||
workflow.run_stream_from_checkpoint(
|
||||
checkpoint_id,
|
||||
checkpoint_storage=storage,
|
||||
responses=pre_supplied,
|
||||
)
|
||||
)
|
||||
completed_output, requests = _print_events(events)
|
||||
if pre_supplied and not requests and completed_output is None:
|
||||
# When the checkpoint only needed the provided answers we let the user
|
||||
# know the workflow is waiting for the next superstep (usually another
|
||||
# agent response).
|
||||
print("Pre-supplied responses applied automatically; workflow is now waiting for the next step.")
|
||||
|
||||
pending = _prompt_for_responses(requests)
|
||||
while completed_output is None and pending:
|
||||
events = await _consume(workflow.send_responses_streaming(pending))
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending = _prompt_for_responses(requests)
|
||||
else:
|
||||
break
|
||||
|
||||
if completed_output:
|
||||
print(f"Workflow completed with: {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)...")
|
||||
completed = await run_interactive_session(workflow, initial_message=brief)
|
||||
if completed:
|
||||
print(f"Initial run completed with final copy: {completed}")
|
||||
else:
|
||||
print("Initial run paused for human input.")
|
||||
|
||||
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 = RequestInfoExecutor.checkpoint_summary(chosen)
|
||||
if summary.status == "completed":
|
||||
print("Selected checkpoint already reflects a completed workflow; nothing to resume.")
|
||||
return
|
||||
|
||||
# If the user wants, capture their decisions now so the resume call can
|
||||
# push them into the workflow and avoid re-prompting.
|
||||
pre_responses = _maybe_pre_supply_responses(chosen)
|
||||
|
||||
resumed_workflow = create_workflow()
|
||||
# Resume with a fresh workflow instance. The checkpoint carries the
|
||||
# persistent state while this object holds the runtime wiring.
|
||||
await resume_from_checkpoint(resumed_workflow, chosen.checkpoint_id, storage, pre_responses)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,323 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
ChatMessage,
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoExecutor,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Workflow
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
"""
|
||||
Sample: Checkpointing and Resuming a Workflow (with an Agent stage)
|
||||
|
||||
Purpose:
|
||||
This sample shows how to enable checkpointing at superstep boundaries, persist both
|
||||
executor-local state and shared workflow state, and then resume execution from a specific
|
||||
checkpoint. The workflow demonstrates a simple text-processing pipeline that includes
|
||||
an LLM-backed AgentExecutor stage.
|
||||
|
||||
Pipeline:
|
||||
1) UpperCaseExecutor converts input to uppercase and records state.
|
||||
2) ReverseTextExecutor reverses the string.
|
||||
3) SubmitToLowerAgent prepares an AgentExecutorRequest for the lowercasing agent.
|
||||
4) lower_agent (AgentExecutor) converts text to lowercase via Azure OpenAI.
|
||||
5) FinalizeFromAgent yields the final result.
|
||||
|
||||
What you learn:
|
||||
- How to persist executor state using ctx.get_state and ctx.set_state.
|
||||
- How to persist shared workflow state using ctx.set_shared_state for cross-executor visibility.
|
||||
- How to configure FileCheckpointStorage and call with_checkpointing on WorkflowBuilder.
|
||||
- How to list and inspect checkpoints programmatically.
|
||||
- How to interactively choose a checkpoint to resume from (instead of always resuming
|
||||
from the most recent or a hard-coded one) using run_stream_from_checkpoint.
|
||||
- How workflows complete by yielding outputs when idle, not via explicit completion events.
|
||||
|
||||
Prerequisites:
|
||||
- Azure AI or Azure OpenAI available for AzureOpenAIChatClient.
|
||||
- Authentication with azure-identity via AzureCliCredential. Run az login locally.
|
||||
- Filesystem access for writing JSON checkpoint files in a temp directory.
|
||||
"""
|
||||
|
||||
# Define the temporary directory for storing checkpoints.
|
||||
# These files allow the workflow to be resumed later.
|
||||
DIR = os.path.dirname(__file__)
|
||||
TEMP_DIR = os.path.join(DIR, "tmp", "checkpoints")
|
||||
os.makedirs(TEMP_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class UpperCaseExecutor(Executor):
|
||||
"""Uppercases the input text and persists both local and shared state."""
|
||||
|
||||
@handler
|
||||
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
result = text.upper()
|
||||
print(f"UpperCaseExecutor: '{text}' -> '{result}'")
|
||||
|
||||
# Persist executor-local state so it is captured in checkpoints
|
||||
# and available after resume for observability or logic.
|
||||
prev = await ctx.get_state() or {}
|
||||
count = int(prev.get("count", 0)) + 1
|
||||
await ctx.set_state({
|
||||
"count": count,
|
||||
"last_input": text,
|
||||
"last_output": result,
|
||||
})
|
||||
|
||||
# Write to shared_state so downstream executors and any resumed runs can read it.
|
||||
await ctx.set_shared_state("original_input", text)
|
||||
await ctx.set_shared_state("upper_output", result)
|
||||
|
||||
# Send transformed text to the next executor.
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class SubmitToLowerAgent(Executor):
|
||||
"""Builds an AgentExecutorRequest to send to the lowercasing agent while keeping shared-state visibility."""
|
||||
|
||||
def __init__(self, id: str, agent_id: str):
|
||||
super().__init__(id=id)
|
||||
self._agent_id = agent_id
|
||||
|
||||
@handler
|
||||
async def submit(self, text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
# Demonstrate reading shared_state written by UpperCaseExecutor.
|
||||
# Shared state survives across checkpoints and is visible to all executors.
|
||||
orig = await ctx.get_shared_state("original_input")
|
||||
upper = await ctx.get_shared_state("upper_output")
|
||||
print(f"LowerAgent (shared_state): original_input='{orig}', upper_output='{upper}'")
|
||||
|
||||
# Build a minimal, deterministic prompt for the AgentExecutor.
|
||||
prompt = f"Convert the following text to lowercase. Return ONLY the transformed text.\n\nText: {text}"
|
||||
|
||||
# Send to the AgentExecutor. should_respond=True instructs the agent to produce a reply.
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True),
|
||||
target_id=self._agent_id,
|
||||
)
|
||||
|
||||
|
||||
class FinalizeFromAgent(Executor):
|
||||
"""Consumes the AgentExecutorResponse and yields the final result."""
|
||||
|
||||
@handler
|
||||
async def finalize(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any, str]) -> None:
|
||||
result = response.agent_run_response.text or ""
|
||||
|
||||
# Persist executor-local state for auditability when inspecting checkpoints.
|
||||
prev = await ctx.get_state() or {}
|
||||
count = int(prev.get("count", 0)) + 1
|
||||
await ctx.set_state({
|
||||
"count": count,
|
||||
"last_output": result,
|
||||
"final": True,
|
||||
})
|
||||
|
||||
# Yield the final result so external consumers see the final value.
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
class ReverseTextExecutor(Executor):
|
||||
"""Reverses the input text and persists local state."""
|
||||
|
||||
@handler
|
||||
async def reverse_text(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
result = text[::-1]
|
||||
print(f"ReverseTextExecutor: '{text}' -> '{result}'")
|
||||
|
||||
# Persist executor-local state so checkpoint inspection can reveal progress.
|
||||
prev = await ctx.get_state() or {}
|
||||
count = int(prev.get("count", 0)) + 1
|
||||
await ctx.set_state({
|
||||
"count": count,
|
||||
"last_input": text,
|
||||
"last_output": result,
|
||||
})
|
||||
|
||||
# Forward the reversed string to the next stage.
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> "Workflow":
|
||||
# Instantiate the pipeline executors.
|
||||
upper_case_executor = UpperCaseExecutor(id="upper-case")
|
||||
reverse_text_executor = ReverseTextExecutor(id="reverse-text")
|
||||
|
||||
# Configure the agent stage that lowercases the text.
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
lower_agent = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions=("You transform text to lowercase. Reply with ONLY the transformed text.")
|
||||
),
|
||||
id="lower_agent",
|
||||
)
|
||||
|
||||
# Bridge to the agent and terminalization stage.
|
||||
submit_lower = SubmitToLowerAgent(id="submit_lower", agent_id=lower_agent.id)
|
||||
finalize = FinalizeFromAgent(id="finalize")
|
||||
|
||||
# Build the workflow with checkpointing enabled.
|
||||
return (
|
||||
WorkflowBuilder(max_iterations=5)
|
||||
.add_edge(upper_case_executor, reverse_text_executor) # Uppercase -> Reverse
|
||||
.add_edge(reverse_text_executor, submit_lower) # Reverse -> Build Agent request
|
||||
.add_edge(submit_lower, lower_agent) # Submit to AgentExecutor
|
||||
.add_edge(lower_agent, finalize) # Agent output -> Finalize
|
||||
.set_start_executor(upper_case_executor) # Entry point
|
||||
.with_checkpointing(checkpoint_storage=checkpoint_storage) # Enable persistence
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
"""Display human-friendly checkpoint metadata using framework summaries."""
|
||||
|
||||
if not checkpoints:
|
||||
return
|
||||
|
||||
print("\nCheckpoint summary:")
|
||||
for cp in sorted(checkpoints, key=lambda c: c.timestamp):
|
||||
summary = RequestInfoExecutor.checkpoint_summary(cp)
|
||||
msg_count = sum(len(v) for v in cp.messages.values())
|
||||
state_keys = sorted(cp.executor_states.keys())
|
||||
orig = cp.shared_state.get("original_input")
|
||||
upper = cp.shared_state.get("upper_output")
|
||||
|
||||
line = (
|
||||
f"- {summary.checkpoint_id} | iter={summary.iteration_count} | messages={msg_count} | states={state_keys}"
|
||||
)
|
||||
if summary.status:
|
||||
line += f" | status={summary.status}"
|
||||
line += f" | shared_state: original_input='{orig}', upper_output='{upper}'"
|
||||
print(line)
|
||||
|
||||
|
||||
async def main():
|
||||
# Clear existing checkpoints in this sample directory for a clean run.
|
||||
checkpoint_dir = Path(TEMP_DIR)
|
||||
for file in checkpoint_dir.glob("*.json"): # noqa: ASYNC240
|
||||
file.unlink()
|
||||
|
||||
# Backing store for checkpoints written by with_checkpointing.
|
||||
checkpoint_storage = FileCheckpointStorage(storage_path=TEMP_DIR)
|
||||
|
||||
workflow = create_workflow(checkpoint_storage=checkpoint_storage)
|
||||
|
||||
# Run the full workflow once and observe events as they stream.
|
||||
print("Running workflow with initial message...")
|
||||
async for event in workflow.run_stream(message="hello world"):
|
||||
print(f"Event: {event}")
|
||||
|
||||
# Inspect checkpoints written during the run.
|
||||
all_checkpoints = await checkpoint_storage.list_checkpoints()
|
||||
if not all_checkpoints:
|
||||
print("No checkpoints found!")
|
||||
return
|
||||
|
||||
# All checkpoints created by this run share the same workflow_id.
|
||||
workflow_id = all_checkpoints[0].workflow_id
|
||||
|
||||
_render_checkpoint_summary(all_checkpoints)
|
||||
|
||||
# Offer an interactive selection of checkpoints to resume from.
|
||||
sorted_cps = sorted([cp for cp in all_checkpoints if cp.workflow_id == workflow_id], key=lambda c: c.timestamp)
|
||||
|
||||
print("\nAvailable checkpoints to resume from:")
|
||||
for idx, cp in enumerate(sorted_cps):
|
||||
summary = RequestInfoExecutor.checkpoint_summary(cp)
|
||||
line = f" [{idx}] id={summary.checkpoint_id} iter={summary.iteration_count}"
|
||||
if summary.status:
|
||||
line += f" status={summary.status}"
|
||||
msg_count = sum(len(v) for v in cp.messages.values())
|
||||
line += f" messages={msg_count}"
|
||||
print(line)
|
||||
|
||||
user_input = input( # noqa: ASYNC250
|
||||
"\nEnter checkpoint index (or paste checkpoint id) to resume from, or press Enter to skip resume: "
|
||||
).strip()
|
||||
|
||||
if not user_input:
|
||||
print("No checkpoint selected. Exiting without resuming.")
|
||||
return
|
||||
|
||||
chosen_cp_id: str | None = None
|
||||
|
||||
# Try as index first
|
||||
if user_input.isdigit():
|
||||
idx = int(user_input)
|
||||
if 0 <= idx < len(sorted_cps):
|
||||
chosen_cp_id = sorted_cps[idx].checkpoint_id
|
||||
# Fall back to direct id match
|
||||
if chosen_cp_id is None:
|
||||
for cp in sorted_cps:
|
||||
if cp.checkpoint_id.startswith(user_input): # allow prefix match for convenience
|
||||
chosen_cp_id = cp.checkpoint_id
|
||||
break
|
||||
|
||||
if chosen_cp_id is None:
|
||||
print("Input did not match any checkpoint. Exiting without resuming.")
|
||||
return
|
||||
|
||||
# You can reuse the same workflow graph definition and resume from a prior checkpoint.
|
||||
# This second workflow instance does not enable checkpointing to show that resumption
|
||||
# reads from stored state but need not write new checkpoints.
|
||||
new_workflow = create_workflow(checkpoint_storage=checkpoint_storage)
|
||||
|
||||
print(f"\nResuming from checkpoint: {chosen_cp_id}")
|
||||
async for event in new_workflow.run_stream_from_checkpoint(chosen_cp_id, checkpoint_storage=checkpoint_storage):
|
||||
print(f"Resumed Event: {event}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
Running workflow with initial message...
|
||||
UpperCaseExecutor: 'hello world' -> 'HELLO WORLD'
|
||||
Event: ExecutorInvokeEvent(executor_id=upper_case_executor)
|
||||
Event: ExecutorCompletedEvent(executor_id=upper_case_executor)
|
||||
ReverseTextExecutor: 'HELLO WORLD' -> 'DLROW OLLEH'
|
||||
Event: ExecutorInvokeEvent(executor_id=reverse_text_executor)
|
||||
Event: ExecutorCompletedEvent(executor_id=reverse_text_executor)
|
||||
LowerAgent (shared_state): original_input='hello world', upper_output='HELLO WORLD'
|
||||
Event: ExecutorInvokeEvent(executor_id=submit_lower)
|
||||
Event: ExecutorInvokeEvent(executor_id=lower_agent)
|
||||
Event: ExecutorInvokeEvent(executor_id=finalize)
|
||||
|
||||
Checkpoint summary:
|
||||
- dfc63e72-8e8d-454f-9b6d-0d740b9062e6 | label='after_initial_execution' | iter=0 | messages=1 | states=['upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
|
||||
- a78c345a-e5d9-45ba-82c0-cb725452d91b | label='superstep_1' | iter=1 | messages=1 | states=['reverse_text_executor', 'upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
|
||||
- 637c1dbd-a525-4404-9583-da03980537a2 | label='superstep_2' | iter=2 | messages=0 | states=['finalize', 'lower_agent', 'reverse_text_executor', 'submit_lower', 'upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
|
||||
|
||||
Available checkpoints to resume from:
|
||||
[0] id=dfc63e72-... iter=0 messages=1 label='after_initial_execution'
|
||||
[1] id=a78c345a-... iter=1 messages=1 label='superstep_1'
|
||||
[2] id=637c1dbd-... iter=2 messages=0 label='superstep_2'
|
||||
|
||||
Enter checkpoint index (or paste checkpoint id) to resume from, or press Enter to skip resume: 1
|
||||
|
||||
Resuming from checkpoint: a78c345a-e5d9-45ba-82c0-cb725452d91b
|
||||
LowerAgent (shared_state): original_input='hello world', upper_output='HELLO WORLD'
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=submit_lower)
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=lower_agent)
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=finalize)
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,370 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
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 via RequestInfoExecutor
|
||||
- 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(RequestInfoMessage):
|
||||
"""Human approval request surfaced via RequestInfoExecutor."""
|
||||
|
||||
topic: str = ""
|
||||
iteration: int = 1
|
||||
draft_excerpt: str = ""
|
||||
due_iso: str = ""
|
||||
reviewer_guidance: list[str] = field(default_factory=list) # type: ignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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[ReviewRequest]) -> None:
|
||||
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.send_message(request, target_id="sub_review_requests")
|
||||
|
||||
@handler
|
||||
async def forward_decision(
|
||||
self,
|
||||
decision: RequestResponse[ReviewRequest, str],
|
||||
ctx: WorkflowContext[RequestResponse[ReviewRequest, str]],
|
||||
) -> None:
|
||||
await ctx.send_message(decision, target_id="draft_finaliser")
|
||||
|
||||
|
||||
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,
|
||||
decision: RequestResponse[ReviewRequest, str],
|
||||
ctx: WorkflowContext[DraftTask, FinalDraft],
|
||||
) -> None:
|
||||
reply = (decision.data or "").strip().lower()
|
||||
original = 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")
|
||||
self._final: FinalDraft | None = None
|
||||
|
||||
@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, target_id="launch_subworkflow")
|
||||
|
||||
@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
|
||||
|
||||
self._final = normalised
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def final_result(self) -> FinalDraft | None:
|
||||
return self._final
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow construction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_sub_workflow() -> WorkflowExecutor:
|
||||
writer = DraftWriter()
|
||||
router = DraftReviewRouter()
|
||||
request_info = RequestInfoExecutor(id="sub_review_requests")
|
||||
finaliser = DraftFinaliser()
|
||||
|
||||
sub_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(writer)
|
||||
.add_edge(writer, router)
|
||||
.add_edge(router, request_info)
|
||||
.add_edge(request_info, router, condition=lambda msg: isinstance(msg, RequestResponse))
|
||||
.add_edge(router, finaliser, condition=lambda msg: isinstance(msg, RequestResponse))
|
||||
.add_edge(request_info, finaliser)
|
||||
.add_edge(finaliser, writer) # permits revision loops
|
||||
.build()
|
||||
)
|
||||
|
||||
return WorkflowExecutor(sub_workflow, id="launch_subworkflow")
|
||||
|
||||
|
||||
def build_parent_workflow(storage: FileCheckpointStorage) -> tuple[LaunchCoordinator, Workflow]:
|
||||
coordinator = LaunchCoordinator()
|
||||
sub_executor = build_sub_workflow()
|
||||
parent_request_info = RequestInfoExecutor(id="parent_review_gateway")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(coordinator)
|
||||
.add_edge(coordinator, sub_executor)
|
||||
.add_edge(sub_executor, coordinator, condition=lambda msg: isinstance(msg, FinalDraft))
|
||||
.add_edge(
|
||||
sub_executor,
|
||||
parent_request_info,
|
||||
condition=lambda msg: isinstance(msg, RequestInfoMessage),
|
||||
)
|
||||
.add_edge(parent_request_info, sub_executor)
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
|
||||
return coordinator, workflow
|
||||
|
||||
|
||||
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:
|
||||
print("Sub-workflow completed without requesting review.")
|
||||
return
|
||||
|
||||
checkpoints = await storage.list_checkpoints(workflow.id)
|
||||
if not checkpoints:
|
||||
print("No checkpoints written.")
|
||||
return
|
||||
|
||||
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():
|
||||
snapshot = json.loads(checkpoint_path.read_text())
|
||||
exec_states = snapshot.get("executor_states", {})
|
||||
sub_pending = exec_states.get("sub_review_requests", {}).get("request_events", {})
|
||||
parent_pending = exec_states.get("parent_review_gateway", {}).get("request_events", {})
|
||||
print(f"Pending review requests (sub executor snapshot): {list(sub_pending.keys())}")
|
||||
print(f"Pending review requests (parent executor snapshot): {list(parent_pending.keys())}")
|
||||
|
||||
print("\n=== Stage 2: resume from checkpoint and approve draft ===")
|
||||
# Rebuild fresh instances to mimic a separate process resuming
|
||||
coordinator2, workflow2 = build_parent_workflow(storage)
|
||||
|
||||
approval_response = "approve"
|
||||
final_event: WorkflowOutputEvent | None = None
|
||||
async for event in workflow2.run_stream_from_checkpoint(
|
||||
resume_checkpoint.checkpoint_id,
|
||||
responses={request_id: approval_response},
|
||||
):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
final_event = event
|
||||
|
||||
if final_event is None:
|
||||
print("Workflow did not complete after resume.")
|
||||
return
|
||||
|
||||
final = final_event.data
|
||||
print("\n=== Final Draft (from resumed run) ===")
|
||||
print(final)
|
||||
|
||||
if coordinator2.final_result is None:
|
||||
print("Coordinator did not capture final result via handler.")
|
||||
else:
|
||||
print("Coordinator stored final draft successfully.")
|
||||
|
||||
""""
|
||||
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())
|
||||
Reference in New Issue
Block a user