# 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:

```text
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_path` is 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](/docs/exec-traces-logs).

## Next steps

- [Execution Model](/docs/exec-model) — how containers are scheduled in the outer DAG.
- [Variables & Data Mapping](/docs/editor-variables-and-mapping) — loop virtual inputs in the precedence order.
- [Traces, Logs & Replay](/docs/exec-traces-logs) — inspecting per-iteration results.
