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).
|
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
|
||||||
|
|||||||
Reference in new issue
Block a user