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
This commit is contained in:
Evan Mattson
2026-08-14 00:31:52 +00:00
committed by GitHub
parent 8c4da3c3b9
commit 4aa737eee5
15 changed files with 291 additions and 138 deletions
+6
View File
@@ -201,6 +201,12 @@ agent_framework/
every output-capable executor not selected by `output_from`.
- **`WorkflowRunResult`** - Non-streaming workflow result with Workflow Output `get_outputs()`
and Intermediate Output `get_intermediate_outputs()` accessors
- **Functional workflow definition/build lifecycle** - `@workflow` returns a stateless
`FunctionalWorkflowDefinition`. Call `build()` to create a stateful `FunctionalWorkflow` scoped to one logical
caller or session. The definition has no `run()` or `as_agent()` surface, so module-level decorated definitions
cannot accidentally retain caller state. Each built workflow and its `FunctionalWorkflowAgent` must remain scoped
to that caller/session. Pass a caller-scoped checkpoint storage to `build(checkpoint_storage=...)` when needed;
hosts remain responsible for authorizing and tenant-scoping access to any shared checkpoint adapter.
- **Orchestrators**: `SequentialOrchestrator`, `ConcurrentOrchestrator`, `GroupChatOrchestrator`, `MagenticOrchestrator`, `HandoffOrchestrator`
## Built-in Providers
@@ -321,6 +321,7 @@ _LAZY_MODULE_EXPORTS: Final[Mapping[str, tuple[str, ...]]] = {
"._workflows._function_executor": ("FunctionExecutor", "executor"),
"._workflows._functional": (
"FunctionalWorkflow",
"FunctionalWorkflowDefinition",
"FunctionalWorkflowAgent",
"RunContext",
"StepWrapper",
@@ -478,6 +479,7 @@ __all__ = [
"FunctionTool",
"FunctionalWorkflow",
"FunctionalWorkflowAgent",
"FunctionalWorkflowDefinition",
"GeneratedEmbeddings",
"GraphConnectivityError",
"HistoryProvider",
@@ -285,6 +285,7 @@ from ._workflows._function_executor import FunctionExecutor, executor
from ._workflows._functional import (
FunctionalWorkflow,
FunctionalWorkflowAgent,
FunctionalWorkflowDefinition,
RunContext,
StepWrapper,
get_run_context,
@@ -442,6 +443,7 @@ __all__ = [
"FunctionTool",
"FunctionalWorkflow",
"FunctionalWorkflowAgent",
"FunctionalWorkflowDefinition",
"GeneratedEmbeddings",
"GraphConnectivityError",
"HistoryProvider",
@@ -21,7 +21,10 @@ parameter to access HITL and state APIs directly.
Key public symbols:
* :func:`workflow` / :class:`FunctionalWorkflow` — decorator and runtime.
* :func:`workflow` / :class:`FunctionalWorkflowDefinition` — decorator and
stateless definition.
* :class:`FunctionalWorkflow` — stateful runtime created by
:meth:`FunctionalWorkflowDefinition.build`.
* :func:`step` / :class:`StepWrapper` — optional step decorator.
* :class:`RunContext` — execution context injected into workflow and step
functions.
@@ -628,6 +631,46 @@ def step(
return _decorator
# ---------------------------------------------------------------------------
# FunctionalWorkflowDefinition
# ---------------------------------------------------------------------------
@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS)
class FunctionalWorkflowDefinition:
"""Stateless definition produced by :func:`workflow`.
Call :meth:`build` to create a stateful :class:`FunctionalWorkflow`.
Each built workflow represents one logical caller or session.
"""
def __init__(
self,
func: Callable[..., Awaitable[Any]],
*,
name: str | None = None,
description: str | None = None,
) -> None:
FunctionalWorkflow._classify_signature(func)
self._func = func
self.name = name or func.__name__
self.description = description
functools.update_wrapper(self, func) # type: ignore[arg-type]
def build(
self,
*,
checkpoint_storage: CheckpointStorage | None = None,
) -> FunctionalWorkflow:
"""Build a stateful workflow for one logical caller or session."""
return FunctionalWorkflow(
self._func,
name=self.name,
description=self.description,
checkpoint_storage=checkpoint_storage,
)
# ---------------------------------------------------------------------------
# FunctionalWorkflow
# ---------------------------------------------------------------------------
@@ -637,8 +680,8 @@ def step(
class FunctionalWorkflow:
"""A workflow backed by a user-defined async function.
Created by the :func:`workflow` decorator. Exposes the same ``run()``
interface as graph-based :class:`Workflow` objects, returning a
Built from a :class:`FunctionalWorkflowDefinition`. Exposes the same
``run()`` interface as graph-based :class:`Workflow` objects, returning a
:class:`WorkflowRunResult` (or a :class:`ResponseStream` in streaming
mode).
@@ -646,6 +689,10 @@ class FunctionalWorkflow:
edge wiring is involved. Native Python control flow (``if``/``else``,
``for``, ``asyncio.gather``) is used for branching and parallelism.
Like graph-based :class:`Workflow`, each instance owns mutable execution
state across calls to :meth:`run`. Scope an instance to one logical
caller or session; build separate instances for independent callers.
Args:
func: The async function that implements the workflow logic.
name: Display name for the workflow. Defaults to ``func.__name__``.
@@ -664,7 +711,8 @@ class FunctionalWorkflow:
return await to_upper(data)
result = await my_pipeline.run("hello")
pipeline = my_pipeline.build()
result = await pipeline.run("hello")
print(result.get_outputs()) # ['HELLO']
"""
@@ -933,7 +981,7 @@ class FunctionalWorkflow:
if storage is None:
raise ValueError(
"Cannot restore from checkpoint without checkpoint_storage. "
"Provide checkpoint_storage parameter or set it on the @workflow decorator."
"Provide checkpoint_storage to build() or to this run."
)
checkpoint = await storage.load(checkpoint_id)
if checkpoint.graph_signature_hash != self.graph_signature_hash:
@@ -1258,7 +1306,7 @@ class FunctionalWorkflow:
@overload
def workflow(func: Callable[..., Awaitable[Any]]) -> FunctionalWorkflow: ...
def workflow(func: Callable[..., Awaitable[Any]]) -> FunctionalWorkflowDefinition: ...
@overload
@@ -1266,8 +1314,7 @@ def workflow(
*,
name: str | None = None,
description: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
) -> Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflow]: ...
) -> Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflowDefinition]: ...
@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS)
@@ -1276,29 +1323,26 @@ def workflow(
*,
name: str | None = None,
description: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
) -> FunctionalWorkflow | Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflow]:
"""Decorator that converts an async function into a :class:`FunctionalWorkflow`.
) -> FunctionalWorkflowDefinition | Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflowDefinition]:
"""Decorator that creates a stateless :class:`FunctionalWorkflowDefinition`.
Supports both bare ``@workflow`` and parameterized
``@workflow(name="my_wf")`` forms.
The decorated function receives its input as the first positional argument
and a :class:`RunContext` instance wherever a parameter is annotated with
that type. The resulting :class:`FunctionalWorkflow` object exposes the
same ``run()`` interface as graph-based workflows.
that type. Call ``build()`` on the resulting definition to create a
stateful :class:`FunctionalWorkflow`.
Args:
func: The async function to decorate (when using the bare
``@workflow`` form).
name: Display name for the workflow. Defaults to ``func.__name__``.
description: Optional human-readable description.
checkpoint_storage: Default :class:`CheckpointStorage` for
persisting step results and workflow state.
Returns:
A :class:`FunctionalWorkflow` (bare form) or a decorator that
produces one (parameterized form).
A :class:`FunctionalWorkflowDefinition` (bare form) or a decorator
that produces one (parameterized form).
Examples:
@@ -1311,14 +1355,17 @@ def workflow(
# Parameterized form
@workflow(name="my_pipeline", checkpoint_storage=storage)
@workflow(name="my_pipeline")
async def pipeline(data: str) -> str: ...
instance = pipeline.build(checkpoint_storage=storage)
"""
if func is not None:
return FunctionalWorkflow(func, name=name, description=description, checkpoint_storage=checkpoint_storage)
return FunctionalWorkflowDefinition(func, name=name, description=description)
def _decorator(fn: Callable[..., Awaitable[Any]]) -> FunctionalWorkflow:
return FunctionalWorkflow(fn, name=name, description=description, checkpoint_storage=checkpoint_storage)
def _decorator(fn: Callable[..., Awaitable[Any]]) -> FunctionalWorkflowDefinition:
return FunctionalWorkflowDefinition(fn, name=name, description=description)
return _decorator
@@ -1343,6 +1390,12 @@ class FunctionalWorkflowAgent:
:class:`WorkflowAgent`), so HITL workflows are callable via this
adapter. Callers resume via ``responses=`` / ``checkpoint_id=``.
The wrapped workflow owns mutable execution state. Scope the workflow and
this adapter to one logical caller or session; create separate workflow
instances for independent or mutually untrusted callers. If those
instances use checkpoint storage, the host must also authorize and
tenant-scope access to that external store.
Args:
workflow: The :class:`FunctionalWorkflow` to wrap.
name: Display name for the agent. Defaults to the workflow name.
@@ -7,17 +7,20 @@ from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Iterator
from collections.abc import Awaitable, Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, overload
import pytest
from agent_framework import (
AgentResponseUpdate,
CheckpointStorage,
ExperimentalFeature,
FunctionalWorkflow,
FunctionalWorkflowAgent,
FunctionalWorkflowDefinition,
InMemoryCheckpointStorage,
RunContext,
StepWrapper,
@@ -37,6 +40,34 @@ from agent_framework._workflows._functional import (
# ---------------------------------------------------------------------------
@overload
def built_workflow(func: Callable[..., Awaitable[Any]]) -> FunctionalWorkflow: ...
@overload
def built_workflow(
*,
name: str | None = None,
description: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
) -> Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflow]: ...
def built_workflow(
func: Callable[..., Awaitable[Any]] | None = None,
*,
name: str | None = None,
description: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
) -> FunctionalWorkflow | Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflow]:
"""Build a fresh executable workflow for behavior-focused tests."""
def decorate(fn: Callable[..., Awaitable[Any]]) -> FunctionalWorkflow:
return workflow(name=name, description=description)(fn).build(checkpoint_storage=checkpoint_storage)
return decorate(func) if func is not None else decorate
@step
async def add_one(x: int) -> int:
return x + 1
@@ -69,7 +100,7 @@ async def failing_step(x: int) -> int:
class TestBasicExecution:
async def test_simple_sequential_pipeline(self):
@workflow
@built_workflow
async def pipeline(x: int) -> int:
a = await add_one(x)
return await double(a)
@@ -80,7 +111,7 @@ class TestBasicExecution:
assert outputs == [12] # (5+1)*2
async def test_workflow_with_string_data(self):
@workflow
@built_workflow
async def upper_pipeline(text: str) -> str:
return await to_upper(text)
@@ -88,7 +119,7 @@ class TestBasicExecution:
assert result.get_outputs() == ["HELLO"]
async def test_workflow_returns_result(self):
@workflow
@built_workflow
async def simple(x: int) -> int:
return await add_one(x)
@@ -96,14 +127,14 @@ class TestBasicExecution:
assert result.get_outputs() == [11]
async def test_workflow_name_defaults_to_function_name(self):
@workflow
@built_workflow
async def my_pipeline(x: int) -> int:
return x
assert my_pipeline.name == "my_pipeline"
async def test_workflow_custom_name(self):
@workflow(name="custom_wf", description="A test workflow")
@built_workflow(name="custom_wf", description="A test workflow")
async def wf(x: int) -> int:
return x
@@ -118,7 +149,7 @@ class TestBasicExecution:
class TestEventEmission:
async def test_step_events_emitted(self):
@workflow
@built_workflow
async def pipeline(x: int) -> int:
return await add_one(x)
@@ -129,7 +160,7 @@ class TestEventEmission:
assert "output" in event_types
async def test_step_events_carry_executor_id(self):
@workflow
@built_workflow
async def pipeline(x: int) -> int:
return await add_one(x)
@@ -144,7 +175,7 @@ class TestEventEmission:
assert completed_events[0].data == 6
async def test_status_events_in_timeline(self):
@workflow
@built_workflow
async def pipeline(x: int) -> int:
return x
@@ -154,7 +185,7 @@ class TestEventEmission:
assert WorkflowRunState.IDLE in states
async def test_final_state_is_idle(self):
@workflow
@built_workflow
async def pipeline(x: int) -> int:
return x
@@ -164,7 +195,7 @@ class TestEventEmission:
async def test_custom_event(self):
from agent_framework import WorkflowEvent
@workflow
@built_workflow
async def pipeline(x: int, ctx: RunContext) -> int:
await ctx.add_event(WorkflowEvent("intermediate", executor_id="pipeline", data="custom_data"))
return x
@@ -192,7 +223,7 @@ class TestParallelExecution:
await asyncio.sleep(0.01)
return x * 2
@workflow
@built_workflow
async def parallel_wf(x: int) -> list[int]:
a, b = await asyncio.gather(slow_add(x), slow_double(x))
return [a, b]
@@ -210,7 +241,7 @@ class TestParallelExecution:
async def task_b(x: int) -> int:
return x * 2
@workflow
@built_workflow
async def par_wf(x: int) -> tuple[int, int]:
a, b = await asyncio.gather(task_a(x), task_b(x))
return (a, b)
@@ -228,8 +259,50 @@ class TestParallelExecution:
class TestHITL:
async def test_request_info_interrupts(self):
async def test_workflow_definition_builds_isolated_pending_continuations(self):
@workflow
async def review_wf(doc: str, ctx: RunContext) -> str:
feedback = await ctx.request_info(doc, response_type=str)
return f"{doc}:{feedback}"
assert not hasattr(review_wf, "run")
caller_a = review_wf.build()
caller_b = review_wf.build()
caller_a_paused = await caller_a.run("caller-a")
request_id = caller_a_paused.get_request_info_events()[0].request_id
with pytest.raises(ValueError, match="no pending request_info events"):
await caller_b.run(responses={request_id: "caller-b-response"})
caller_a_completed = await caller_a.run(responses={request_id: "caller-a-response"})
assert caller_a_completed.get_outputs() == ["caller-a:caller-a-response"]
async def test_build_does_not_inherit_checkpoint_storage_by_default(self):
@workflow
async def review_wf(doc: str) -> str:
return doc
caller = review_wf.build()
with pytest.raises(ValueError, match="checkpoint_storage"):
await caller.run(checkpoint_id="missing")
async def test_build_accepts_tenant_scoped_checkpoint_storage(self):
caller_storage = InMemoryCheckpointStorage()
@workflow
async def review_wf(doc: str, ctx: RunContext) -> str:
return await ctx.request_info(doc, response_type=str)
caller = review_wf.build(checkpoint_storage=caller_storage)
await caller.run("caller")
assert len(await caller_storage.list_checkpoints(workflow_name="review_wf")) == 1
async def test_request_info_interrupts(self):
@built_workflow
async def review_wf(doc: str, ctx: RunContext) -> str:
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
return f"Final: {feedback}"
@@ -242,7 +315,7 @@ class TestHITL:
assert request_events[0].request_id == "req1"
async def test_request_info_resume(self):
@workflow
@built_workflow
async def review_wf(doc: str, ctx: RunContext) -> str:
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
return f"Final: {feedback}"
@@ -260,7 +333,7 @@ class TestHITL:
async def test_fresh_message_while_pending_requests_warns(self, caplog: pytest.LogCaptureFixture) -> None:
"""A fresh message while request_info events are pending is allowed but logs a warning."""
@workflow
@built_workflow
async def review_wf(doc: str, ctx: RunContext) -> str:
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
return f"Final: {feedback}"
@@ -279,7 +352,7 @@ class TestHITL:
async def test_responses_while_pending_requests_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None:
"""Delivering responses is the normal completion path and must not warn."""
@workflow
@built_workflow
async def review_wf(doc: str, ctx: RunContext) -> str:
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
return f"Final: {feedback}"
@@ -297,7 +370,7 @@ class TestHITL:
async def test_untyped_ctx_parameter(self):
"""ctx is injected by parameter name even without a RunContext annotation."""
@workflow # pyright: ignore[reportUnknownArgumentType]
@built_workflow # pyright: ignore[reportUnknownArgumentType]
async def review_wf(doc: str, ctx) -> str: # pyright: ignore[reportMissingParameterType, reportUnknownParameterType]
feedback: str = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
return f"Final: {feedback}"
@@ -309,7 +382,7 @@ class TestHITL:
assert result2.get_outputs() == ["Final: LGTM"]
async def test_multiple_sequential_interrupts(self):
@workflow
@built_workflow
async def multi_hitl(data: str, ctx: RunContext) -> str:
r1 = await ctx.request_info("step1", response_type=str, request_id="r1")
r2 = await ctx.request_info("step2", response_type=str, request_id="r2")
@@ -330,7 +403,7 @@ class TestHITL:
assert result3.get_outputs() == ["A+B"]
async def test_request_info_auto_generates_id(self):
@workflow
@built_workflow
async def auto_id_wf(x: int, ctx: RunContext) -> None:
await ctx.request_info("need data", response_type=str)
@@ -347,7 +420,7 @@ class TestHITL:
class TestErrorHandling:
async def test_step_failure_propagates(self):
@workflow
@built_workflow
async def failing_wf(x: int) -> None:
await failing_step(x)
@@ -355,7 +428,7 @@ class TestErrorHandling:
await failing_wf.run(42)
async def test_step_failure_emits_executor_failed(self):
@workflow
@built_workflow
async def failing_wf(x: int) -> None:
await failing_step(x)
@@ -371,7 +444,7 @@ class TestErrorHandling:
assert failed_events[0].executor_id == "failing_step"
async def test_workflow_failure_emits_failed_status(self):
@workflow
@built_workflow
async def bad_wf(x: int) -> None:
raise RuntimeError("workflow broke")
@@ -387,7 +460,7 @@ class TestErrorHandling:
assert any(e.state == WorkflowRunState.FAILED for e in status_events)
async def test_invalid_params_message_and_responses(self):
@workflow
@built_workflow
async def wf(x: int) -> None:
pass
@@ -395,7 +468,7 @@ class TestErrorHandling:
await wf.run("hello", responses={"r1": "val"})
async def test_invalid_params_message_and_checkpoint(self):
@workflow
@built_workflow
async def wf(x: int) -> None:
pass
@@ -403,7 +476,7 @@ class TestErrorHandling:
await wf.run("hello", checkpoint_id="abc")
async def test_invalid_params_nothing(self):
@workflow
@built_workflow
async def wf(x: int) -> None:
pass
@@ -418,7 +491,7 @@ class TestErrorHandling:
class TestStreaming:
async def test_streaming_yields_events(self):
@workflow
@built_workflow
async def pipeline(x: int) -> int:
return await add_one(x)
@@ -434,7 +507,7 @@ class TestStreaming:
assert "output" in event_types
async def test_streaming_final_response(self):
@workflow
@built_workflow
async def pipeline(x: int) -> int:
return await add_one(x)
@@ -446,7 +519,7 @@ class TestStreaming:
async def test_streaming_context_reports_streaming(self):
streaming_flag = None
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> int:
nonlocal streaming_flag
streaming_flag = ctx.is_streaming() # type: ignore[assignment]
@@ -491,7 +564,7 @@ class TestStepPassthrough:
class TestStateManagement:
async def test_get_set_state(self):
@workflow
@built_workflow
async def stateful_wf(x: int, ctx: RunContext) -> int:
ctx.set_state("counter", x)
return ctx.get_state("counter")
@@ -500,7 +573,7 @@ class TestStateManagement:
assert result.get_outputs() == [42]
async def test_get_state_default(self):
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> str:
return ctx.get_state("missing", "default_val")
@@ -521,7 +594,7 @@ class TestCheckpointing:
async def expensive(x: int) -> int:
return x * 100
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def ckpt_wf(x: int) -> int:
return await expensive(x)
@@ -539,7 +612,7 @@ class TestCheckpointing:
async def compute(x: int) -> int:
return x + 1
@workflow
@built_workflow
async def wf(x: int) -> int:
return await compute(x)
@@ -559,7 +632,7 @@ class TestCheckpointing:
call_count += 1
return x + 1
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def wf(x: int) -> int:
return await counting_task(x)
@@ -580,7 +653,7 @@ class TestCheckpointing:
async def test_checkpoint_hitl_resume(self):
storage = InMemoryCheckpointStorage()
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def hitl_wf(doc: str, ctx: RunContext) -> str:
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
return f"Done: {feedback}"
@@ -598,7 +671,7 @@ class TestCheckpointing:
assert result2.get_outputs() == ["Done: Approved!"]
async def test_checkpoint_without_storage_raises(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
return x
@@ -608,7 +681,7 @@ class TestCheckpointing:
async def test_checkpoint_preserves_state(self):
storage = InMemoryCheckpointStorage()
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def stateful_wf(x: int, ctx: RunContext) -> str:
ctx.set_state("key", "value")
feedback = await ctx.request_info("need info", response_type=str, request_id="r1")
@@ -648,7 +721,7 @@ class TestCheckpointing:
raise RuntimeError("simulated crash")
return x * 2
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def crash_wf(x: int) -> int:
a = await slow_step1(x)
return await crashing_step2(a)
@@ -687,7 +760,7 @@ class TestCheckpointing:
async def s3(x: int) -> int:
return x + 3
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def multi_step_wf(x: int) -> int:
a = await s1(x)
b = await s2(a)
@@ -708,7 +781,7 @@ class TestCheckpointing:
async def compute(x: int) -> int:
return x + 1
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def wf(x: int) -> int:
return await compute(x)
@@ -748,7 +821,7 @@ class TestControlFlow:
async def quarantine(text: str) -> str:
return f"quarantined: {text}"
@workflow
@built_workflow
async def email_pipeline(email: str) -> str:
cl = await classify(email)
if cl.is_spam:
@@ -775,7 +848,7 @@ class TestNestedWorkflows:
async def step_a(x: int) -> int:
return x + 1
@workflow
@built_workflow
async def inner_wf(x: int) -> int:
return await step_a(x)
@@ -784,7 +857,7 @@ class TestNestedWorkflows:
result = await inner_wf.run(x)
return result.get_outputs()[0]
@workflow
@built_workflow
async def outer_wf(x: int) -> int:
return await call_inner(x)
@@ -799,7 +872,7 @@ class TestNestedWorkflows:
class TestAsAgent:
async def test_as_agent_returns_agent(self):
@workflow
@built_workflow
async def wf(x: int) -> str:
return f"result: {x}"
@@ -807,7 +880,7 @@ class TestAsAgent:
assert agent.name == "wf"
async def test_as_agent_custom_name(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
return x
@@ -815,7 +888,7 @@ class TestAsAgent:
assert agent.name == "my_agent"
async def test_as_agent_run(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
return await add_one(x)
@@ -824,7 +897,7 @@ class TestAsAgent:
assert response.text == "11"
async def test_as_agent_run_streaming(self):
@workflow
@built_workflow
async def wf(x: int) -> str:
return f"result: {x}"
@@ -840,7 +913,7 @@ class TestAsAgent:
assert len(response.messages) >= 1
async def test_as_agent_has_id_and_description(self):
@workflow(description="A test workflow")
@built_workflow(description="A test workflow")
async def wf(x: int) -> int:
return x
@@ -856,7 +929,7 @@ class TestAsAgent:
class TestConcurrencyGuard:
async def test_concurrent_run_raises(self):
@workflow
@built_workflow
async def slow_wf(x: int) -> int:
await asyncio.sleep(0.1)
return x
@@ -872,7 +945,7 @@ class TestConcurrencyGuard:
await stream.get_final_response()
async def test_run_after_completion(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
return x
@@ -911,7 +984,7 @@ class TestDecoratorForms:
async def my_wf(x: int) -> None:
pass
assert isinstance(my_wf, FunctionalWorkflow)
assert isinstance(my_wf, FunctionalWorkflowDefinition)
assert my_wf.name == "my_wf"
def test_workflow_with_params(self):
@@ -919,7 +992,7 @@ class TestDecoratorForms:
async def my_wf(x: int) -> None:
pass
assert isinstance(my_wf, FunctionalWorkflow)
assert isinstance(my_wf, FunctionalWorkflowDefinition)
assert my_wf.name == "custom"
assert my_wf.description == "desc"
@@ -931,7 +1004,7 @@ class TestDecoratorForms:
class TestIncludeStatusEvents:
async def test_status_events_excluded_by_default(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
return x
@@ -940,7 +1013,7 @@ class TestIncludeStatusEvents:
assert len(status_in_list) == 0
async def test_status_events_included_when_requested(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
return x
@@ -956,7 +1029,7 @@ class TestIncludeStatusEvents:
class TestEdgeCases:
async def test_workflow_with_no_tasks(self):
@workflow
@built_workflow
async def no_tasks(x: int) -> int:
return x * 2
@@ -964,7 +1037,7 @@ class TestEdgeCases:
assert result.get_outputs() == [10]
async def test_workflow_with_no_output(self):
@workflow
@built_workflow
async def silent_wf(x: int) -> None:
pass # returns None — no output emitted
@@ -974,7 +1047,7 @@ class TestEdgeCases:
async def test_return_value_auto_yields_output(self):
"""Returning a non-None value automatically emits it as an output."""
@workflow
@built_workflow
async def wf(x: int) -> int:
return x * 3
@@ -982,7 +1055,7 @@ class TestEdgeCases:
assert result.get_outputs() == [15]
async def test_step_called_multiple_times(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
a = await add_one(x)
b = await add_one(a)
@@ -1005,7 +1078,7 @@ class TestEdgeCases:
class TestRecoveryAfterErrors:
async def test_run_after_failure_is_allowed(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
if x == 1:
raise RuntimeError("boom")
@@ -1036,7 +1109,7 @@ class TestWorkflowInterruptedIsBaseException:
"""User code with ``except Exception`` should not catch WorkflowInterrupted."""
caught = False
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> str:
nonlocal caught
try:
@@ -1064,7 +1137,7 @@ class TestCheckpointValidation:
storage = InMemoryCheckpointStorage()
@workflow(name="my_wf", checkpoint_storage=storage)
@built_workflow(name="my_wf", checkpoint_storage=storage)
async def wf(x: int) -> int:
return x
@@ -1108,7 +1181,7 @@ class TestExecutorBypassed:
call_count += 1
return x + 1
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def wf(x: int) -> int:
return await tracked(x)
@@ -1141,7 +1214,7 @@ class TestExecutorBypassed:
async def compute(x: int) -> int:
return x * 10
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def wf(x: int) -> int:
return await compute(x)
@@ -1169,7 +1242,7 @@ class TestRequestInfoInStep:
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="s1")
return f"reviewed: {feedback}"
@workflow
@built_workflow
async def wf(doc: str) -> str:
return await review_step(doc)
@@ -1205,7 +1278,7 @@ class TestRequestInfoInStep:
ctx.set_state("seen", data)
return f"{data}:{ctx.get_state('seen')}"
@workflow
@built_workflow
async def wf(data: str) -> str:
return await needs_ctx_first(data)
@@ -1225,7 +1298,7 @@ class TestRequestInfoInStep:
captured_ctx = get_run_context() # type: ignore[assignment]
return x
@workflow
@built_workflow
async def wf(x: int) -> int:
return await capture_ctx(x)
@@ -1249,7 +1322,7 @@ class TestNoneResponseHandling:
async def test_none_response_logs_warning(self):
"""Providing None as a response value should log a warning."""
@workflow
@built_workflow
async def wf(doc: str, ctx: RunContext) -> str:
val = await ctx.request_info("need input", response_type=str, request_id="r1")
return f"got: {val}"
@@ -1267,7 +1340,7 @@ class TestNoneResponseHandling:
async def test_none_response_is_returned(self):
"""None is a valid (if discouraged) response value."""
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> str:
val = await ctx.request_info("need data", response_type=str, request_id="r1")
return f"value={val}"
@@ -1322,7 +1395,7 @@ class TestHITLInStepWithCaching:
feedback = await ctx.request_info({"val": val}, response_type=str, request_id="r1")
return f"{val}:{feedback}"
@workflow
@built_workflow
async def wf(x: int) -> str:
a = await step_a(x)
return await step_b(a)
@@ -1353,7 +1426,7 @@ class TestHITLInStepWithCaching:
feedback = await ctx.request_info({"val": val}, response_type=str, request_id="rev")
return f"reviewed({val}):{feedback}"
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def wf(x: int) -> str:
v = await compute(x)
return await review(v)
@@ -1384,7 +1457,7 @@ class TestHITLInStepWithCaching:
val = await ctx.request_info({"doc": doc}, response_type=str, request_id="r1")
return f"got:{val}"
@workflow
@built_workflow
async def wf(doc: str) -> str:
return await needs_feedback(doc)
@@ -1403,7 +1476,7 @@ class TestHITLInStepWithCaching:
async def hitl_step(x: int, ctx: RunContext) -> str:
return await ctx.request_info("need data", response_type=str, request_id="r1")
@workflow
@built_workflow
async def wf(x: int) -> str:
return await hitl_step(x)
@@ -1422,7 +1495,7 @@ class TestDeterministicAutoRequestId:
"""Regression for bug_001: auto-generated request_info ids must be stable across replay."""
async def test_auto_request_id_roundtrips_on_resume(self):
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> str:
# No request_id — framework must generate a deterministic one
val = await ctx.request_info("need data", response_type=str)
@@ -1441,7 +1514,7 @@ class TestDeterministicAutoRequestId:
assert result2.get_outputs() == ["got:hello"]
async def test_multiple_auto_ids_are_distinct_and_stable(self):
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> str:
a = await ctx.request_info("first", response_type=str)
b = await ctx.request_info("second", response_type=str)
@@ -1468,7 +1541,7 @@ class TestDeterministicAutoRequestId:
async def second_review(value: int, ctx: RunContext) -> str:
return await ctx.request_info({"step": "second", "value": value}, response_type=str)
@workflow
@built_workflow
async def wf(value: int) -> str:
first = await first_review(value)
second = await second_review(value)
@@ -1495,7 +1568,7 @@ class TestPendingRequestsPruned:
async def test_final_checkpoint_no_longer_claims_resolved_requests_pending(self):
storage = InMemoryCheckpointStorage()
@workflow(checkpoint_storage=storage)
@built_workflow(checkpoint_storage=storage)
async def wf(x: int, ctx: RunContext) -> str:
a = await ctx.request_info("q1", response_type=str, request_id="r1")
b = await ctx.request_info("q2", response_type=str, request_id="r2")
@@ -1523,7 +1596,7 @@ class TestArityValidation:
return f"{a}+{b}"
async def test_ctx_only_workflow_with_message_raises_clear_error(self):
@workflow
@built_workflow
async def wf(ctx: RunContext) -> str:
return "no message used"
@@ -1535,7 +1608,7 @@ class TestArityValidation:
# message-receiving parameter. (Running it without a message still
# requires providing responses or a checkpoint_id — that's
# _validate_run_params's job, not ours.)
@workflow
@built_workflow
async def wf(ctx: RunContext) -> str:
return "ok"
@@ -1546,7 +1619,7 @@ class TestStaleResponsesRejected:
"""Regression for bug_014: stale responses after clean completion must be rejected."""
async def test_responses_after_clean_completion_raise(self):
@workflow
@built_workflow
async def wf(x: int) -> int:
return x * 2
@@ -1555,7 +1628,7 @@ class TestStaleResponsesRejected:
await wf.run(responses={"stale": "x"})
async def test_responses_mismatched_key_raises(self):
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> str:
return await ctx.request_info("q", response_type=str, request_id="r1")
@@ -1568,7 +1641,7 @@ class TestReservedStateKeys:
"""Regression for bug_017: set_state must reject underscore-prefixed keys."""
async def test_underscore_key_rejected(self):
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> int:
ctx.set_state("_private", "user value")
return x
@@ -1577,7 +1650,7 @@ class TestReservedStateKeys:
await wf.run(1)
async def test_normal_key_still_works(self):
@workflow
@built_workflow
async def wf(x: int, ctx: RunContext) -> int:
ctx.set_state("normal_key", "v")
assert ctx.get_state("normal_key") == "v"
@@ -1597,7 +1670,7 @@ class TestDeepcopyOnCacheHit:
async def takes_lock(lock: threading.Lock, n: int) -> int:
return n + 1
@workflow
@built_workflow
async def wf(x: int) -> int:
lock = threading.Lock()
return await takes_lock(lock, x)
@@ -1613,11 +1686,11 @@ class TestStepDiscoveryAttributeAccess:
"""Regression for bug_008: checkpoint hash must differ when function body changes."""
async def test_signature_hash_changes_when_function_body_changes(self):
@workflow
@built_workflow
async def wf_a(x: int) -> int:
return x + 1
@workflow(name="wf_b")
@built_workflow(name="wf_b")
async def wf_b(x: int) -> int:
return x * 100
@@ -1630,7 +1703,7 @@ class TestAsAgentSignatureParity:
"""Regression for bug_015: as_agent signature must accept description/context_providers."""
async def test_as_agent_accepts_description_override(self):
@workflow(description="workflow level")
@built_workflow(description="workflow level")
async def wf(x: str) -> str:
return x.upper()
@@ -1638,7 +1711,7 @@ class TestAsAgentSignatureParity:
assert agent.description == "agent level"
async def test_as_agent_accepts_context_providers_kwarg(self):
@workflow
@built_workflow
async def wf(x: str) -> str:
return x
@@ -1647,7 +1720,7 @@ class TestAsAgentSignatureParity:
assert list(agent.context_providers or []) == providers
async def test_as_agent_description_defaults_to_workflow_description(self):
@workflow(description="from workflow")
@built_workflow(description="from workflow")
async def wf(x: str) -> str:
return x
@@ -1659,7 +1732,7 @@ class TestFunctionalWorkflowAgentHITL:
"""Regression for bug_013: .as_agent() must surface request_info events."""
async def test_request_info_surfaces_as_function_approval_request(self):
@workflow
@built_workflow
async def wf(x: str, ctx: RunContext) -> str:
answer = await ctx.request_info({"need": x}, response_type=str, request_id="rid-1")
return f"got:{answer}"
@@ -1686,7 +1759,7 @@ class TestFunctionalWorkflowAgentHITL:
target_agent: str
reason: str
@workflow
@built_workflow
async def wf(x: str, ctx: RunContext) -> str:
answer = await ctx.request_info(
HandoffRequest(target_agent=x, reason="overflow"),
@@ -1712,7 +1785,7 @@ class TestFunctionalWorkflowAgentHITL:
assert json.loads(json.dumps(function_call_arguments)) == function_call_arguments
async def test_resume_via_agent_responses_kwarg(self):
@workflow
@built_workflow
async def wf(x: str, ctx: RunContext) -> str:
answer = await ctx.request_info(x, response_type=str, request_id="rid-1")
return f"got:{answer}"
@@ -1751,6 +1824,7 @@ class TestFunctionalWorkflowExperimentalStage:
StepWrapper,
step,
FunctionalWorkflow,
FunctionalWorkflowDefinition,
workflow,
FunctionalWorkflowAgent,
]