From 8788cbaa9732f7694ba23bd5524b0fd86fe6d0c4 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 18:50:33 -0600 Subject: [PATCH] docs(graph): output is left absent after state_updates; retire the output reducer gotcha and recipe --- Graph-Agents.md | 94 +++++++++++++++++++------------------------------ 1 file changed, 36 insertions(+), 58 deletions(-) diff --git a/Graph-Agents.md b/Graph-Agents.md index 6366703..e646313 100644 --- a/Graph-Agents.md +++ b/Graph-Agents.md @@ -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