add the async twin of Ts

This commit is contained in:
lda
2026-05-23 23:52:51 +07:00 Verified
parent 998b1943ba
commit 2dc00518b9
6 changed files with 280 additions and 9 deletions
+5 -3
View File
@@ -136,9 +136,11 @@ limits and intended adapter seam.
includes node, condition, foreach, join, and interrupt steps; `Workflow` does
not contain nested workflow/subgraph steps.
- Nested subgraph interruption is not first-class yet. The current
`wf_authoring` subgraph helper wraps a child workflow as an ordinary node and
validates the child output; it does not preserve a child run state that can
interrupt, bubble to the parent, and later resume inside the child.
`wf_authoring` subgraph helpers wrap a child workflow as an ordinary sync or
async node and validate the child output; they do not preserve a child run
state that can interrupt, bubble to the parent, and later resume inside the
child. See `examples/authoring_workflow_as_node.py` for the current
wrapper-node shape.
- Saved workflow-as-node execution with interrupts requires a core runtime
upgrade: nested run state, child-frame trace preservation, interrupt bubbling
with path metadata, and resume back into the child workflow.
+8 -2
View File
@@ -637,11 +637,17 @@ diagnostics before attempting persistent nested resume.
Native subgraphs are not in `wf_core` yet. The current core `Step` model has
node, condition, foreach, join, and interrupt steps, but no subgraph step. The
current `wf_authoring.subgraph_node` helper executes a child workflow as a plain
node and validates the child output. Future saved-workflow-as-node execution
current `wf_authoring.subgraph_node` and `async_subgraph_node` helpers execute
a child workflow as a plain node and validate the child output. The async helper
is explicit because hiding `asyncio.run()` inside the sync wrapper would break
inside already-running event loops. Future saved-workflow-as-node execution
needs a real child run state if child interrupts should pause the parent and
later resume the child.
See `examples/authoring_workflow_as_node.py` for the current wrapper-node
approach. In that example the parent trace sees one node call; the child
workflow's internal trace is not embedded in the parent run state.
Until that core upgrade exists, artifact tooling must not assume that an
interrupting saved workflow can safely be used as a child node. Top-level saved
workflows with interrupt nodes are valid, but nested interrupting workflows
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import sys
from pathlib import Path
from pydantic import BaseModel
# Keep this example runnable as either `python -m examples...` or a direct file.
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from wf_authoring import (
WorkflowBuilder,
input_from,
output_to,
state_path,
subgraph_node,
)
from wf_core import END
from wf_core.run_state import RunState
from examples.demo_workflow import build_demo_registry, build_demo_workflow
class ChildInput(BaseModel):
"""Input accepted by the wrapped child workflow."""
folder_id: str
should_email: bool
class ChildOutput(BaseModel):
"""Output exposed by the wrapped child workflow."""
summary: str
email_status: str
class ParentInput(BaseModel):
"""Input accepted by the parent workflow."""
folder_id: str
should_email: bool
class ParentState(BaseModel):
"""Parent state stores only the child workflow output fields it needs."""
summary: str = ""
email_status: str = ""
class ParentOutput(BaseModel):
"""Parent output projected from state after the wrapped child completes."""
summary: str
email_status: str
wrapped_demo_workflow = subgraph_node(
name="example.wrapped_demo_workflow",
workflow=build_demo_workflow(),
registry=build_demo_registry(),
input_model=ChildInput,
output_model=ChildOutput,
description=(
"Runs the demo child workflow as one parent node. Current wrapper "
"semantics do not embed the child trace or support child interrupts."
),
)
def build_parent_workflow() -> WorkflowBuilder:
"""Build a parent workflow that treats a whole child workflow as one node."""
graph = WorkflowBuilder(
name="workflow_as_node_parent",
input_schema=ParentInput,
state_schema=ParentState,
output_schema=ParentOutput,
)
child = graph.use(
wrapped_demo_workflow,
id="run_child",
input=[
input_from("input.folder_id", "folder_id"),
input_from("input.should_email", "should_email"),
],
output=[
output_to("summary", state_path("summary")),
output_to("email_status", state_path("email_status")),
],
)
graph.set_entry_point(child)
graph.connect(child, "ok", END)
return graph
def run_parent_workflow() -> RunState:
"""Run the parent workflow around the wrapped child workflow."""
return build_parent_workflow().execute(
{"folder_id": "demo-folder", "should_email": False}
)
def main() -> None:
"""Run the example directly from the command line."""
run = run_parent_workflow()
print(run.status.value, run.output)
if __name__ == "__main__":
main()
+2 -1
View File
@@ -66,12 +66,13 @@ from .nodes import (
)
from .reducers import AuthoredReducer, ReducerCatalog, reducer
from .schemas import StateFieldMetadata, state_field
from .subgraph import subgraph_node
from .subgraph import async_subgraph_node, subgraph_node
__all__ = [
"NodeCatalog",
"NodeCatalogEntry",
"AuthoredReducer",
"async_subgraph_node",
"BoolOutput",
"CoalesceInput",
"ConstantInput",
+44 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypeVar
from pydantic import BaseModel
from wf_core import RuntimeContext, Workflow, execute_workflow
from wf_core import RuntimeContext, Workflow, execute_workflow, execute_workflow_async
from .nodes import NodeSpec
@@ -22,6 +22,14 @@ def subgraph_node(
output_model: type[OutputT],
description: str | None = None,
) -> NodeSpec[InputT, OutputT]:
"""Wrap a compiled workflow as a sync authoring node.
This is deliberately only a wrapper-node compatibility helper: the parent
trace sees one node call, and child interrupts/frames are not promoted into
native parent subgraph state. Use `async_subgraph_node` when the wrapped
workflow registry contains async handlers.
"""
def run_subgraph(payload: InputT, ctx: RuntimeContext) -> OutputT:
child_run = execute_workflow(
workflow,
@@ -39,3 +47,38 @@ def subgraph_node(
description=description or f"Subgraph wrapper for {workflow.name}",
is_async=False,
)
def async_subgraph_node(
*,
name: str,
workflow: Workflow,
registry: Mapping[str, Any],
input_model: type[InputT],
output_model: type[OutputT],
description: str | None = None,
) -> NodeSpec[InputT, OutputT]:
"""Wrap a compiled workflow with async handlers as an async authoring node.
This keeps async explicit instead of calling `asyncio.run()` from the sync
wrapper, which would break inside already-running event loops. It is still
wrapper-node composition, not native subgraph execution.
"""
async def run_subgraph(payload: InputT, ctx: RuntimeContext) -> OutputT:
child_run = await execute_workflow_async(
workflow,
payload.model_dump(),
registry,
)
return output_model.model_validate(child_run.output)
return NodeSpec(
name=name,
input_model=input_model,
output_model=output_model,
outcomes=("ok",),
fn=run_subgraph,
description=description or f"Async subgraph wrapper for {workflow.name}",
is_async=True,
)
+109 -2
View File
@@ -1,10 +1,28 @@
from __future__ import annotations
import asyncio
from pydantic import BaseModel
from wf_authoring import build_registry, subgraph_node
from wf_core import RuntimeContext
from wf_authoring import (
WorkflowBuilder,
async_subgraph_node,
build_async_registry,
build_registry,
input_from,
input_path,
node,
output_to,
state_path,
subgraph_node,
)
from wf_core import END, RunStatus, RuntimeContext, execute_workflow_async
from examples.demo_workflow import build_demo_registry, build_demo_workflow
from examples.authoring_workflow_as_node import (
build_parent_workflow,
run_parent_workflow,
wrapped_demo_workflow,
)
def test_subgraph_node_wraps_compiled_workflow() -> None:
@@ -35,3 +53,92 @@ def test_subgraph_node_wraps_compiled_workflow() -> None:
assert result["outcome"] == "ok"
assert result["output"]["email_status"] == "skipped"
assert "summary" in result["output"]
def test_async_subgraph_node_wraps_async_compiled_workflow() -> None:
class ChildInput(BaseModel):
text: str
class ChildState(BaseModel):
text: str
class ChildOutput(BaseModel):
text: str
@node(name="child.async_upper", input_model=ChildInput, output_model=ChildOutput)
async def async_upper(payload: ChildInput) -> ChildOutput:
return ChildOutput(text=payload.text.upper())
child = WorkflowBuilder(
name="async_child",
input_schema=ChildInput,
state_schema=ChildState,
output_schema=ChildOutput,
)
upper = child.use(
async_upper,
id="upper",
input=[input_from(input_path("text"), "text")],
output=[output_to("text", state_path("text"))],
)
child.set_entry_point(upper)
child.connect(upper, "ok", END)
wrapped = async_subgraph_node(
name="wrapped_async_child",
workflow=child.compile(),
registry=build_async_registry(async_upper),
input_model=ChildInput,
output_model=ChildOutput,
)
parent = WorkflowBuilder(
name="async_parent",
input_schema=ChildInput,
state_schema=ChildState,
output_schema=ChildOutput,
)
step = parent.use(
wrapped,
id="run_async_child",
input=[input_from(input_path("text"), "text")],
output=[output_to("text", state_path("text"))],
)
parent.set_entry_point(step)
parent.connect(step, "ok", END)
run = asyncio.run(
execute_workflow_async(
parent.compile(),
{"text": "hello"},
build_async_registry(wrapped),
)
)
assert run.status == RunStatus.COMPLETED
assert run.output["text"] == "HELLO"
def test_workflow_as_node_example_runs_child_inside_parent_workflow() -> None:
run = run_parent_workflow()
assert run.status == RunStatus.COMPLETED
assert run.output["email_status"] == "skipped"
assert "Summary of demo-folder/meeting-notes.md" in run.output["summary"]
def test_workflow_as_node_example_parent_trace_is_one_node_call() -> None:
run = run_parent_workflow()
assert len(run.trace) == 1
assert run.trace[0].node_id == "run_child"
assert run.trace[0].step_type == "node"
assert run.trace[0].outcome == "ok"
def test_workflow_as_node_example_compiles_to_normal_node_use() -> None:
workflow = build_parent_workflow().compile()
node = workflow.nodes[0]
assert node.type == "node"
assert node.node == wrapped_demo_workflow.name
assert workflow.node_defs[0].name == wrapped_demo_workflow.name