fix(workflow): stop node state writes from being rolled back mid-run - #636
Conversation
AmaadMartin
left a comment
There was a problem hiding this comment.
The diagnosis is convincing and the mechanism works. I checked the load-bearing part: State.set writes to both value and delta (state.ts:49-52), so super.set populates the shared overlay and a later node's super.has finds an earlier node's write. toRecord composes in the right order.
I also checked the case your comment calls out — two invocations sharing a session object, where the guard evicts rather than coexists. It is not reachable: AgentTool builds a new Runner and re-fetches the session, and getSession hands back a fresh state object, so a sub-invocation keys a different WeakMap entry.
Two things inline, one of them one line. On the unrelated issue you found — the missing invocation filter in reconstructNodeStates — that is #637, already open.
| override set(key: string, value: unknown): void { | ||
| super.set(key, value); | ||
| // Keep writing through to the session so readers of `session.state` (agent | ||
| // instruction templates, callbacks, tools) see the write immediately, as | ||
| // they did before the overlay existed. | ||
| this.committed[key] = value; | ||
| } |
There was a problem hiding this comment.
Not a nit. State has two write paths and this overrides one of them.
override set(key: string, value: unknown): void {
super.set(key, value);
this.committed[key] = value;
}update() is the other one (state.ts:73), and it is public on ctx.state:
update(delta: Record<string, unknown>) {
Object.assign(this.delta, delta);
Object.assign(this.value, delta); // overlay only
}So ctx.state.update({...}) lands in the overlay and the node delta but never in session.state — the write-through your set comment protects, and which "writes remain visible on session.state for instruction templating" tests. That test passes because it uses set.
Nothing calls update on a NodeContext today, so this is latent. One line closes it:
override update(delta: Record<string, unknown>): void {
super.update(delta);
Object.assign(this.committed, delta);
}There was a problem hiding this comment.
Good catch — fixed in a00c183. Added the override exactly as you wrote it:
override update(delta: Record<string, unknown>): void {
super.update(delta);
Object.assign(this.committed, delta);
}Also moved the write-through comment above both overrides so it reads as a property of NodeStateView, not of set specifically — that framing is what let the gap through in the first place.
Pinned with a test (writes made through update() are visible the same way as set()) asserting both halves of the contract: a later node reads it back, and it lands on session.state. Verified it fails without the override (expected undefined to be 'oceans').
| override get<T>(key: string, defaultValue?: T): T | undefined { | ||
| if (super.has(key)) { | ||
| return super.get<T>(key, defaultValue); | ||
| } | ||
| return key in this.committed ? (this.committed[key] as T) : defaultValue; | ||
| } |
There was a problem hiding this comment.
Nit. The overlay also shadows writes coming the other way, which is worth naming next to the known residual.
Once a node writes k, every later read of k in this invocation is served from the overlay:
override get<T>(key: string, defaultValue?: T): T | undefined {
if (super.has(key)) {
return super.get<T>(key, defaultValue);
}
return key in this.committed ? (this.committed[key] as T) : defaultValue;
}A tool or callback that writes the same key straight to session.state mid-run is therefore invisible to later nodes, for the rest of the invocation. Before the overlay they saw it, subject to the race you are fixing.
This is the mirror of the residual you already document: that one is workflow writes not reaching an outside reader, this one is outside writes not reaching a workflow reader. Same root cause, same real fix (version-stamping). Worth a sentence in the same place, so the next person does not have to derive it.
There was a problem hiding this comment.
Agreed, and you framed it better than I would have. Documented both directions together in the NodeStateView doc comment (a00c183) rather than only the one I'd noticed:
* Two gaps remain. They are mirror images of each other, share the root cause
* above, and are both only really fixable at the source (version-stamping keys
* as the session services apply a delta), so neither is closed here:
*
* - Workflow writes still reach an outside reader out of order. ...
* - Outside writes no longer reach a workflow reader. Once a node writes `k`,
* every later read of `k` in this invocation is served from the overlay, so a
* tool or callback that writes `k` straight to `session.state` mid-run is
* invisible to those reads for the rest of the invocation. Before the overlay
* they saw it, subject to the rollback race being fixed.
Worth stating plainly that this second one is a behaviour change, not just an untouched gap: before the overlay those reads did see the outside write (racily). I think it's the right trade — a mid-run outside writer to a key the workflow also owns is already in undefined-ordering territory, whereas node-to-node read-modify-write is the documented pattern — but it is a trade, and the doc now says so. I'll also carry both into the PR description's Known residual section.
AmaadMartin
left a comment
There was a problem hiding this comment.
The core is sound: I checked the write-through (state.ts:49), the delta re-apply (base_session_service.ts:204), and the new state object each getSession returns. Keying the overlay on that object is the right scope, and it matches FunctionNode.attachedStateByCtx (function_node.ts:76). Two comments below: one write path still bypasses the overlay, and the invocation-id guard is untested. Windows CI fails on unsafe_local_code_executor_test.ts, which also fails on main. Please open a separate issue for the reconstructNodeStates gap.
| * Two gaps remain. They are mirror images of each other, share the root cause | ||
| * above, and are both only really fixable at the source (version-stamping keys | ||
| * as the session services apply a delta), so neither is closed here: |
There was a problem hiding this comment.
Not a nit. A third path misses the overlay: llm_agent_wrapper.ts:141 writes the delta directly.
if (agent.outputKey && output !== undefined) {
ctx.actions.stateDelta[agent.outputKey] = output;
}That write skips the overlay and skips session.state, so the next node reads undefined until the event commits. Use ctx.state.set(agent.outputKey, output), which writes the same delta key, plus the overlay and the session. The fault is older than this PR, and no workflow test covers outputKey. llm_agent.ts:708 repeats the pattern outside this package.
There was a problem hiding this comment.
Fixed in 5cc73fc — and you undersold it: that write does not just miss the overlay, it lands nowhere at all.
ctx.actions.stateDelta is only ever drained onto an event by FunctionNode.toEvent (function_node.ts:182, via pendingStateDelta). BaseNode.toEvent doesn't, enrichEvent doesn't, and nothing constructs a NodeContext with a shared actions, so for this node the object is write-only. Task-mode outputKey therefore reached neither the overlay, nor session.state, nor committed state — it was dropped, not merely late.
Measured with the new test, which asserts all three (later node reads it, it is on session.state, it is in the committed session):
| wrapper writes | later node | session.state |
committed |
|---|---|---|---|
ctx.actions.stateDelta[k] = v (before) |
✗ undefined | ✗ | ✗ |
ctx.state.set(k, v) (as suggested) |
✓ | ✓ | ✗ undefined |
| both (landed) | ✓ | ✓ | ✓ |
So it needs the write-through and the event stamp:
ctx.state.set(agent.outputKey, output);
event.actions.stateDelta[agent.outputKey] = output;Test: an agent node's outputKey is visible to the next node — a task-mode LlmAgent over a mock model that calls finish_task, feeding a node that reads the key. First workflow coverage of outputKey, as you said.
On llm_agent.ts:708: left alone deliberately, and it is not the same severity. I probed a single_turn agent node with an outputKey followed by a reader node — the reader does see the value today, because the runner commits the agent's event before the next node starts. That path is exposed to the commit-lag window (the first residual in the NodeStateView doc comment), not to a dropped write, and closing it properly is the version-stamping change rather than a one-liner here.
There was a problem hiding this comment.
Not a nit. I retract two parts of my comment above; your framing is correct.
The value never committed, so "reads undefined until the event commits" was wrong. Nothing drains ctx.actions for this node:
BaseNode.toEventnever reads it (base_node.ts:191).enrichEventonly writesagentState(node_runner.ts:250).- No caller passes
actionsto aNodeContext.
ctx.state.set alone was also incomplete — committed state would stay empty. The landed pair is right, and the new test pins all three effects.
| * `invocationId` guard covers the case where two invocations share a session | ||
| * object. |
There was a problem hiding this comment.
Nit. The guard covers sequential reuse only, not two live invocations.
const created = {invocationId: ic.invocationId, values: {}};
invocationOverlays.set(sessionState, created);Two invocations on one session object overwrite each other's entry, so both fall back to the old behaviour. Please narrow this sentence to "sequential invocations". Nothing tests the guard: runOnce makes a new session service per call, so the two runs never share a state object. Two NodeContexts over one session, with different invocation ids, would cover it.
There was a problem hiding this comment.
Right, the sentence claimed more than the code does. Narrowed in 5cc73fc:
* The `invocationId` guard covers *sequential* reuse of one state object: a
* later invocation replaces the entry rather than inheriting the earlier one's
* writes. It does not let two *live* invocations share a state object — they
* would evict each other's entry and fall back to reading committed state.
* Nothing does that today: `getSession` hands out a fresh state object per
* turn, and a sub-invocation (`AgentTool`) re-fetches the session, so it keys a
* different entry.
Also tested it, the way you described — two NodeContexts over one session object with different invocation ids (serves a second invocation over the same session from committed state). The one wrinkle is that a fresh overlay is invisible on its own, since the read just falls through to session.state and finds the same value the previous invocation wrote through. So the test first makes committed state disagree with the overlay (standing in for the runner re-applying a stale delta) and then pins both sides:
mkCtx().state.set('k', 'from-inv-1');
ic1.session.state['k'] = 'stale';
expect(mkCtx().state.get('k')).toBe('from-inv-1'); // same invocation: overlay wins
const ic2 = ic1.clone({invocationId: 'inv-2'});
expect(ic2.session.state).toBe(ic1.session.state); // same state object
expect(mkCtx(ic2).state.get('k')).toBe('stale'); // fresh overlay: committed stateFails without the guard (the second read returns from-inv-1).
A node that read a session-state key an earlier node had also written could
observe the earlier, already-superseded value:
a: set('attempts', 0)
b: get -> 0, set('attempts', 1)
c: get -> 0 <- wrong, b already set 1
`session.state` is written from two directions during a run. Nodes write
through it immediately (`State.set`), while the runner separately re-applies
each event's `actions.stateDelta` as it commits that event — and that commit
lags node execution by an event or two. Re-applying `a`'s delta therefore
rolled `b`'s write back, and `c` read during that window. Traced:
a: set 0
b: read 0, set 1
>>> appendEvent(author=a) re-applies attempts=0 <- rollback
c: read 0
>>> appendEvent(author=b) re-applies attempts=1 <- too late
The value converged afterwards, so committed state was already correct; only
the in-run reads were wrong. That made the failure silent and timing
dependent, and it only showed up for keys written by more than one node —
which is exactly the counter pattern the data-handling docs demonstrate.
Node reads are now served from a per-invocation write overlay that only ever
moves forward, with reads falling through to committed session state for
anything the run has not written. Writes still land in `session.state` too,
so consumers that read it directly — notably `{key}` instruction templating
in an agent node — are unaffected.
State has two write paths and the overlay only overrode one. update() landed in the overlay and the node delta but never in session.state, so a node writing via update() was invisible to instruction templating and other direct readers of the session. Override it alongside set(). Also document the mirror of the known residual: once a node writes a key, outside writes to that key on session.state are shadowed from later node reads for the rest of the invocation.
a00c183 to
8e050d7
Compare
A task-mode agent node wrote `outputKey` straight to `ctx.actions.stateDelta`, which no non-FunctionNode ever drains onto an event — so the write reached neither the invocation overlay, nor `session.state`, nor committed state. A downstream node read `undefined`, and the key never persisted at all. Write through `ctx.state` (overlay + session, for in-run readers) and stamp the delta on the event being emitted (for the commit). Also narrow the overlay's doc comment: the invocation-id guard covers sequential reuse of one state object, not two live invocations sharing one, and add a test for it.
|
Both comments addressed in 5cc73fc; replies inline.
On Windows
|
AmaadMartin
left a comment
There was a problem hiding this comment.
Both findings are closed at 5cc73fc, verified against source, and the fix commit is clean. The narrowed doc matches the code, and the new guard test uses two invocation ids over one shared session.state. The outputKey fix writes the overlay, session.state, and the event delta; no test covered task mode before, so nothing depended on the old path. Leaving llm_agent.ts:708 alone is right — that write reaches the event, so it is late, not lost. I am not approving only because run-tests (windows-latest) is red; zero tests failed there and the A2A suite's server exited at startup, so please re-run that job.
|
Windows is green on the re-run — every check on the PR now passes. Confirming the flake was what you read it as: the failed run had i.e. the spawned server never came up; ubuntu and macOS ran the same suite fine on the same commit, and the previous commit on this branch (8e050d7) passed on Windows too. Nothing in the diff goes near that path. Re-ran job: https://github.com/google/adk-js/actions/runs/31448921496/job/93981457475 — pass, 10m22s. Ready for another look whenever you have a moment. |
AmaadMartin
left a comment
There was a problem hiding this comment.
Approved at 5cc73fc.
Both findings are closed. llm_agent_wrapper.ts:147-148 now writes through ctx.state.set and stamps event.actions.stateDelta, and the doc matches the guard.
One correction to my earlier review: I wrote that the value read undefined until the event commits. That was wrong. BaseNode.toEvent never reads ctx.actions, so the value was dropped, not late. My one-line fix would have left committed state empty. Your framing was correct.
run-tests (windows-latest) failed three different ways across four runs on this branch and main. It passes on the re-run, so the job is unstable and the failures are not from this diff.
…he read The third stale claim of this review, and the same shape as the other two: a sample working around a framework bug that has since been fixed on main. The session_state port carried a "do not read-modify-write ONE key from several nodes" gotcha, and routed `attempts` along the edges as node output to avoid it. #636 landed that fix — node reads are now served from a per-invocation write overlay — so the warning describes a bug that no longer exists and the workaround is no longer buying anything. `attempts` goes back to being a state key that one node initializes, another increments and a third reads, which is what the Python snippet does and what its inline comment claims it prints. Confirmed against both sides of the fix rather than assuming: reverting #636's `node_context.ts` makes the third node read 0, and with it in place the sample prints `attempts state: 1` — the snippet's own documented output. Drops the README gotcha section with it, and keeps the surviving half of the advice — prefer an edge when only the next node needs the value — as guidance in the sample rather than as a warning about a defect.
…he read The third stale claim of this review, and the same shape as the other two: a sample working around a framework bug that has since been fixed on main. The session_state port carried a "do not read-modify-write ONE key from several nodes" gotcha, and routed `attempts` along the edges as node output to avoid it. #636 landed that fix — node reads are now served from a per-invocation write overlay — so the warning describes a bug that no longer exists and the workaround is no longer buying anything. `attempts` goes back to being a state key that one node initializes, another increments and a third reads, which is what the Python snippet does and what its inline comment claims it prints. Confirmed against both sides of the fix rather than assuming: reverting #636's `node_context.ts` makes the third node read 0, and with it in place the sample prints `attempts state: 1` — the snippet's own documented output. Drops the README gotcha section with it, and keeps the surviving half of the advice — prefer an edge when only the next node needs the value — as guidance in the sample rather than as a warning about a defect.
…oc snippets (#634) * docs(workflow): add runnable ports of the graph-workflow doc snippets The Python snippets on https://adk.dev/graphs/ have no TypeScript counterpart, and they are fragments: they reference helpers they never define (`condition()`, `task_A_node`, …), so they cannot be run as written even in Python. A TS reader has nothing to copy from and no way to check that the concept behaves the way the page claims. Adds 26 runnable ports, one directory per snippet, grouped by the docs page it comes from so a directory maps 1:1 to a section anchor on adk.dev: graphs/ get_started, process_pipeline routes/ sequence, branches, function_node, fan_out_join, loop_escalation, nested_workflow data_handling/ node_output, routing_output, schemas, session_state, structured_access, structured_output, user_message dynamic/ get_started, nodes, custom_run_ids, data_handling, human_input, loop_route, parallel_route, sequence_route human_input/ get_started, initial_prompt, payload_and_schema Each fills in the undefined helpers with the smallest plausible implementation and says so in its header. Where TypeScript genuinely diverges from the Python API the file comments say why, so a reader porting from the docs is not left guessing — for example Python's `Event(message=...)` has no TS equivalent, and a graph's validating schema belongs on the node wrapping an agent rather than on the agent itself. 18 of the 26 run with no API key, which keeps the concepts (routing, loops, fan-out/join, dynamic dispatch, human-in-the-loop) explorable offline. * ci(samples): type-check samples/ in CI samples/ is not an npm workspace, so "npm run build" never compiled it, and the lint job uses tseslint's non-type-aware recommended config. That left the sample sources backing the docs pages with nothing in CI that would catch a renamed type or a removed export as the @experimental workflow API moves. Add samples/tsconfig.json (the same extends-the-root pattern core, dev and integrations use), a "ts:check:samples" script, and a validation.yaml step that runs it after the build. Scoped to samples rather than the existing repo-wide "ts:check", which currently reports 288 pre-existing errors across 44 test files. * docs(workflow): correct two wrong claims in the sample comments Both were review findings, and both were wrong about the framework rather than about the samples. The dynamic HITL sample said the `rerun_on_resume=False` handoff -- "do not re-run on resume; complete with the human's reply as my output" -- was implemented for static graph nodes only, so its leaf used a re-entry form instead: a stable `interruptId` plus a `ctx.resumeInputs[id]` lookup that returns the reply on the second pass. #635 added that handoff for dynamic `ctx.runNode` children (`dynamic_node_scheduler.ts:134`, `resumeHandoff`), so the claim went stale in the same branch that now carries the sample. The leaf is the doc's `rerun_on_resume=False` one-liner again, which is both the faithful port and four fewer concepts to explain. The node_output sample cautioned that a node may emit only ONE event carrying `output`. Nothing enforces that: `node_runner.ts:234` assigns `child.output = event.output` for every event, so the last one silently wins and the successor never sees the rest. That is worth stating precisely, because the Python page gives two accounts and neither is what happens here -- each `yield` "adds to a list of data objects on the Event" under Node output, and two yields carrying `Event.output` are "a runtime error" under the structured-data caution. Recorded as a Python-to-TypeScript difference in the README rather than only in the sample. Verified both by running them, not by reading: a node yielding two `output` events hands the successor the second and raises nothing, and the reworked HITL leaf pauses on turn 1 and resolves "yes" to "Approved" on turn 2. * docs(workflow): stop coercing inputs that are already typed as strings Review finding: the samples were split on how they treat the workflow input. Eleven files wrapped it in `String(...)`; eight called `.trim()` or `.toUpperCase()` straight on a parameter already declared `string`. `extractWorkflowInput` (`workflow_agent.ts:187`) returns the message text for a text-only turn and the raw `Content` for anything else, so neither form is sound for a non-text turn -- but they fail differently. `String()` turns a `Content` into `"[object Object]"` and carries it happily through the graph; the bare call throws where the mistake is. Keep the one that fails loudly, and say so in the README so a reader copying a sample knows what it assumes. Coercion stays where the value genuinely is untyped: `ctx.runNode(...).output` and a `ctx.resumeInputs[id]` reply are both `unknown`, and the samples that read them keep converting explicitly at the point of use. * test(workflow): execute the docs samples instead of only compiling them Review finding: eslint, Prettier, check_license.sh and the new `tsc` step all read `samples/`, so a syntax, style, license or type error in these 26 files fails CI. Nothing ran them, which left the failure they are most exposed to uncovered: a `WorkflowAgent` validates its graph in its constructor, so a rename or a semantics change in the `@experimental` workflow API can turn a sample into a load-time error that still type-checks -- and #635, #637 and #647 all moved that API while this branch was open. Every sample is now constructed, and the 18 that call no model are also run end-to-end through a real `InMemoryRunner`. Reuses the existing sample harness in `offline` mode, which installs the record/replay model over an empty response set, so an "offline" sample that starts calling a model throws rather than reaching the network. The 8 model-backed samples are constructed only: driving them means a checked-in fixture each, and what they add over the sibling `tests/integration/workflows/` set is prompt wording, not graph shape. One table drives it, and a guard test asserts the table matches the directories on disk -- otherwise a new sample silently gets no coverage, which is the hole this closes. Checked against all three failures it is meant to catch, rather than assuming a passing suite means a working one: a duplicate node name (constructor validation) fails the sample's case, an unregistered new directory fails the guard, and an LlmAgent spliced into an offline graph fails on the missing fixture. * docs(workflow): restore the state-based counter now that #636 fixed the read The third stale claim of this review, and the same shape as the other two: a sample working around a framework bug that has since been fixed on main. The session_state port carried a "do not read-modify-write ONE key from several nodes" gotcha, and routed `attempts` along the edges as node output to avoid it. #636 landed that fix — node reads are now served from a per-invocation write overlay — so the warning describes a bug that no longer exists and the workaround is no longer buying anything. `attempts` goes back to being a state key that one node initializes, another increments and a third reads, which is what the Python snippet does and what its inline comment claims it prints. Confirmed against both sides of the fix rather than assuming: reverting #636's `node_context.ts` makes the third node read 0, and with it in place the sample prints `attempts state: 1` — the snippet's own documented output. Drops the README gotcha section with it, and keeps the surviving half of the advice — prefer an edge when only the next node needs the value — as guidance in the sample rather than as a warning about a defect. * ci(samples): keep samples resolving @google/adk through node_modules The samples config inherits the root one, so once #648 adds the `@google/adk` -> `core/src` aliases there, `npm run ts:check:samples` would start checking the samples against the workspace sources instead of the published types — the one thing a sample should not do, since a user's project resolves the package through `node_modules`. `"paths": {}` pins that, the same reset `core`, `dev` and `integrations` already carry. No-op against the root config as it stands today: the check resolves to `core/dist/types/index.d.ts` and passes either way. * ci(samples): keep the repo-wide type check out of samples/ Fallout from rebasing onto #648, which landed the repo-wide `ts:check` while this branch was open. The root config names no `include` and excludes only `node_modules` and `**/dist`, so `tsc --noEmit` now picks up all 26 sample files — and resolves their `@google/adk` imports through the root `paths` aliases, against `core/src`. That is the one resolution a sample must not use, which is the whole point of the `"paths": {}` reset in `samples/tsconfig.json`: a sample is a consumer of the published package, so it has to resolve the way a user's project does, through `node_modules` and against the built types. With both checks running, the scoped one did that and the repo-wide one quietly did the opposite over the same files. Excluding `samples` from the root config leaves one owner. Verified on both sides: `tsc --noEmit --listFiles` now reports 0 files under `samples/` and still passes, while `tsc -p samples --listFiles` reports all 26 and resolves `@google/adk` to `core/dist/types/index.d.ts`. The `validation.yaml` collision #648 was warned about resolved as both steps, not one: `ts:check` for the repo, `ts:check:samples` for the samples. The zizmor hardening on that file (`permissions`, `persist-credentials`, the three SHA pins) came in with #648, so that commit dropped out of this branch as already upstream.
…oogle#636) * fix(workflow): stop node state writes from being rolled back mid-run A node that read a session-state key an earlier node had also written could observe the earlier, already-superseded value: a: set('attempts', 0) b: get -> 0, set('attempts', 1) c: get -> 0 <- wrong, b already set 1 `session.state` is written from two directions during a run. Nodes write through it immediately (`State.set`), while the runner separately re-applies each event's `actions.stateDelta` as it commits that event — and that commit lags node execution by an event or two. Re-applying `a`'s delta therefore rolled `b`'s write back, and `c` read during that window. Traced: a: set 0 b: read 0, set 1 >>> appendEvent(author=a) re-applies attempts=0 <- rollback c: read 0 >>> appendEvent(author=b) re-applies attempts=1 <- too late The value converged afterwards, so committed state was already correct; only the in-run reads were wrong. That made the failure silent and timing dependent, and it only showed up for keys written by more than one node — which is exactly the counter pattern the data-handling docs demonstrate. Node reads are now served from a per-invocation write overlay that only ever moves forward, with reads falling through to committed session state for anything the run has not written. Writes still land in `session.state` too, so consumers that read it directly — notably `{key}` instruction templating in an agent node — are unaffected. * fix(workflow): route ctx.state.update through the session write-through State has two write paths and the overlay only overrode one. update() landed in the overlay and the node delta but never in session.state, so a node writing via update() was invisible to instruction templating and other direct readers of the session. Override it alongside set(). Also document the mirror of the known residual: once a node writes a key, outside writes to that key on session.state are shadowed from later node reads for the rest of the invocation. * fix(workflow): route an agent node's outputKey through ctx.state A task-mode agent node wrote `outputKey` straight to `ctx.actions.stateDelta`, which no non-FunctionNode ever drains onto an event — so the write reached neither the invocation overlay, nor `session.state`, nor committed state. A downstream node read `undefined`, and the key never persisted at all. Write through `ctx.state` (overlay + session, for in-run readers) and stamp the delta on the event being emitted (for the commit). Also narrow the overlay's doc comment: the invocation-id guard covers sequential reuse of one state object, not two live invocations sharing one, and add a test for it.
…oc snippets (google#634) * docs(workflow): add runnable ports of the graph-workflow doc snippets The Python snippets on https://adk.dev/graphs/ have no TypeScript counterpart, and they are fragments: they reference helpers they never define (`condition()`, `task_A_node`, …), so they cannot be run as written even in Python. A TS reader has nothing to copy from and no way to check that the concept behaves the way the page claims. Adds 26 runnable ports, one directory per snippet, grouped by the docs page it comes from so a directory maps 1:1 to a section anchor on adk.dev: graphs/ get_started, process_pipeline routes/ sequence, branches, function_node, fan_out_join, loop_escalation, nested_workflow data_handling/ node_output, routing_output, schemas, session_state, structured_access, structured_output, user_message dynamic/ get_started, nodes, custom_run_ids, data_handling, human_input, loop_route, parallel_route, sequence_route human_input/ get_started, initial_prompt, payload_and_schema Each fills in the undefined helpers with the smallest plausible implementation and says so in its header. Where TypeScript genuinely diverges from the Python API the file comments say why, so a reader porting from the docs is not left guessing — for example Python's `Event(message=...)` has no TS equivalent, and a graph's validating schema belongs on the node wrapping an agent rather than on the agent itself. 18 of the 26 run with no API key, which keeps the concepts (routing, loops, fan-out/join, dynamic dispatch, human-in-the-loop) explorable offline. * ci(samples): type-check samples/ in CI samples/ is not an npm workspace, so "npm run build" never compiled it, and the lint job uses tseslint's non-type-aware recommended config. That left the sample sources backing the docs pages with nothing in CI that would catch a renamed type or a removed export as the @experimental workflow API moves. Add samples/tsconfig.json (the same extends-the-root pattern core, dev and integrations use), a "ts:check:samples" script, and a validation.yaml step that runs it after the build. Scoped to samples rather than the existing repo-wide "ts:check", which currently reports 288 pre-existing errors across 44 test files. * docs(workflow): correct two wrong claims in the sample comments Both were review findings, and both were wrong about the framework rather than about the samples. The dynamic HITL sample said the `rerun_on_resume=False` handoff -- "do not re-run on resume; complete with the human's reply as my output" -- was implemented for static graph nodes only, so its leaf used a re-entry form instead: a stable `interruptId` plus a `ctx.resumeInputs[id]` lookup that returns the reply on the second pass. google#635 added that handoff for dynamic `ctx.runNode` children (`dynamic_node_scheduler.ts:134`, `resumeHandoff`), so the claim went stale in the same branch that now carries the sample. The leaf is the doc's `rerun_on_resume=False` one-liner again, which is both the faithful port and four fewer concepts to explain. The node_output sample cautioned that a node may emit only ONE event carrying `output`. Nothing enforces that: `node_runner.ts:234` assigns `child.output = event.output` for every event, so the last one silently wins and the successor never sees the rest. That is worth stating precisely, because the Python page gives two accounts and neither is what happens here -- each `yield` "adds to a list of data objects on the Event" under Node output, and two yields carrying `Event.output` are "a runtime error" under the structured-data caution. Recorded as a Python-to-TypeScript difference in the README rather than only in the sample. Verified both by running them, not by reading: a node yielding two `output` events hands the successor the second and raises nothing, and the reworked HITL leaf pauses on turn 1 and resolves "yes" to "Approved" on turn 2. * docs(workflow): stop coercing inputs that are already typed as strings Review finding: the samples were split on how they treat the workflow input. Eleven files wrapped it in `String(...)`; eight called `.trim()` or `.toUpperCase()` straight on a parameter already declared `string`. `extractWorkflowInput` (`workflow_agent.ts:187`) returns the message text for a text-only turn and the raw `Content` for anything else, so neither form is sound for a non-text turn -- but they fail differently. `String()` turns a `Content` into `"[object Object]"` and carries it happily through the graph; the bare call throws where the mistake is. Keep the one that fails loudly, and say so in the README so a reader copying a sample knows what it assumes. Coercion stays where the value genuinely is untyped: `ctx.runNode(...).output` and a `ctx.resumeInputs[id]` reply are both `unknown`, and the samples that read them keep converting explicitly at the point of use. * test(workflow): execute the docs samples instead of only compiling them Review finding: eslint, Prettier, check_license.sh and the new `tsc` step all read `samples/`, so a syntax, style, license or type error in these 26 files fails CI. Nothing ran them, which left the failure they are most exposed to uncovered: a `WorkflowAgent` validates its graph in its constructor, so a rename or a semantics change in the `@experimental` workflow API can turn a sample into a load-time error that still type-checks -- and google#635, google#637 and google#647 all moved that API while this branch was open. Every sample is now constructed, and the 18 that call no model are also run end-to-end through a real `InMemoryRunner`. Reuses the existing sample harness in `offline` mode, which installs the record/replay model over an empty response set, so an "offline" sample that starts calling a model throws rather than reaching the network. The 8 model-backed samples are constructed only: driving them means a checked-in fixture each, and what they add over the sibling `tests/integration/workflows/` set is prompt wording, not graph shape. One table drives it, and a guard test asserts the table matches the directories on disk -- otherwise a new sample silently gets no coverage, which is the hole this closes. Checked against all three failures it is meant to catch, rather than assuming a passing suite means a working one: a duplicate node name (constructor validation) fails the sample's case, an unregistered new directory fails the guard, and an LlmAgent spliced into an offline graph fails on the missing fixture. * docs(workflow): restore the state-based counter now that google#636 fixed the read The third stale claim of this review, and the same shape as the other two: a sample working around a framework bug that has since been fixed on main. The session_state port carried a "do not read-modify-write ONE key from several nodes" gotcha, and routed `attempts` along the edges as node output to avoid it. google#636 landed that fix — node reads are now served from a per-invocation write overlay — so the warning describes a bug that no longer exists and the workaround is no longer buying anything. `attempts` goes back to being a state key that one node initializes, another increments and a third reads, which is what the Python snippet does and what its inline comment claims it prints. Confirmed against both sides of the fix rather than assuming: reverting google#636's `node_context.ts` makes the third node read 0, and with it in place the sample prints `attempts state: 1` — the snippet's own documented output. Drops the README gotcha section with it, and keeps the surviving half of the advice — prefer an edge when only the next node needs the value — as guidance in the sample rather than as a warning about a defect. * ci(samples): keep samples resolving @google/adk through node_modules The samples config inherits the root one, so once google#648 adds the `@google/adk` -> `core/src` aliases there, `npm run ts:check:samples` would start checking the samples against the workspace sources instead of the published types — the one thing a sample should not do, since a user's project resolves the package through `node_modules`. `"paths": {}` pins that, the same reset `core`, `dev` and `integrations` already carry. No-op against the root config as it stands today: the check resolves to `core/dist/types/index.d.ts` and passes either way. * ci(samples): keep the repo-wide type check out of samples/ Fallout from rebasing onto google#648, which landed the repo-wide `ts:check` while this branch was open. The root config names no `include` and excludes only `node_modules` and `**/dist`, so `tsc --noEmit` now picks up all 26 sample files — and resolves their `@google/adk` imports through the root `paths` aliases, against `core/src`. That is the one resolution a sample must not use, which is the whole point of the `"paths": {}` reset in `samples/tsconfig.json`: a sample is a consumer of the published package, so it has to resolve the way a user's project does, through `node_modules` and against the built types. With both checks running, the scoped one did that and the repo-wide one quietly did the opposite over the same files. Excluding `samples` from the root config leaves one owner. Verified on both sides: `tsc --noEmit --listFiles` now reports 0 files under `samples/` and still passes, while `tsc -p samples --listFiles` reports all 26 and resolves `@google/adk` to `core/dist/types/index.d.ts`. The `validation.yaml` collision google#648 was warned about resolved as both steps, not one: `ts:check` for the repo, `ts:check:samples` for the samples. The zizmor hardening on that file (`permissions`, `persist-credentials`, the three SHA pins) came in with google#648, so that commit dropped out of this branch as already upstream.
Summary
A workflow node that reads a session-state key an earlier node also wrote can observe the earlier, already-superseded value:
Root cause
session.stateis written from two directions during a run:NodeContextbuilds itsStatedirectly over the livesession.state(node_context.ts:120), andState.setwrites through immediately (state.ts:49-52).actions.stateDeltaonto that same object as it commits the event (base_session_service.ts:195-209).The event drain lags node execution, so an earlier node's delta gets re-applied after a later node has already written a newer value. Traced with an
appendEventprobe:The value converges once the backlog drains — the same read returns
1after a 300 ms delay — so committed state was already correct. Only in-run reads were wrong, which made this silent, timing-dependent, and invisible unless a key has more than one writer. That is exactly the counter pattern the data-handling docs demonstrate (attempts + 1), so the documented example does not work as written.Fix
Node reads are served from a per-invocation write overlay that only ever moves forward; reads fall through to committed session state for anything this run has not written. The overlay is keyed by the session's live state object (stable within a turn) and guarded by invocation id.
Writes still land in
session.stateas well, so consumers that read it directly are unaffected — notably{key}instruction templating in an agent node, which resolves againstinvocationContext.session.state. There is a test pinning that.Contained to the workflow package;
Stateand the session services are untouched, and committed state is byte-for-byte what it was before.A second, older bug found in review
An agent node's
outputKeybypassed all of this: task mode wrote it straight toctx.actions.stateDelta(llm_agent_wrapper.ts:141). OnlyFunctionNodeever drains that object onto an event (function_node.ts:182);BaseNode.toEventdoes not, and nothing else merges it — so for this node it is write-only. The value reached neither the overlay, norsession.state, nor committed state. A downstream node readundefined, and the key never persisted at all. Pre-dates this PR, andoutputKeyhad no workflow test.It now writes through
ctx.state(overlay + session, for in-run readers) and stamps the delta on the event being emitted (for the commit). Both halves are load-bearing: with only the first, committed state staysundefined; with only the second, the next node still readsundefined.Known residual
Two gaps remain, and they are mirror images of each other. Both have the same root cause and the same real fix — version-stamping keys in
updateSessionState, which touches every session service — so both are deliberately left out of this PR.session.statedirectly, so an agent resolving{key}for a key written by two different nodes is still exposed to the same window. Same forLlmAgent's ownoutputKey(llm_agent.ts:708), which writes the event delta and so is committed rather than dropped — I probed a single_turn agent node followed by a reader node and the reader does see the value, because the runner commits the agent's event before the next node starts. Exposed to the window, not broken.k, later reads ofkin that invocation are served from the overlay, so a tool or callback writingkstraight tosession.statemid-run is invisible to them for the rest of the invocation. This one is a behaviour change, not just an untouched gap: before the overlay those reads did see the write, subject to the race being fixed here. The trade looks right — a mid-run outside writer to a key the workflow also owns is already in undefined-ordering territory, whereas node-to-node read-modify-write is the documented pattern — but it is a trade. Both are now written down in theNodeStateViewdoc comment.Happy to follow up with the version-stamping fix if you want it.
Tests
New
core/test/workflow/state_consistency_test.ts:[1,2,3,4,5]session.state(instruction-templating contract)ctx.state.update()honours both halves of that contract (added in review)outputKeyis readable by the next node, onsession.state, and committed (added in review; first workflow coverage ofoutputKey)unit:coregreen: 206 files, 2833 tests.unit:devhas 1 failure (cli_create_test) that reproduces identically on unmodifiedmain.Unrelated issue found while testing
reconstructNodeStatesscans all session events with no invocation filter (workflow.ts:192). A workflow that completed in turn 1 therefore has every node fast-forwarded from cache on turn 2 — re-invoking it in the same session is a no-op. Reproduces on cleanmain; not touched here. Already covered by #637, which fixes it (delimiting runs by pausing rather than by invocation id) — so no separate issue needed unless you want one for tracking.