add the async twin of Ts
This commit is contained in:
@@ -136,9 +136,11 @@ limits and intended adapter seam.
|
|||||||
includes node, condition, foreach, join, and interrupt steps; `Workflow` does
|
includes node, condition, foreach, join, and interrupt steps; `Workflow` does
|
||||||
not contain nested workflow/subgraph steps.
|
not contain nested workflow/subgraph steps.
|
||||||
- Nested subgraph interruption is not first-class yet. The current
|
- Nested subgraph interruption is not first-class yet. The current
|
||||||
`wf_authoring` subgraph helper wraps a child workflow as an ordinary node and
|
`wf_authoring` subgraph helpers wrap a child workflow as an ordinary sync or
|
||||||
validates the child output; it does not preserve a child run state that can
|
async node and validate the child output; they do not preserve a child run
|
||||||
interrupt, bubble to the parent, and later resume inside the child.
|
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
|
- Saved workflow-as-node execution with interrupts requires a core runtime
|
||||||
upgrade: nested run state, child-frame trace preservation, interrupt bubbling
|
upgrade: nested run state, child-frame trace preservation, interrupt bubbling
|
||||||
with path metadata, and resume back into the child workflow.
|
with path metadata, and resume back into the child workflow.
|
||||||
|
|||||||
@@ -637,11 +637,17 @@ diagnostics before attempting persistent nested resume.
|
|||||||
|
|
||||||
Native subgraphs are not in `wf_core` yet. The current core `Step` model has
|
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
|
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
|
current `wf_authoring.subgraph_node` and `async_subgraph_node` helpers execute
|
||||||
node and validates the child output. Future saved-workflow-as-node execution
|
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
|
needs a real child run state if child interrupts should pause the parent and
|
||||||
later resume the child.
|
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
|
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
|
interrupting saved workflow can safely be used as a child node. Top-level saved
|
||||||
workflows with interrupt nodes are valid, but nested interrupting workflows
|
workflows with interrupt nodes are valid, but nested interrupting workflows
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -66,12 +66,13 @@ from .nodes import (
|
|||||||
)
|
)
|
||||||
from .reducers import AuthoredReducer, ReducerCatalog, reducer
|
from .reducers import AuthoredReducer, ReducerCatalog, reducer
|
||||||
from .schemas import StateFieldMetadata, state_field
|
from .schemas import StateFieldMetadata, state_field
|
||||||
from .subgraph import subgraph_node
|
from .subgraph import async_subgraph_node, subgraph_node
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NodeCatalog",
|
"NodeCatalog",
|
||||||
"NodeCatalogEntry",
|
"NodeCatalogEntry",
|
||||||
"AuthoredReducer",
|
"AuthoredReducer",
|
||||||
|
"async_subgraph_node",
|
||||||
"BoolOutput",
|
"BoolOutput",
|
||||||
"CoalesceInput",
|
"CoalesceInput",
|
||||||
"ConstantInput",
|
"ConstantInput",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any, TypeVar
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
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
|
from .nodes import NodeSpec
|
||||||
|
|
||||||
@@ -22,6 +22,14 @@ def subgraph_node(
|
|||||||
output_model: type[OutputT],
|
output_model: type[OutputT],
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
) -> NodeSpec[InputT, OutputT]:
|
) -> 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:
|
def run_subgraph(payload: InputT, ctx: RuntimeContext) -> OutputT:
|
||||||
child_run = execute_workflow(
|
child_run = execute_workflow(
|
||||||
workflow,
|
workflow,
|
||||||
@@ -39,3 +47,38 @@ def subgraph_node(
|
|||||||
description=description or f"Subgraph wrapper for {workflow.name}",
|
description=description or f"Subgraph wrapper for {workflow.name}",
|
||||||
is_async=False,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from wf_authoring import build_registry, subgraph_node
|
from wf_authoring import (
|
||||||
from wf_core import RuntimeContext
|
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.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:
|
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["outcome"] == "ok"
|
||||||
assert result["output"]["email_status"] == "skipped"
|
assert result["output"]["email_status"] == "skipped"
|
||||||
assert "summary" in result["output"]
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user