Files
Evan Mattson 4aa737eee5 Python: [BREAKING] Require building functional workflow instances (#7521)
* Harden functional workflow continuation authority

Use a versioned opaque single-use token on WorkflowRunResult, validate it before request correlation, consume it immediately before replayed user code, and rotate it on each pause. Carry the same explicit authority through streaming and non-streaming FunctionalWorkflowAgent responses.

Files changed: functional workflow/runtime result APIs, functional HITL regression tests, core agent guidance, and the functional HITL sample.

Next iteration: enforce pending-state overlap and token-authorized abandonment, then document and test checkpoint authorization boundaries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Enforce one pending functional continuation

Reject fresh messages and checkpoint restores while an in-memory continuation is pending. Add token-authorized abandonment on FunctionalWorkflow and FunctionalWorkflowAgent, and clear retained replay state atomically when authority is consumed while preserving the active message for token rotation and checkpoints.

Files changed: functional workflow runtime and agent adapter, functional lifecycle regression tests, and core workflow guidance.

Next iteration: preserve and document authorized checkpoint continuation boundaries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Preserve authorized functional checkpoint continuation

Treat checkpoint restore as a host- and storage-authorized path independent of process-local continuation tokens, and issue fresh authority whenever restored execution pauses again. Cover default and per-run storage, deterministic and custom request IDs, token rotation, and checkpoint-plus-response restore.

Files changed: functional workflow and checkpoint interface guidance, functional checkpoint lifecycle tests, the functional HITL sample, and core workflow guidance.

Next iteration: run the final repository-wide Python validation gates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Validate Python continuation hardening

Run the complete Python workspace checks, aggregate coverage suite, repository hooks, and core package build from the final combined worktree. Keep the validation iteration code-neutral because all gates pass without corrective changes.

Files changed: none; this commit records the final validation gate.

Blockers: none. Next iteration: no remaining AFK tasks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Handle functional checkpoint continuation failures

Publish retained continuation state only after checkpoint persistence succeeds, and cover reuse after a transient save failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Address functional continuation review findings

Add owner recovery for lost tokens, harden malformed token validation, preserve consistent failure surfaces, and keep agent pending state aligned with resumable workflow state.

Document process-local single-use continuation semantics and extend regression coverage across direct, streaming, checkpoint, and agent paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Handle functional continuation cancellation

Release the workflow run guard when cancellation interrupts resumed user code while keeping the single-use continuation token consumed.

Replace sample assertions with explicit runtime checks and add cancellation regression coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Simplify functional workflow instance isolation

Remove continuation-token handling and align functional workflows with the graph workflow ownership model: one stateful instance per logical caller or session.

Add create_instance() for independent callers, document the ownership contract, and cover pending-state isolation between instances.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Scope functional workflow checkpoint storage

Do not inherit checkpoint storage when creating an independent workflow instance. Allow hosts to provide an explicitly caller-scoped storage adapter and document that shared checkpoint access requires host authorization and tenant isolation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

* Require building functional workflow instances

Make @workflow return a stateless FunctionalWorkflowDefinition and require build() before run() or as_agent(). This aligns functional workflows with the graph definition/build lifecycle and prevents module-level decorated definitions from retaining caller state.

Move checkpoint configuration to build(), export the definition type, migrate samples, and cover isolated built instances.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a8f47743-1cdc-4924-8e1b-667d0d790b78
2026-08-14 00:31:52 +00:00

88 lines
3.4 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Human-in-the-loop review pipeline using functional workflows.
Demonstrates ctx.request_info() for pausing the workflow to wait for
external input and resuming with run(responses={...}).
HITL works with or without @step. The difference is what happens on resume:
- Without @step: every function re-executes from the top (fine for cheap calls).
- With @step: completed functions return their saved result instantly.
This sample uses @step on write_draft() because it simulates an expensive
operation that shouldn't re-run just because the workflow was paused.
"""
import asyncio
from agent_framework import RunContext, WorkflowRunState, step, workflow
# @step saves the result. When the workflow resumes after the HITL pause,
# this returns its saved result instead of running the expensive operation again.
#
# In a real workflow you might call an agent here instead:
# @step
# async def write_draft(topic: str) -> str:
# return (await writer_agent.run(f"Write a draft about: {topic}")).text
@step
async def write_draft(topic: str) -> str:
"""Simulate writing a draft — expensive, shouldn't re-run on resume."""
print(f" write_draft executing for '{topic}'")
return f"Draft document about '{topic}': Lorem ipsum dolor sit amet..."
@step
async def revise_draft(draft: str, feedback: str) -> str:
"""Revise the draft based on feedback."""
return f"Revised: {draft[:50]}... [Applied feedback: {feedback}]"
@workflow
async def review_pipeline(topic: str, ctx: RunContext) -> str:
"""Write a draft, get human review, then revise."""
draft = await write_draft(topic)
# ctx.request_info() suspends the workflow here. The caller gets back
# a WorkflowRunResult with state IDLE_WITH_PENDING_REQUESTS and can
# inspect the pending request via result.get_request_info_events().
feedback = await ctx.request_info(
{"draft": draft, "instructions": "Please review this draft"},
response_type=str,
request_id="review_request",
)
# This only executes after the caller resumes with run(responses={...}).
# write_draft above returns its saved result (thanks to @step),
# request_info returns the provided response, and we continue here.
return await revise_draft(draft, feedback)
async def main():
workflow_instance = review_pipeline.build()
# Phase 1: Run until the workflow pauses for human input
print("=== Phase 1: Initial run ===")
result1 = await workflow_instance.run("AI Safety")
# If request_info() was reached, the state is IDLE_WITH_PENDING_REQUESTS.
# If the workflow completed without hitting request_info(), it would be IDLE.
print(f"State: {(final_state := result1.get_final_state())}")
if final_state != WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
raise RuntimeError(f"Expected pending review input, but workflow entered {final_state}.")
requests = result1.get_request_info_events()
print(f"Pending request: {requests[0].request_id}")
# Phase 2: Resume with the human's response
print("\n=== Phase 2: Resume with feedback ===")
print("(write_draft should NOT execute again — saved by @step)")
result2 = await workflow_instance.run(responses={"review_request": "Add more details about alignment research"})
print(f"State: {result2.get_final_state()}")
print(f"Output: {result2.get_outputs()[0]}")
if __name__ == "__main__":
asyncio.run(main())