Flows¶
A flow is a graph of nodes over one pydantic state. You write it once with Flow. The
local FlowRunner runs it on the executor; the
LangGraph and ADK compilers consume
the same validated graph.
flowchart LR
classify --> retrieve["retrieve<br/>Map: one call per plan step"]
retrieve --> merge["merge<br/>Join"]
merge --> reason
reason -- "needs_more (max 3 loops)" --> retrieve
reason -- done --> approve["approve<br/>pause"]
State and merge rules¶
class Hit(BaseModel):
source_id: str
text: str
class ResearchState(BaseModel):
question: str
plan_steps: list[dict[str, Any]] = Field(default_factory=list)
evidence: Annotated[list[Hit], Merge.keyed(by="source_id")] = Field(default_factory=list)
rounds: Annotated[int, Merge.sum] = 0
needs_more: bool = False
answer: str = ""
Each field declares how several writes in one superstep combine (unannotated: replace).
| Rule | Several updates in one superstep |
|---|---|
Merge.replace |
equal values → that value; different values → FlowError("conflicting writes to <field>") |
Merge.append |
list concatenation in update order |
Merge.sum |
added onto the current value |
Merge.keyed(by) |
a later item with the same key replaces the earlier one; first-seen order kept |
append and keyed extend with the elements when a value is a list, else add the value
itself. Update order is node name order (LangGraph's task order), then Map item
index, so with keyed the node whose name sorts last wins a shared key. Unknown keys raise
FlowError; the merged state is validated again with the model. Nodes of one superstep all
read the same input state: treat it as read-only and return updates.
Nodes¶
async def classify(state: ResearchState) -> dict[str, Any]:
steps = [
{"tool": name, "args": {"query": "returns"}}
for name in ("search_articles", "search_tickets")
]
return {"plan_steps": steps}
async def reason(state: ResearchState, ctx: ToolContext) -> dict[str, Any]:
model = node_model(ctx) # the runner's model, bound to this node
if model is None:
raise ConfigError("the reason node needs FlowRunner(model=...)")
request = LLMRequest(
messages=(
Message(role="system", content=node_prompt(ctx) or ""),
Message(role="user", content=f"{len(state.evidence)} sources"),
),
stage="reason",
)
reply = await model.generate(request)
first_round = state.rounds == 0
update: dict[str, Any] = {"rounds": 1, "needs_more": first_round, "answer": reply.text}
if first_round: # ask for one more source, then loop back to retrieval
update["plan_steps"] = [
*state.plan_steps,
{"tool": "search_articles", "args": {"query": "shipping"}},
]
return update
def approve_step(state: ResearchState) -> dict[str, str]:
return {"answer": f"{state.answer} (approved)"}
| Action | Runs | Writes |
|---|---|---|
fn(state) / fn(state, ctx) |
sync (in a worker thread) or async | the mapping it returns (or nothing for None) |
Call(tool, args=…, into=…) |
executor.run_spec; args may be fn(state) |
{into: value} |
Map(over, tool= / tool_from= / fn=, into=…) |
one call per item of the list field over |
one {into: value} per item |
Join(sources, fn=None) |
once nothing else is pending (fan-in) | what fn returns |
SubFlow(flow, input=…, into=…) |
another flow, as one node (sub-flows) | the child's result, mapped by into |
Map(tool=…) passes the item (or args_fn(item)); tool_from="tool" reads the tool name
from each item, arguments from item[args_from] (else the rest of the item); fn(item,
state, ctx) maps without a tool. on_error="fail" fails on the first failed item in item
order, "skip" drops it with a WARNING. Node options: concurrency (Map), timeout,
retries (executor backoff), pause, prompt, cache (node cache).
Building and validating¶
flow = (
Flow("research", state=ResearchState)
.node("classify", classify)
.node("retrieve", Map("plan_steps", tool_from="tool", into="evidence"), concurrency=8)
.node("merge", Join(["retrieve"]))
.node("reason", reason, prompt="Answer from the evidence only.")
.node("approve", approve_step, pause=lambda s: {"answer": s.answer})
.edge("classify", "retrieve")
.edge("retrieve", "merge")
.edge("merge", "reason")
.route(
"reason",
Route(lambda s: "retrieve" if s.needs_more else "approve", targets=["retrieve", "approve"]),
max_loops=3,
on_exhausted="approve",
)
.end("approve")
)
edge(src, [a, b]) runs a and b in one superstep; Route(fn, targets) picks nodes by
label (names, or label → node). validate() returns the FlowGraph, or one
FlowDefinitionError listing every problem:
| Checked | |
|---|---|
| names | the flow and every node match ^[A-Za-z_][A-Za-z0-9_]{0,63}$ |
| endpoints | every edge, route target, start, end and on_exhausted exists |
| exits | static edges or one route, not both; end nodes have no exit, others do |
| reachability | every node is reachable from start (default: the first node) |
| joins | each source is a predecessor |
| cycles | every cycle passes through a route with max_loops |
| state | Map.over, Map / Call targets are fields; aa_loop_counts, aa_visits are reserved |
Running¶
store = MemoryApprovalStore()
runner = FlowRunner(
Executor(registry), approval=DeferredApproval(store), model=CallableModel(answer)
)
paused = await runner.run(flow, {"question": "What does the returns policy allow?"})
if paused.pending is None or paused.checkpoint is None:
raise FlowError(f"expected the approval pause, got {paused.status}")
print(paused.status, paused.pending.tool, paused.pending.arguments)
done = await runner.resume(flow, paused.checkpoint, ApprovalDecision(approved=True))
print(done.status, done.steps, dict(done.visits))
print([hit.source_id for hit in done.state.evidence])
print(done.state.answer)
flowchart TB
F["frontier = {start}"] --> R["ready = non-join nodes<br/>(joins only when nothing else is left)"]
R --> G{"pause gates"}
G -- pending --> P["status paused + checkpoint"]
G -- denied --> D["status denied"]
G -- approved --> E["apply edited_args<br/>run ready nodes concurrently"]
E --> M["merge updates by rule"]
M --> N["next = rest + edges + route targets"]
N --> F
A route sees the superstep's input plus its own node's updates (not a sibling's).
Picking its own node, or a node visited before this superstep, counts a loop; past
max_loops it goes to on_exhausted or raises FlowLoopExceeded, as does max_steps.
FlowRunner(executor, *, …) |
Default | Meaning |
|---|---|---|
approval |
the executor's | port asked by pause gates |
hooks, sink |
(), the executor's |
node hooks (after the executor's), event sink |
max_steps |
100 |
supersteps per run |
concurrency |
none | cap on nodes per superstep |
model |
None |
given to nodes through node_model(ctx) |
checkpoint_key |
per process | key that seals checkpoints (bytes, 16+) |
seal_encoder, seal_decoder |
None |
seal (and load) state values of custom types: see Flow pause and resume |
trace_state |
False |
node.finished also carries updates (each changed field's value, JSON, 2 KB cap); the run viewer keeps them for that run |
Pause and resume¶
A pause node asks the port with ApprovalRequest(kind="node", tool="node:<flow>.<node>",
arguments=pause(state) or {}) before its superstep; nothing in that superstep runs first.
| Port answer | Result |
|---|---|
| approved | the node runs with ctx.approval set; edited_args is a state update applied first |
| denied (or no port) | status="denied", denied_at=<node> |
ApprovalPending |
status="paused", pending, checkpoint; nothing in that superstep ran |
ApprovalPending from inside a node also pauses the flow. Resuming re-runs only the paused
node, replays what it finished, and is sealed and single-use (details).
Inside a node¶
| Read | Gives |
|---|---|
ctx.call_id |
"<flow>:<node>:<visit>"; its tool calls are "…:<i>" |
node_prompt(ctx) |
the node's prompt (optimisers edit them with flow.with_prompts) |
node_model(ctx) |
the runner's model, instrumented by the executor (executor.model(model).bind(ctx); an InstrumentedModel is only bound, so each call is billed once) |
current_context() |
the node context while the node runs |
Events: flow.started, flow.finished (status: done, paused, denied, failed or
cancelled; steps), node.started, node.finished (changed: the field names it set),
node.failed (error_kind), node.paused (approval_id), run.started / run.finished
when the runner made the run.
A node error becomes FlowError(node=…) raised from the original; control flow passes
through. With trace_state the run recorder keeps state values
(secret-named keys masked, 2 KB each) without capture_content; compiled flows keep names.
Files¶
| File | Holds |
|---|---|
flow/spec.py, actions.py |
Flow builder; Call, Map, Join, Route, node_prompt, node_model |
flow/state.py |
merge rules, merge_rules, apply_updates |
flow/graph.py |
FlowGraph, NodeSpec, RouteSpec, validation |
flow/runner.py, _loop.py |
FlowRunner, FlowResult, FlowCheckpoint; the superstep loop |
flow/_nodes.py, _gates.py, _step.py, _invoke.py |
node visit, gates and resume answer, merge and routes, user code |