docs(graph): output is left absent after state_updates; retire the output reducer gotcha and recipe
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
|
||||
|
||||
Reference in new issue
Block a user