mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Replace RequestInfoExecutor with request_info API and @response_handler (#1466)
* Prototype: Add request_info API and @response_handler * Add original_request as a parameter to the response handler * Prototype: request interception in sub workflows * Prototype: request interception in sub workflows 2 * WIP: Make checkpointing work * checkpointing with sub workflow * Fix function executor * Allow sub-workflow to output directly * Remove ReqeustInfoExecutor and related classes; Debugging checkpoint_with_human_in_the_loop * Fix Handoff and sample * fix pending requests in checkpoint * Fix unit tests * Fix formatting * Resolve comments * Address comment * Add checkpoint tests * Add tests * misc * fix mypy * fix mypy * Use request type as part of the key * Log warning if there is not response handler for a request * Update Internal edge group comments * REcord message type in executor processing span * Update sample * Improve tests
This commit is contained in:
committed by
GitHub
Unverified
parent
f6eadd412e
commit
943d92674e
+95
-234
@@ -1,11 +1,13 @@
|
||||
# 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
|
||||
|
||||
# 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 (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
@@ -14,30 +16,20 @@ from agent_framework import (
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
Role,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
get_checkpoint_summary,
|
||||
handler,
|
||||
response_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.
|
||||
|
||||
@@ -45,17 +37,14 @@ 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.
|
||||
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 by pairing `RequestInfoExecutor` with
|
||||
checkpoint restoration.
|
||||
- Supplying responses at restore time (`run_stream_from_checkpoint(..., responses=...)`).
|
||||
- Human-in-the-loop pause/resume with checkpoint restoration.
|
||||
|
||||
Typical pause/resume flow
|
||||
-------------------------
|
||||
@@ -110,8 +99,8 @@ class BriefPreparer(Executor):
|
||||
|
||||
|
||||
@dataclass
|
||||
class HumanApprovalRequest(RequestInfoMessage):
|
||||
"""Message sent to the human reviewer via RequestInfoExecutor."""
|
||||
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
|
||||
@@ -124,52 +113,42 @@ class HumanApprovalRequest(RequestInfoMessage):
|
||||
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:
|
||||
def __init__(self, id: str, writer_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.
|
||||
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.
|
||||
draft = response.agent_run_response.text or ""
|
||||
iteration = int((await ctx.get_executor_state() or {}).get("iteration", 0)) + 1
|
||||
await ctx.set_executor_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(
|
||||
# Emit a human approval request.
|
||||
await ctx.request_info(
|
||||
HumanApprovalRequest(
|
||||
prompt="Review the draft. Reply 'approve' or provide edit instructions.",
|
||||
draft=draft,
|
||||
iteration=iteration,
|
||||
),
|
||||
target_id=self._reviewer_id,
|
||||
HumanApprovalRequest,
|
||||
str,
|
||||
)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[HumanApprovalRequest, str],
|
||||
original_request: HumanApprovalRequest,
|
||||
feedback: 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()
|
||||
# The `original_request` is the request we sent earlier that is now being answered.
|
||||
reply = feedback.strip()
|
||||
state = await ctx.get_executor_state() or {}
|
||||
draft = state.get("last_draft") or (feedback.original_request.draft if feedback.original_request else "")
|
||||
draft = state.get("last_draft") or (original_request.draft or "")
|
||||
|
||||
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)
|
||||
# Workflow is completed when the human approves.
|
||||
await ctx.yield_output(draft)
|
||||
return
|
||||
|
||||
# Any other response loops us back to the writer with fresh guidance.
|
||||
@@ -187,63 +166,34 @@ class ReviewGateway(Executor):
|
||||
)
|
||||
|
||||
|
||||
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_executor_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":
|
||||
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> 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.
|
||||
# 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,
|
||||
)
|
||||
agent = chat_client.create_agent(instructions="Write concise, warm release notes that sound human and helpful.")
|
||||
|
||||
writer = AgentExecutor(agent, id="writer")
|
||||
gateway = ReviewGateway(id="review_gateway", writer_id=writer.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 = (
|
||||
workflow_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)
|
||||
.add_edge(gateway, writer) # revisions loop
|
||||
.with_checkpointing(checkpoint_storage=checkpoint_storage)
|
||||
)
|
||||
# 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()
|
||||
|
||||
return workflow_builder.build()
|
||||
|
||||
|
||||
def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
def render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
"""Pretty-print saved checkpoints with the new framework summaries."""
|
||||
|
||||
print("\nCheckpoint summary:")
|
||||
@@ -251,166 +201,83 @@ def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
# 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"- {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.draft_preview:
|
||||
line += f" | draft_preview={summary.draft_preview}"
|
||||
if summary.pending_requests:
|
||||
line += f" | pending_request_id={summary.pending_requests[0].request_id}"
|
||||
if summary.pending_request_info_events:
|
||||
line += f" | pending_request_id={summary.pending_request_info_events[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:
|
||||
def prompt_for_responses(requests: dict[str, HumanApprovalRequest]) -> dict[str, str]:
|
||||
"""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.
|
||||
responses: dict[str, str] = {}
|
||||
for request_id, request in requests.items():
|
||||
print("\n=== Human approval needed ===")
|
||||
print(f"request_id: {request_id}")
|
||||
if request.iteration:
|
||||
print(f"Iteration: {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":
|
||||
response = input("Type 'approve' or enter revision guidance (or 'exit' to quit): ").strip()
|
||||
if response.lower() == "exit":
|
||||
raise SystemExit("Stopped by user.")
|
||||
answers[request_id] = answer
|
||||
return answers
|
||||
responses[request_id] = response
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
def _maybe_pre_supply_responses(cp: "WorkflowCheckpoint") -> dict[str, str] | None:
|
||||
"""Offer to collect responses before resuming a checkpoint."""
|
||||
|
||||
pending = get_checkpoint_summary(cp).pending_requests
|
||||
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:
|
||||
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."""
|
||||
|
||||
pending_responses: dict[str, str] | None = None
|
||||
requests: dict[str, HumanApprovalRequest] = {}
|
||||
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))
|
||||
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(initial_message)
|
||||
elif checkpoint_id:
|
||||
print("\nStarting workflow from checkpoint...\n")
|
||||
event_stream = workflow.run_stream_from_checkpoint(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
|
||||
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending_responses = _prompt_for_responses(requests)
|
||||
if requests:
|
||||
responses = prompt_for_responses(requests)
|
||||
continue
|
||||
|
||||
raise RuntimeError("Workflow stopped without completing or requesting input")
|
||||
|
||||
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."""
|
||||
|
||||
@@ -428,11 +295,8 @@ async def main() -> None:
|
||||
)
|
||||
|
||||
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.")
|
||||
result = await run_interactive_session(workflow, initial_message=brief)
|
||||
print(f"Workflow completed with: {result}")
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
if not checkpoints:
|
||||
@@ -441,7 +305,7 @@ async def main() -> None:
|
||||
|
||||
# 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)
|
||||
render_checkpoint_summary(checkpoints)
|
||||
|
||||
sorted_cps = sorted(checkpoints, key=lambda c: c.timestamp)
|
||||
print("\nAvailable checkpoints:")
|
||||
@@ -472,14 +336,11 @@ async def main() -> None:
|
||||
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()
|
||||
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.
|
||||
await resume_from_checkpoint(resumed_workflow, chosen.checkpoint_id, storage, pre_responses)
|
||||
result = await run_interactive_session(new_workflow, checkpoint_id=chosen.checkpoint_id)
|
||||
print(f"Workflow completed with: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -11,9 +12,8 @@ from agent_framework import (
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
@@ -22,6 +22,7 @@ from agent_framework import (
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
CHECKPOINT_DIR = Path(__file__).with_suffix("").parent / "tmp" / "sub_workflow_checkpoints"
|
||||
@@ -30,7 +31,7 @@ CHECKPOINT_DIR = Path(__file__).with_suffix("").parent / "tmp" / "sub_workflow_c
|
||||
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
|
||||
- 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
|
||||
|
||||
@@ -78,9 +79,10 @@ class FinalDraft:
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewRequest(RequestInfoMessage):
|
||||
"""Human approval request surfaced via RequestInfoExecutor."""
|
||||
class ReviewRequest:
|
||||
"""Human approval request surfaced via `request_info`."""
|
||||
|
||||
id: str = str(uuid.uuid4())
|
||||
topic: str = ""
|
||||
iteration: int = 1
|
||||
draft_excerpt: str = ""
|
||||
@@ -88,6 +90,14 @@ class ReviewRequest(RequestInfoMessage):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -122,7 +132,8 @@ class DraftReviewRouter(Executor):
|
||||
super().__init__(id="draft_review")
|
||||
|
||||
@handler
|
||||
async def request_review(self, draft: DraftPackage, ctx: WorkflowContext[ReviewRequest]) -> None:
|
||||
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,
|
||||
@@ -134,15 +145,17 @@ class DraftReviewRouter(Executor):
|
||||
"Confirm CTA is action-oriented",
|
||||
],
|
||||
)
|
||||
await ctx.send_message(request, target_id="sub_review_requests")
|
||||
await ctx.request_info(request, ReviewRequest, str)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def forward_decision(
|
||||
self,
|
||||
decision: RequestResponse[ReviewRequest, str],
|
||||
ctx: WorkflowContext[RequestResponse[ReviewRequest, str]],
|
||||
original_request: ReviewRequest,
|
||||
decision: str,
|
||||
ctx: WorkflowContext[ReviewDecision],
|
||||
) -> None:
|
||||
await ctx.send_message(decision, target_id="draft_finaliser")
|
||||
"""Route the decision to the next executor."""
|
||||
await ctx.send_message(ReviewDecision(decision=decision, original_request=original_request))
|
||||
|
||||
|
||||
class DraftFinaliser(Executor):
|
||||
@@ -154,11 +167,11 @@ class DraftFinaliser(Executor):
|
||||
@handler
|
||||
async def on_review_decision(
|
||||
self,
|
||||
decision: RequestResponse[ReviewRequest, str],
|
||||
review_decision: ReviewDecision,
|
||||
ctx: WorkflowContext[DraftTask, FinalDraft],
|
||||
) -> None:
|
||||
reply = (decision.data or "").strip().lower()
|
||||
original = decision.original_request
|
||||
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
|
||||
|
||||
@@ -192,12 +205,11 @@ class LaunchCoordinator(Executor):
|
||||
|
||||
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")
|
||||
await ctx.send_message(task)
|
||||
|
||||
@handler
|
||||
async def collect_final(self, draft: FinalDraft, ctx: WorkflowContext[None, FinalDraft]) -> None:
|
||||
@@ -209,8 +221,6 @@ class LaunchCoordinator(Executor):
|
||||
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:")
|
||||
@@ -221,9 +231,50 @@ class LaunchCoordinator(Executor):
|
||||
|
||||
await ctx.yield_output(normalised)
|
||||
|
||||
@property
|
||||
def final_result(self) -> FinalDraft | None:
|
||||
return self._final
|
||||
@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 to response matching
|
||||
review_request = request.source_event.data
|
||||
executor_state = await ctx.get_executor_state() or {}
|
||||
executor_state[review_request.id] = request
|
||||
await ctx.set_executor_state(executor_state)
|
||||
|
||||
# Send the request without modification
|
||||
await ctx.request_info(review_request, ReviewRequest, 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.
|
||||
"""
|
||||
executor_state = await ctx.get_executor_state() or {}
|
||||
request_message = executor_state.pop(original_request.id, None)
|
||||
|
||||
# Save the executor state back to the context
|
||||
await ctx.set_executor_state(executor_state)
|
||||
|
||||
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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -234,17 +285,13 @@ class LaunchCoordinator(Executor):
|
||||
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(router, finaliser)
|
||||
.add_edge(finaliser, writer) # permits revision loops
|
||||
.build()
|
||||
)
|
||||
@@ -252,28 +299,19 @@ def build_sub_workflow() -> WorkflowExecutor:
|
||||
return WorkflowExecutor(sub_workflow, id="launch_subworkflow")
|
||||
|
||||
|
||||
def build_parent_workflow(storage: FileCheckpointStorage) -> tuple[LaunchCoordinator, Workflow]:
|
||||
def build_parent_workflow(storage: FileCheckpointStorage) -> Workflow:
|
||||
coordinator = LaunchCoordinator()
|
||||
sub_executor = build_sub_workflow()
|
||||
parent_request_info = RequestInfoExecutor(id="parent_review_gateway")
|
||||
|
||||
workflow = (
|
||||
return (
|
||||
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)
|
||||
.add_edge(sub_executor, coordinator)
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
|
||||
return coordinator, workflow
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -282,9 +320,10 @@ async def main() -> None:
|
||||
|
||||
storage = FileCheckpointStorage(CHECKPOINT_DIR)
|
||||
|
||||
_, workflow = build_parent_workflow(storage)
|
||||
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:
|
||||
@@ -294,52 +333,52 @@ async def main() -> None:
|
||||
break
|
||||
|
||||
if request_id is None:
|
||||
print("Sub-workflow completed without requesting review.")
|
||||
return
|
||||
raise RuntimeError("Sub-workflow completed without requesting review.")
|
||||
|
||||
checkpoints = await storage.list_checkpoints(workflow.id)
|
||||
if not checkpoints:
|
||||
print("No checkpoints written.")
|
||||
return
|
||||
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():
|
||||
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())}")
|
||||
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 ===")
|
||||
|
||||
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)
|
||||
workflow2 = build_parent_workflow(storage)
|
||||
|
||||
approval_response = "approve"
|
||||
final_event: WorkflowOutputEvent | None = None
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
async for event in workflow2.run_stream_from_checkpoint(
|
||||
resume_checkpoint.checkpoint_id,
|
||||
responses={request_id: approval_response},
|
||||
):
|
||||
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):
|
||||
final_event = event
|
||||
output_event = event
|
||||
|
||||
if final_event is None:
|
||||
print("Workflow did not complete after resume.")
|
||||
return
|
||||
if output_event is None:
|
||||
raise RuntimeError("Workflow did not complete after resume.")
|
||||
|
||||
final = final_event.data
|
||||
output = output_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.")
|
||||
print(output)
|
||||
|
||||
""""
|
||||
Sample Output:
|
||||
|
||||
Reference in New Issue
Block a user