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). type intact).
- Anything else (`"Repo: {{repo}}"`, `"{{a}}-{{b}}"`) renders to a string. - Anything else (`"Repo: {{repo}}"`, `"{{a}}-{{b}}"`) renders to a string.
- **`null` is a value, not an absence.** A lone `"{{k}}"` whose parent value - **`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` is `null` overlays the child's default with `null`. A **missing** parent key,
cleanup leaves `output: null` behind on the parent, `inputs: {x: "{{output}}"}` by contrast, fails the interpolation strictly: `inputs: {x: "{{output}}"}`
after another node's `state_updates` is a footgun: the child gets after a node whose `state_updates` did not name `output` errors (the
`x: null`, not its own default. 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 **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 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 seeded from the parent is cross-item coupling that maps don't offer; seed
per-item input through `as:` instead. per-item input through `as:` instead.
- **Prefer a named key.** With the default `output_key: output`, an - **Prefer a named key.** With the default `output_key: output`, an
llm/agent/rag chain step whose `state_updates` don't name `output` leaves llm/agent/rag chain step whose `state_updates` don't name `output` does not
`output: null` behind (the transient completion binding, see write it (the transient completion binding, see
[the implicit `output` key](#the-implicit-output-key-gotcha)) and `null` [the implicit `output` key](#the-implicit-output-key-gotcha)), so the
is what gets collected, with no error. `output_key: finding` + collector errors if the chain never writes it. `output_key: finding` +
`state_updates: { finding: "{{output}}" }` never collides with the `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 - Every other fork write (`draft`, `attempts`, `feedback`, ...) is
branch-local scratch: visible to later chain steps, gone at collect. 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 same contention rule to every key a branch actually wrote (writes are
extracted by diffing branch state; see extracted by diffing branch state; see
[Branch isolation](#branch-isolation)), including keys the load-time [Branch isolation](#branch-isolation)), including keys the load-time
validator does not model. The most important of these is the implicit validator does not model (for example, keys a script emits in its JSON
`output` key: that no `state_updates` declare). One key that never joins on its own is
the implicit `output` key:
### The implicit `output` key (gotcha) ### 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 (the raw text, or the extracted object when `output_schema` is set). The
binding is **transient**: the executor sets `output` to the completion, binding is **transient**: the executor sets `output` to the completion,
renders every `state_updates` template, then puts `output` back to whatever 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 it was before the node ran, or removes it again if the key did not exist.
LLM/agent/rag node therefore **never persists its completion under 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: `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 ```yaml
state_updates: 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 is the recommended pattern, especially for map branches (pair it with the
map's `output_key`, see [`map` nodes](#map)). 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 - A node with **no `state_updates`** writes nothing at all, and a node whose
chain whose nodes declare no `state_updates`/`output_schema` at all `state_updates` name *other* keys writes only those. A map branch chain
therefore fails the map at run time (the key is removed from each item's that never writes the map's `output_key` (the key is removed from each
fork before the chain starts, so nothing is inherited), and the validator item's fork before the chain starts, so nothing is inherited) therefore
warns about it at load time. A chain node whose `state_updates` name fails the map at run time with `did not write output_key`, and the
*other* keys leaves `output: null` instead, so a map with the default validator warns about it at load time. This is yet another reason to
`output_key: output` collects `null`. This is yet another reason to
always declare a named `output_key` and write it explicitly. always declare a named `output_key` and write it explicitly.
- When `output` was **absent** from state before the super-step, every - Parallel LLM/agent/rag branches whose `state_updates` name distinct keys
LLM/agent/rag branch that has `state_updates` leaves `output: null` do **not** contend on `output` at the join: `output` is restored to
behind (the restore falls back to `null`). Two or more such branches exactly what it was (or removed again), so the state diff sees no write
then contend on `output` at the join, even though every declared key is to it. No reducer for `output` is needed.
distinct: - 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
Key 'output' was written by 5 parallel branches but has no reducer with a default, e.g. `state.get("output")`). `log_state_snapshots` shows
declared. Add a reducer for 'output' to the graph's `reducers:` block, or no `output` key after such nodes.
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.
## Branch isolation ## Branch isolation
@@ -1665,9 +1643,9 @@ This means:
- Branches can freely mutate state **during** the super-step without - Branches can freely mutate state **during** the super-step without
stepping on siblings. But every key a branch wrote participates in the stepping on siblings. But every key a branch wrote participates in the
join, including the `output: null` residual an LLM/agent/rag node with join, so any key written by 2+ branches needs a declared reducer. The
`state_updates` leaves behind when `output` was previously absent, so transient `output` binding is restored (or removed again) before the
any key written by 2+ branches needs a declared reducer (see diff, so it never counts as a write on its own (see
[the implicit `output` key](#the-implicit-output-key-gotcha)) [the implicit `output` key](#the-implicit-output-key-gotcha))
- Race conditions are impossible since there is no shared mutable state during - Race conditions are impossible since there is no shared mutable state during
the parallel phase the parallel phase