Subflows & Batch Subflows

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: crates/cheng-nodes/src/nodes/builtin/tools/sub_flow.rs, crates/cheng-nodes/src/nodes/builtin/tools/batch_sub_flow.rs, crates/cheng-core/src/domain/loop_container.rs, crates/cheng-core/src/domain/container.rs

There are two different ways to run a graph inside a graph, and they solve different problems:

Subflow nodeLoop container
What it runsAnother workflow, by id or pathA sub-graph drawn inside this workflow
ReuseShared across workflowsLocal to this one
Nodetools/subflow, tools/batch_subflowA container drawn on the canvas
Use whenThe logic is a reusable unitYou need to repeat local steps per item

Reach for a subflow when the callee has its own identity; reach for a loop when you just need the same few nodes run per item.

The subflow node

tools/subflow runs another workflow through the same Executor port the API uses — it does not reimplement execution — and returns its result.

InputMeaning
workflow_pathA workflow UUID, or the path to a JSON workflow file
input_dataData handed to the child
async_executiontrue returns as soon as the child starts; false (default) waits
timeout_secondsDefault 300, range 1–3600
max_parallelismNode parallelism inside the child, 1–100
response_pathDot-path (e.g. result.answer) to extract response from the child's outputs
context_pathDot-path to extract the child context
return_child_contextReturn the child's updated context (default on)
return_node_outputsInclude child node outputs in result.outputs (default on)
child_context_max_bytesInline cap, default 100 000; oversized contexts are summarised in diagnostics instead

Outputs are status, agent_context, elapsed_ms, summary, and error. When collapsed on the canvas the node shows only status and summary.

response_path and context_path are worth setting. Without them the node falls back to heuristics to find the "answer" among the child's node outputs; with them, extraction is exact.

Context priority

Three inputs can supply the child's context, and the highest one wins:

1. child_context     ← authoritative follow-up state from a previous subflow call
2. agent_context     ← upstream routing (typically wired from a condition router)
3. input_data.context ← manual passthrough

This ordering is what makes multi-turn delegation work: the parent passes the child's returned context back on the next call, and that state beats anything the routing layer supplies.

conversation_id is injected ambiently from the parent's execution scope, so the child shares the parent's conversation for turn lifecycle, approvals, and history-aware tools.

Recursion protection

Each call carries a call stack. Two guards, both hard failures rather than warnings:

The default recommended nesting depth is 5; 10 is the absolute ceiling.

Batch subflows

tools/batch_subflow runs many subflow invocations concurrently. Each item can name a different workflow with different inputs — it is not "the same workflow N times" unless you make it so.

LimitValue
Max items256
max_concurrency (batch level)1–64, default 4
max_parallelism (inside each child)1–100
timeout_seconds (per item)1–3600

max_concurrency and max_parallelism are different dials. The first is how many sub-workflows are in flight at once; the second is how many nodes run in parallel inside each one. Their product is your real concurrency, which is the number to think about when sizing. These limits are validated at runtime, not only in the schema, so an invalid value fails at the node rather than deep inside execution.

Failure semantics are collect-all. Every item runs to completion and results are then aggregated; there is no fail-fast option. Fail-fast needs cancellable execution handles and is deferred. The container reports status as completed, partial_failed, or failed, plus total / succeeded / failed counts and per-item results.

Results come back in input order, not completion order, so upstream data and downstream results stay aligned without a join step.

Two implementation properties that show up in behaviour: the batch node holds one long-lived subflow node internally, so its workflow cache is shared across batch runs instead of reloading each time; and iterations are polled in the current async stack rather than spawned, because the tool and execution contexts are borrowed references.

Loop containers

A loop container is a workflow structure, not a node. It wraps a sub-graph, runs it once per iteration, and aggregates the results into a container-level output.

In the outer DAG a container is scheduled as a single unit, referenced by a synthetic NodeId derived deterministically from the container id (UUID v5 with a fixed namespace). That determinism matters: persisted workflows and execution traces depend on the same container always mapping to the same node id, across processes and deployments.

Virtual ports

The loop/input boundary exposes fixed virtual ports to the inner nodes. They are not real nodes and never enter the DAG — the engine projects them per iteration:

PortMeaning
itemsThe source array (boundary input)
itemThe current item
item_textText projection of the current item
item_idStable id of the item, when present
item_pathPath of the item, for file references
indexZero-based index
iterationOne-based iteration number (= index + 1)
previousFor while loops: merged outputs of the previous iteration, Null on the first
conditionBoolean output the condition-source node must produce

Modes

ModeIterates
forEach (default)Over an array bound to a boundary input
countExactly N times, N read from a boundary input
whileWhile a condition is true, checked before each iteration
untilUntil a condition is true, checked after each iteration

For while, the condition-source node must be a member of the inner graph and output a boolean condition port. For until, it must be downstream of the body — and the iteration that triggers the stop is included in aggregation.

Concurrency and errors

SettingValues
Concurrencysequential (default), or parallel with maxParallelism in 2–32
Error policyfailFast (default), continue, collectErrors

maxParallelism: 1 is rejected rather than accepted — it degrades to sequential, so the validator makes you say sequential explicitly.

Parallel containers with approval-gated inner nodes require allowApprovals: true; without it the validator rejects the configuration instead of surprising you mid-run. And regardless of completion order, aggregation is finalised in iteration-index order — a parallel loop produces the same output array as a sequential one.

Nesting is capped hard: maximum depth 3, and at most 64 in-flight iterations across the entire nested loop tree, whatever the declared parallelism at each level.

collectErrors is the parallel counterpart of continue: every iteration runs to its own terminal state, the container fails if any did, but only after the queue is drained, and aggregation includes successful iterations only.

Only forEach + sequential + array aggregation is fully implemented. Other combinations exist as forward-compatible slots, and the executor rejects unsupported ones with a clear error rather than silently downgrading — so if a configuration is accepted, it is genuinely supported.

Observability

Containers emit CONTAINER_STARTED / CONTAINER_COMPLETED and LOOP_ITERATION_* events, so a batch of 100 items is observable per iteration instead of as one opaque node. See Traces, Logs & Replay.

Next steps