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 node | Loop container | |
|---|---|---|
| What it runs | Another workflow, by id or path | A sub-graph drawn inside this workflow |
| Reuse | Shared across workflows | Local to this one |
| Node | tools/subflow, tools/batch_subflow | A container drawn on the canvas |
| Use when | The logic is a reusable unit | You 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.
| Input | Meaning |
|---|---|
workflow_path | A workflow UUID, or the path to a JSON workflow file |
input_data | Data handed to the child |
async_execution | true returns as soon as the child starts; false (default) waits |
timeout_seconds | Default 300, range 1–3600 |
max_parallelism | Node parallelism inside the child, 1–100 |
response_path | Dot-path (e.g. result.answer) to extract response from the child's outputs |
context_path | Dot-path to extract the child context |
return_child_context | Return the child's updated context (default on) |
return_node_outputs | Include child node outputs in result.outputs (default on) |
child_context_max_bytes | Inline 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:
- Depth cap: 10. Exceeding it aborts with the stack in the message.
- Cycle detection. If
workflow_pathis already on the stack, the call is refused — "Recursive workflow call detected" — with the stack printed.
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.
| Limit | Value |
|---|---|
| Max items | 256 |
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:
| Port | Meaning |
|---|---|
items | The source array (boundary input) |
item | The current item |
item_text | Text projection of the current item |
item_id | Stable id of the item, when present |
item_path | Path of the item, for file references |
index | Zero-based index |
iteration | One-based iteration number (= index + 1) |
previous | For while loops: merged outputs of the previous iteration, Null on the first |
condition | Boolean output the condition-source node must produce |
Modes
| Mode | Iterates |
|---|---|
forEach (default) | Over an array bound to a boundary input |
count | Exactly N times, N read from a boundary input |
while | While a condition is true, checked before each iteration |
until | Until 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
| Setting | Values |
|---|---|
| Concurrency | sequential (default), or parallel with maxParallelism in 2–32 |
| Error policy | failFast (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
- Execution Model — how containers are scheduled in the outer DAG.
- Variables & Data Mapping — loop virtual inputs in the precedence order.
- Traces, Logs & Replay — inspecting per-iteration results.