docs(graph): output is left absent after state_updates; retire the output reducer gotcha and recipe

Dark-Alex-17 committed 2026-09-10 18:50:33 -06:00
1 parent d6a754f6f5
commit 8788cbaa97
1 file changed
+36 -58
+36 -58
@@ -382,10 +382,11 @@ resolved strictly (a missing key fails the node before the child starts, with
type intact).
- Anything else (`"Repo: {{repo}}"`, `"{{a}}-{{b}}"`) renders to a string.
- **`null` is a value, not an absence.** A lone `"{{k}}"` whose parent value
is `null` overlays the child's default with `null`. Because `state_updates`
cleanup leaves `output: null` behind on the parent, `inputs: {x: "{{output}}"}`
after another node's `state_updates` is a footgun: the child gets
`x: null`, not its own default.
is `null` overlays the child's default with `null`. A **missing** parent key,
by contrast, fails the interpolation strictly: `inputs: {x: "{{output}}"}`
after a node whose `state_updates` did not name `output` errors (the
transient completion binding is removed again once its templates render)
instead of handing the child `x: null`.
**Two things are rejected**, both at load time by the validator and again at
runtime for graphs running with `validate_before_run: false`. The validator
@@ -1108,12 +1109,12 @@ offending node.
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` +
llm/agent/rag chain step whose `state_updates` don't name `output` does not
write it (the transient completion binding, see
[the implicit `output` key](#the-implicit-output-key-gotcha)), so the
collector errors if the chain never writes it. `output_key: finding` +
`state_updates: { finding: "{{output}}" }` never collides with the
completion binding and fails loudly when forgotten.
completion binding and reads naturally.
- Every other fork write (`draft`, `attempts`, `feedback`, ...) is
branch-local scratch: visible to later chain steps, gone at collect.
@@ -1586,8 +1587,9 @@ The load-time check covers **declared** writes only. The runtime applies the
same contention rule to every key a branch actually wrote (writes are
extracted by diffing branch state; see
[Branch isolation](#branch-isolation)), including keys the load-time
validator does not model. The most important of these is the implicit
`output` key:
validator does not model (for example, keys a script emits in its JSON
that no `state_updates` declare). One key that never joins on its own is
the implicit `output` key:
### The implicit `output` key (gotcha)
@@ -1595,9 +1597,10 @@ validator does not model. The most important of these is the implicit
(the raw text, or the extracted object when `output_schema` is set). The
binding is **transient**: the executor sets `output` to the completion,
renders every `state_updates` template, then puts `output` back to whatever
it was before the node ran, or to `null` if the key did not exist. An
LLM/agent/rag node therefore **never persists its completion under
`output`** on its own. If you want the completion to survive, name a key:
it was before the node ran, or removes it again if the key did not exist.
An LLM/agent/rag node therefore **never persists its completion under
`output`** on its own, and never writes `output` at all unless its
`state_updates` name it. If you want the completion to survive, name a key:
```yaml
state_updates:
@@ -1609,49 +1612,24 @@ Writing `output` explicitly also works (`state_updates: { output:
is the recommended pattern, especially for map branches (pair it with the
map's `output_key`, see [`map` nodes](#map)).
Two consequences of the restore step that bite in parallel super-steps:
Consequences of the restore step:
- A node with **no `state_updates`** writes nothing at all. A map branch
chain whose nodes declare no `state_updates`/`output_schema` at all
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. A chain node whose `state_updates` name
*other* keys leaves `output: null` instead, so a map with the default
`output_key: output` collects `null`. This is yet another reason to
- A node with **no `state_updates`** writes nothing at all, and a node whose
`state_updates` name *other* keys writes only those. A map branch chain
that never writes the map's `output_key` (the key is removed from each
item's fork before the chain starts, so nothing is inherited) therefore
fails the map at run time with `did not write output_key`, and the
validator warns about it at load time. This is yet another reason to
always declare a named `output_key` and write it explicitly.
- 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
then contend on `output` at the join, even though every declared key is
distinct:
```
Key 'output' was written by 5 parallel branches but has no reducer
declared. Add a reducer for 'output' to the graph's `reducers:` block, or
rename one writer.
```
When `output` already existed before the super-step, every branch restores
the same prior value, the state diff sees no write, and there is no
contention. The load-time validator only models declared write sets
(`state_updates` ∪ `output_schema`), so the `null` case surfaces at
**runtime, at the join**. The fix is one declaration at the graph root;
since the value being reduced is always `null`, `overwrite` is the right
choice:
```yaml
reducers:
output: overwrite # transient completion binding; always null after the join
```
Merge order is deterministic (branches sorted by
`(node_id, invocation_index)`), so `overwrite` is reproducible across runs.
Rule of thumb: **a parallel super-step containing 2+ LLM/agent/rag nodes
with `state_updates` needs a reducer for `output` only if `output` may be
absent from state when the super-step starts.** Seeding `output` in
`initial_state` (or having any earlier node with `state_updates` run
first, which leaves `output: null` behind) makes the reducer unnecessary.
- Parallel LLM/agent/rag branches whose `state_updates` name distinct keys
do **not** contend on `output` at the join: `output` is restored to
exactly what it was (or removed again), so the state diff sees no write
to it. No reducer for `output` is needed.
- A later node that interpolates `{{output}}` after such a step hits the
strict missing-key error instead of receiving the string `"null"`; a
**script** node reading `state["output"]` finds the key absent (read it
with a default, e.g. `state.get("output")`). `log_state_snapshots` shows
no `output` key after such nodes.
## Branch isolation
@@ -1665,9 +1643,9 @@ This means:
- Branches can freely mutate state **during** the super-step without
stepping on siblings. But every key a branch wrote participates in the
join, including the `output: null` residual an LLM/agent/rag node with
`state_updates` leaves behind when `output` was previously absent, so
any key written by 2+ branches needs a declared reducer (see
join, so any key written by 2+ branches needs a declared reducer. The
transient `output` binding is restored (or removed again) before the
diff, so it never counts as a write on its own (see
[the implicit `output` key](#the-implicit-output-key-gotcha))
- Race conditions are impossible since there is no shared mutable state during
the parallel phase