8.1 KiB
wf_authoring Control Flow
Use this document when choosing how to wire branches with
WorkflowBuilder.
The authoring API intentionally separates different control-flow ideas instead of putting them all behind one overloaded method.
| Method | Use when | Creates condition nodes? |
|---|---|---|
branch |
an existing step already returned an outcome label | no |
handle |
several source/outcome pairs should go to one target | no |
match |
one state/input/context value should equal one of several values | yes |
when |
one boolean expression chooses between two targets | yes |
choose |
ordered boolean expressions choose the first matching target | yes |
route() still exists only as deprecated compatibility sugar. New code should
use match() or when() directly.
branch: Route Node Outcomes
Use branch() when a node already decides its own outcome.
router = g.use(classify_message)
send = g.use(send_email)
skip = g.use(skip_email)
fail = g.use(runtime_error)
branches = g.branch(
router,
{
"send": send,
"skip": skip,
"error": fail,
},
)
This only adds edges:
classify_message.send -> send_email
classify_message.skip -> skip_email
classify_message.error -> runtime_error
The return value is a BranchResult. It exposes the resolved source and lets
tests or later code retrieve targets by outcome:
assert branches.source is router
assert branches["send"] is send
handle: Shared Outcome Target
Use handle() when several steps should route the same kind of outcome to one
target.
fail = g.use(runtime_error)
errors = g.handle(
(lookup_user, "error"),
(charge_card, "error"),
(send_receipt, "error"),
to=fail,
)
This is not a join and it does not wait for multiple branches. It only writes edges:
lookup_user.error -> fail
charge_card.error -> fail
send_receipt.error -> fail
The return value is a HandleResult with the shared target and the resolved
source/outcome pairs.
match: Equality Dispatch
Use match() when one graph value chooses a target by equality.
decision = g.match(
state("status"),
{
"approved": approve,
"rejected": reject,
"pending": wait,
},
default=fail,
)
This lowers to an ordered chain of generated condition nodes:
if state.status == "approved": approve
elif state.status == "rejected": reject
elif state.status == "pending": wait
else: fail
Condition ids are source-derived by default, such as state_status,
state_status_2, and so on. Pass id="status_choice" when stable generated
ids matter.
The return value is a DecisionResult:
g.set_entry_point(decision.entry)
assert decision["approved"] is approve
assert decision["default"] is fail
when: Boolean Dispatch
Use when() when one boolean expression chooses between two targets.
decision = g.when(
state("retry_count").lt(3),
then=retry,
otherwise=fail,
)
This lowers to one condition node with true and false edges.
The return value is also a DecisionResult:
assert decision[True] is retry
assert decision[False] is fail
choose: Ordered Predicate Chain
Use choose() when the graph should try several boolean expressions in order
and route to the first true target.
decision = g.choose(
(state("score").ge(90), gold),
(state("score").ge(70), silver),
(state("score").ge(50), bronze),
default=fail,
id="score_tier",
)
This lowers to:
if state.score >= 90: gold
elif state.score >= 70: silver
elif state.score >= 50: bronze
else: fail
choose() is still one explicit call. Fluent or operator-heavy syntax can be
built on top later, but should delegate to this API rather than rebuilding edge
logic itself.
Defaults
match(), when(), and choose() default their fallback path to the standard
runtime_error node. This makes missing cases fail loudly instead of silently
ending or continuing with unclear state.
Pass an explicit default= or otherwise= when the fallback is valid business
logic.
NodeSpec Targets
connect(), branch(), handle(), match(), when(), and choose() accept
either existing step refs or NodeSpec objects as targets. Passing a NodeSpec
creates a fresh use() step with auto-mapping and an auto id.
Use existing step refs when the same node use should be shared. Pass a
NodeSpec when you want a new use at that point in the graph.
Concurrent foreach
Use foreach(mode="concurrent") when item lineages may make progress
independently but should still commit their state writes at one deterministic
barrier.
each = g.foreach(
id="each",
over=state_path("items"),
as_="item",
mode="concurrent",
concurrent={"max_active": 2, "max_outstanding": 2},
item_error={
"action": "collect",
"collect_to": state_path("errors"),
},
)
item_error is the canonical policy field. It accepts:
"fail"or"skip"when no extra policy fields are needed;- a mapping when fields such as
collect_toare needed; - the core
ForeachItemErrorPolicyobject.
item_error="collect" is intentionally incomplete and fails validation because
collect must say where error records should be written. on_item_error is
deprecated compatibility shorthand and should not appear in new examples.
Concurrent foreach is not a general fork/gather node. It is still one foreach step with item-local child lineages:
- each item sees its own buffered writes while it runs;
- sibling item writes do not leak into each other before the barrier;
- final barrier commits happen in item-index order;
- same-path sibling writes require a mergeable reducer on that exact state path;
item_error.action="collect"requirescollect_toto point at a declared array state field.
In async execution, admitted async item node handlers may run at the same time. Run-state mutation, tracing, and barrier commits remain deterministic.
An iteration body returns through its immediate owning foreach:
g.connect(each, "loop", record)
g.connect(record, "ok", each)
g.connect(each, "done", END)
g.connect(each, "completed_with_errors", END)
Region conflicts, unreachable nodes, body terminals, non-local returns, empty bodies, and bodies without possible returns fail validation.
See examples/authoring_concurrent_foreach.py for a runnable example covering:
- sync concurrent foreach with
item_error={"action": "collect", ...}; - async item-node batching with deterministic output order;
item_erroras a string, mapping, orForeachItemErrorPolicyobject.- the replace-conflict case when sibling item writes target a non-mergeable state path.
Structured foreach context
Nested bodies read every active same-scope iteration through structured paths. Prefer declared input bindings via the foreach reference:
orders = graph.foreach(
id="orders",
over=state_path("orders"),
as_="order",
)
charge = graph.use(
charge_order,
input=[input_from(orders.item, "order")],
)
graph.set_route(orders, "loop", charge)
graph.set_route(charge, "ok", orders)
The compiled binding is ordinary protocol data (context.foreach.orders.item).
Normal capabilities receive foreach values through declared inputs. Advanced
handlers may inspect ctx.foreach["orders"].index and stable runtime
identities (activation_id, frame_id, scope_id, lineage_id). Child
workflows do not inherit caller context and must receive input;
loop_item, loop_index, and aliases are migration conveniences.
Deprecated route
route() is a compatibility shim:
route(state("x"), {"a": step})forwards tomatch(...).route(state("x").exists(), {True: step})forwards towhen(...).
It emits a DeprecationWarning and should not appear in new examples.
Drafts
Workflow drafts currently expose only explicit outcome routes:
{
"routes": {
"classify": {
"send": "send_email",
"skip": "__end__"
}
}
}
Draft JSON does not yet have match, when, or choose sugar. Add that only
after the Python authoring surface stays stable enough to be mirrored.