docs: map branches are per-item subgraph chains

Rewrite the map section for subgraph branches: field reference with the
templated max_concurrency, branch subgraph rules in user terms (incl. the
now-honored fallback and the load error for fallback: end_*), the output
contract (key removed from the fork, read-before-write outcomes, the null
residual with the default key), a worked spawn -> verify -> retry-or-accept
example, and what stays unsupported. Fix the routing table, dynamic fan-out
and troubleshooting sentences that said a branch cannot have next, and add
the chain-lifetime and accepted-then-unread notes to teammate messaging.
Dark-Alex-17 committed 2026-09-09 20:06:13 -06:00
1 parent 7e21409dc7
commit 8375bcc352
1 file changed
+240 -53
+240 -53
@@ -719,8 +719,14 @@ it replaces RRF and graph search is not applied.
Dynamic fan-out over a runtime-determined list. The `map` node reads a JSON
array out of state (via the `over:` template), spawns one parallel
sub-branch per item invoking the same branch node, and collects each
sub-branch's output into a state array in input order.
sub-branch per item, and collects each sub-branch's result into a state
array in input order.
Each sub-branch runs a **per-item chain** of nodes on a private fork of the
state. The chain starts at the node named by `branch:` (with the item bound
to `as:`) and follows that node's `next:`, script `_next`, and `fallback:`
edges until it reaches a node with no `next` and no `_next`. A single node
with no `next:` is still the simplest branch: a one-step chain.
This is how a graph agent runs "research these N subjects in parallel" when
N is determined at run-time. For a fixed-cardinality fan-out (you know the
@@ -733,19 +739,19 @@ fan_out_subjects:
type: map
over: "{{subjects}}" # required; template must resolve to a JSON array
as: subject # required; bound name inside each sub-branch
branch: research_subject # required; node id to invoke once per item
output_key: output # optional; state key the branch writes (default "output")
collect_into: research_results # required; state key to receive the array of outputs
max_concurrency: 5 # optional; overrides settings.max_concurrency
branch: research_subject # required; ENTRY node of the per-item chain
output_key: finding # optional; state key the chain writes (default "output")
collect_into: research_results # required; state key to receive the array of results
max_concurrency: 5 # optional; integer or "{{key}}" template
next: rank # required; where to go after the map collects
research_subject: # the branch node (atomic — runs N times)
research_subject: # chain step 1 (runs once per item)
id: research_subject
type: llm
prompt: "Research {{subject}} in depth."
state_updates:
output: "{{output}}" # branch must write to `output_key`
# Note: no `next:` on a map branch; map branches are atomic
finding: "{{output}}" # the chain must write `output_key`
# no `next:` here, so this is also the last step: the chain ends
```
### Field reference
@@ -755,42 +761,176 @@ research_subject: # the branch node (atomic — runs N times)
empty, the map runs zero sub-branches and writes `[]` to `collect_into`
(which is correct semantically. I.e. mapping over nothing yields nothing).
- **`as`:** The state key under which each item is bound inside its
sub-branch. The branch node then references it via `{{<as>}}` in its
prompt / templates.
- **`branch`:** The id of the node to invoke per item. The branch's
outputs across all sub-branches are aggregated into `collect_into`.
- **`output_key`:** The state key each sub-branch must write its result
to. Default `"output"`. The map executor reads this from each
sub-branch's state, extracts the value, and inserts it into the
`collect_into` array.
sub-branch. Every node in the chain can read it via `{{<as>}}` in its
prompt / templates (or from `GRAPH_STATE` in a script).
- **`branch`:** The id of the chain's **entry** node. The branch subgraph is
that node plus everything reachable from it through declared `next:` and
`fallback:` edges; the validator checks the whole subgraph (see
[Branch subgraph rules](#branch-subgraph-rules)).
- **`output_key`:** The state key whose value in the item's **final** fork
state becomes the item's result. Default `"output"`. The chain must
write it; see [Output contract](#output-contract).
- **`collect_into`:** The state key in the **parent's** state that
receives the array of all sub-branch outputs, **in input-list order
regardless of which sub-branch finished first**. This is the map's
user-visible determinism contract.
- **`max_concurrency`:** Per-map cap on concurrent sub-branches; falls
back to `settings.max_concurrency` (default 8) when unset. Must be
`>= 1` if set.
- **`max_concurrency`:** Cap on items in flight. One slot is held for an
item's **whole chain**, so the cap counts items, not steps. Falls back to
`settings.max_concurrency` (default 8) when unset. Accepts either an
integer (`>= 1`) or a `"{{key}}"` template that must resolve at run time
to a positive integer: a JSON number or a numeric string, so a script can
emit `{"review_budget": "3"}`. Anything else (`0`, `"high"`, a missing
key) fails the map node with an error naming the template and the value
it resolved to. A quoted string with no `{{...}}` in it (e.g. `"4"`) is
rejected at load time: write an integer or a template.
`settings.max_concurrency` itself cannot be templated.
- **`next`:** Required. Where the parent super-step continues after the
map collects.
### Branch node constraints
### Branch subgraph rules
Map branches are **atomic**. One node, one execution per item, no
chaining inside the branch. The validator enforces several rules at load
time:
At load time the validator computes each map's branch subgraph (the entry
node plus everything reachable through `next:` and `fallback:`) and
enforces these rules. Errors are reported on the map node and name the
offending node.
- Branch type must be `llm`, `agent`, `rag`, or `script`. Approval, input,
end, or another map node as the branch is a load-time error.
- Branch must **not** have `next:` declared. Anything chained off the
branch belongs after the map (downstream of the `collect_into` write).
- Branch's `state_updates` keys must be a subset of `output_key`. The
branch can write to its own `output_key` and nowhere else.
- Branch must **not** declare `output_schema`. Top-level schema-properties
would auto-merge into state, polluting it across N parallel branches.
Use explicit `state_updates: { output: "{{output}}" }` instead.
- **Node types.** Every node in the subgraph must be `llm`, `agent`, `rag`,
or `script`. An `approval`, `input`, `end`, or `map` node anywhere in the
subgraph is a load error. A chain ends by *omission*, never with an `end`
node, so the main-flow idiom `fallback: end_failure` is an error on a
branch node and gets a targeted hint: *a branch's `fallback` must stay
inside the branch subgraph*. **Before this release `fallback:` on a map
branch was accepted but never honored.** It is honored now, per item, so
either point it at a branch-local recovery node or remove it.
- **No fan-out inside a branch.** `next: [a, b]` on any subgraph node is a
load error. Restructure as one flattened map, or a second map after the
join.
- **Disjoint from the main flow.** No subgraph node may also be reachable
from `start` without entering a map branch (`next`, `fallback`, and
approval routes all count as main-flow edges). Such a node would run
standalone, with no `as` binding, and could route into the middle of a
chain. The error lists the shared node(s). A branch `next:` that escapes
to the main flow trips this rule, and if the escape path also reaches an
`end` (or approval/input/map) node the node-type rule fires for that node
too. Both errors are correct; fix the escaping edge. The check is static:
a main-flow *script* `_next` into a branch node is not detected, like any
other undeclared `_next`.
- **Writes are fork-local.** Chain nodes may use `state_updates` with any
keys, and `output_schema`. Everything they write lives in the item's fork
and is discarded at collect, except `output_key`.
- **Declared writer for `output_key` (warning).** If no node in the
subgraph declares a write to `output_key` (a `state_updates` key or an
`output_schema` property) the validator warns, because the map will error
at run time if the chain never writes it. Any `script` node in the
subgraph suppresses the warning: scripts can write any key.
- **No declared cycles.** `next:`/`fallback:` back to an earlier chain node
is a cycle and is rejected like any other declared cycle. Per-item loops
are script `_next` loops; see
[Retrying inside a branch](#retrying-inside-a-branch-with-_next).
- Two maps **may** share a branch subgraph. Forks are private, so a
per-item pipeline can be reused by several maps.
If a sub-branch fails to write `output_key`, the map node errors loudly
(no silent missing-output behavior).
### Output contract
- The item's result is the value of `output_key` in its **final** fork
state. Any chain node may write it; the last write wins.
- **The chain must write it.** The map removes `output_key` from each
item's fork before the chain starts, so a value the parent held is never
collected as if the item produced it. An item that finishes without
writing the key fails the map:
```
map node 'fan_out_subjects': sub-branch [1] did not write output_key 'finding';
write it via state_updates, output_schema, or a script's JSON output
(the parent's value is not inherited inside a map branch)
```
Writing a value equal to the parent's is fine: removal, not diffing,
decides whether the chain wrote.
- **Reading `output_key` before writing it** sees the key absent, and
where the read happens decides what that means: in a
`prompt`/`instructions` template it is a strict-interpolation error; in a
`state_updates` template it silently renders as `""`; in a script the key
is simply missing from `GRAPH_STATE`. The accumulator idiom
`output_key: summary` + `state_updates: { summary: "{{summary}}\n{{output}}" }`
therefore no longer prepends the parent's value. A per-item accumulator
seeded from the parent is cross-item coupling that maps don't offer; seed
per-item input through `as:` instead.
- **Prefer a named key.** With the default `output_key: output`, an
llm/agent/rag chain step whose `state_updates` don't name `output` leaves
`output: null` behind (the transient completion binding, see
[the implicit `output` key](#the-implicit-output-key-gotcha)) and `null`
is what gets collected, with no error. `output_key: finding` +
`state_updates: { finding: "{{output}}" }` never collides with the
completion binding and fails loudly when forgotten.
- Every other fork write (`draft`, `attempts`, `feedback`, ...) is
branch-local scratch: visible to later chain steps, gone at collect.
### Retrying inside a branch with `_next`
A chain loops only through a script's `_next`. Together with fork-local
scratch that gives each item its own retry loop, spawn, verify, then
retry or accept, with no coupling between items:
```yaml
review_each_file:
type: map
over: "{{files}}"
as: file
branch: review_file
output_key: verdict
collect_into: file_verdicts
max_concurrency: "{{review_budget}}"
next: aggregate
review_file: # step 1: do the work
type: agent
agent: file-reviewer
teammates: true
prompt: "Review {{file}}. Prior feedback (if any): {{retry_feedback}}"
state_updates:
draft: "{{output}}"
next: check_review
check_review: # step 2: verify it
type: llm
prompt: "Does this review of {{file}} meet the bar?\n\n{{draft}}"
output_schema:
type: object
properties:
pass: { type: boolean }
feedback: { type: string }
next: check_gate
check_gate: # step 3: route (deterministic, no LLM)
type: script
script: scripts/check_gate.py
```
`check_gate.py` reads `pass`, `feedback`, `draft`, and a `retry_count` it
keeps in fork state, then prints either
`{"_next": "review_file", "retry_feedback": "...", "retry_count": 2}` to
loop or `{"verdict": ...}` with no `_next` to end the chain. Seed
`retry_feedback` in `initial_state` (or with an upstream node): the first
`review_file` prompt reads it, and prompts fail strict interpolation on a
missing key.
Rules for the loop:
- `_next` must target a node inside the branch subgraph. Routing outside it
fails the item: `... routed to 'aggregate' which is outside the branch
subgraph rooted at 'review_file' (branch nodes: check_gate, check_review,
review_file). Script `_next` targets inside a map branch must stay within
the branch.`
- Each node's per-item visit count is bounded by
`settings.max_loop_iterations` on the fork; exceeding it fails the item
and names the node and the count. Keep your own budget (`retry_count`)
under that hard cap.
- Item errors are fail-fast and carry the chain position:
`map node 'review_each_file': sub-branch [2] failed at node 'check_review' (step 2): ...`.
- A `script` step that fails consults its `fallback:` (a branch-local node)
before failing the item, exactly like a main-flow script. An `llm` step's
`max_attempts` and `fallback:` work per item the same way.
### Empty `over`
@@ -800,11 +940,20 @@ an error. It's the correct semantic for "map over nothing yields
nothing". If you want to reject empty `over` as an error, gate it with a
script node upstream.
### Nested map nodes are not supported in v1
### Not supported in v1
A map branch cannot itself be another map node. The validator rejects this
at load time. If you need M * N parallel work, restructure to a single map
over a flattened list.
- **Fan-out or a nested `map` inside a branch subgraph.** The validator
rejects both at load time. If you need M * N parallel work, restructure
to a single map over a flattened list, or a second map after the join.
- **`end` nodes inside a branch.** Chains end by omission; `end` means
"the graph returns".
- **Per-item error policy** (`on_item_error`, skipping or `null`-filling a
failed item). One failing item fails the map.
- **Per-item timeouts.** `settings.timeout` applies to the whole
super-step; node-level `timeout`s (llm/agent/script) apply per step.
- **Shared state between items mid-run.** Items communicate via
[teammate messaging](#teammate-messaging-in-fan-outs); results merge at
collect.
---
@@ -914,6 +1063,11 @@ Nodes route via three mechanisms in priority order:
| `map` | **Yes** - `next` is where the parent super-step continues after the map collects. | Yes |
| `end` | No - terminal. | n/a |
Inside a map's branch subgraph the "needs `next`" column relaxes: a chain
node with no `next` (and no script `_next`) is the chain's last step, not
an error. Fan-out is never allowed inside a branch. See
[Branch subgraph rules](#branch-subgraph-rules).
### Failure-handling contract
| Node type | Success | Failure with `fallback` | Failure without `fallback` |
@@ -1007,15 +1161,26 @@ fan_out_subjects:
type: map
over: "{{subjects}}" # list determined at run-time
as: subject
branch: research_subject
branch: research_subject # entry node of the per-item chain
output_key: finding
collect_into: research_results # array, in input order
next: rank
```
The map executor spawns one sub-branch per `subjects` item, each
invoking `research_subject` with `{{subject}}` bound to the item.
Outputs are collected into `research_results` **in input order**
regardless of which sub-branch finished first.
The map executor spawns one sub-branch per `subjects` item. Each
sub-branch runs a per-item chain on its own fork of the state: it starts at
`research_subject` with `{{subject}}` bound to the item and follows that
node's `next:` / script `_next` / `fallback:` edges until a node with no
onward route ends the chain. The chain writes its result to `output_key`
(`finding`); those values are collected into `research_results` **in input
order** regardless of which sub-branch finished first, and every other
write the chain made is discarded with the fork.
A branch node may therefore have `next:`. A single node with no `next:` is
just the shortest chain. What a branch chain may *not* do is fan out,
contain another `map`, or end in an `end` node; see
[Branch subgraph rules](#branch-subgraph-rules) and
[Retrying inside a branch](#retrying-inside-a-branch-with-_next).
## Teammate messaging in fan-outs
@@ -1040,6 +1205,24 @@ Mechanics:
requires at least two members. Each fan-out gets its *own* registry: a
flagged map fan-out and a flagged agent node in the same super-step form
two separate teams and cannot message each other.
- **In a map, the identity is the item, not the node.** A map provisions
one identity and one inbox per item when *any* agent node in its branch
subgraph has `teammates: true` (and there are at least two items). That
identity lives for the item's **whole chain**: it is handed to each
`teammates: true` agent step in the chain (a second flagged agent step
speaks as the same teammate and drains the same inbox), it is never
handed to unflagged agent steps or to `llm`/`rag`/`script` steps, and it
is retired only when the chain ends -- on success, on error, and when an
agent step times out. Messages sent to an item while it is between agent
steps (running a gate script, say) wait in its inbox until its next
flagged agent step checks it.
- **Accepted-then-unread window.** Because the identity outlives the last
agent step, a message delivered after an item's *last* `teammates: true`
agent step but before its chain ends is accepted ("Message delivered to
teammate") and never read. With a single-node branch that window is
microseconds; with a chain it is the whole tail of the chain (gate
scripts, llm steps). Design teams so cross-talk happens during the agent
steps, not after them.
- **Pre-provisioned inboxes.** Every member's `(id, inbox)` pair is created
before any branch starts, so a fast sibling can message one that has not
started yet -- the message simply waits in its inbox.
@@ -1162,9 +1345,10 @@ map's `output_key`, see [`map` nodes](#map)).
Two consequences of the restore step that bite in parallel super-steps:
- A node with **no `state_updates`** writes nothing at all. A map branch
that omits `state_updates` produces whatever `output` the fork inherited
(usually `null`), not the completion. Always declare what the branch
writes.
chain that never declares a write to the map's `output_key` therefore
fails the map at run time (the key is removed from each item's fork
before the chain starts, so nothing is inherited), and the validator
warns about it at load time. Always declare what the branch writes.
- When `output` was **absent** from state before the super-step, every
LLM/agent/rag branch that has `state_updates` leaves `output: null`
behind (the restore falls back to `null`). Two or more such branches
@@ -1326,7 +1510,7 @@ for both branches to feed into a shared end node).
## Approval and input nodes are forbidden in fan-out
The validator rejects any approval or input node that's an immediate
fan-out target (`next: [approve, ...]` or as a map's `branch:`). The
fan-out target (`next: [approve, ...]` or anywhere in a map's branch subgraph). The
reason is UX: ten concurrent "type your answer" prompts would
fundamentally not work in a CLI. Put approval / input nodes **after**
the join, downstream of the merge.
@@ -1531,11 +1715,14 @@ startup.
- **Approval or input as a fan-out target**: the immediate target of a
`next: [...]` is an `approval` or `input` node. These would queue N
concurrent user prompts, which is a UX disaster. So they're rejected at load time
- **Map branch violates strict mode**: the validator enforces that a
`map` node's `branch:` target (1) is `llm`, `agent`, `rag`, or `script`
(not approval/input/end/another map); (2) has no `next:` declared;
(3) writes only to its `output_key` via `state_updates` (if any); (4)
has no `output_schema` declared
- **Map branch subgraph violation**: the validator checks every node
reachable from a `map` node's `branch:` entry through `next:`/`fallback:`
edges: (1) all must be `llm`, `agent`, `rag`, or `script` (no
approval/input/end/another map -- `fallback: end_*` on a branch node is
the usual culprit); (2) none may fan out with `next: [...]`; (3) none may
also be reachable from `start` on the main flow. A missing declared writer
for the map's `output_key` is a warning, not an error. See
[Branch subgraph rules](#branch-subgraph-rules)
- **Script in a parallel branch with no `state_updates`**: a script node
whose immediate predecessor is a fan-out has unknown writes from the
validator's perspective (scripts can emit arbitrary JSON to state).