Skip to content

fix(workflow): stop node state writes from being rolled back mid-run - #636

Merged
kalenkevich merged 3 commits into
mainfrom
fix/workflow-state-rollback
Aug 12, 2026
Merged

fix(workflow): stop node state writes from being rolled back mid-run#636
kalenkevich merged 3 commits into
mainfrom
fix/workflow-state-rollback

Conversation

@kalenkevich

@kalenkevich kalenkevich commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

A workflow node that reads a session-state key an earlier node also wrote can observe the earlier, already-superseded value:

a: ctx.state.set('attempts', 0);
b: ctx.state.get('attempts'); // 0
ctx.state.set('attempts', 1);
c: ctx.state.get('attempts'); // -> 0   WRONG, expected 1

Root cause

session.state is written from two directions during a run:

  1. NodeContext builds its State directly over the live session.state (node_context.ts:120), and State.set writes through immediately (state.ts:49-52).
  2. The runner separately re-applies each event's actions.stateDelta onto 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 appendEvent probe:

a: set 0
b: read 0, set 1
  >>> appendEvent(author=a) re-applies attempts=0   <- rolls back b's write
c: read 0                                            <- reads the rolled-back value
  >>> appendEvent(author=b) re-applies attempts=1   <- rolls forward, too late

The value converges once the backlog drains — the same read returns 1 after 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.state as well, so consumers that read it directly are unaffected — notably {key} instruction templating in an agent node, which resolves against invocationContext.session.state. There is a test pinning that.

Contained to the workflow package; State and 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 outputKey bypassed all of this: task mode wrote it straight to ctx.actions.stateDelta (llm_agent_wrapper.ts:141). Only FunctionNode ever drains that object onto an event (function_node.ts:182); BaseNode.toEvent does not, and nothing else merges it — so for this node it is write-only. The value reached neither the overlay, nor session.state, nor committed state. A downstream node read undefined, and the key never persisted at all. Pre-dates this PR, and outputKey had 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 stays undefined; with only the second, the next node still reads undefined.

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.

  • Workflow writes still reach an outside reader out of order. Agent instruction templating reads session.state directly, so an agent resolving {key} for a key written by two different nodes is still exposed to the same window. Same for LlmAgent's own outputKey (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.
  • Outside writes no longer reach a workflow reader. Once a node writes k, later reads of k in that invocation are served from the overlay, so a tool or callback writing k straight to session.state mid-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 the NodeStateView doc comment.

Happy to follow up with the version-stamping fix if you want it.

Tests

New core/test/workflow/state_consistency_test.ts:

  • the read-modify-write regression above
  • a 5-node chain reading [1,2,3,4,5]
  • state seeded on the session before the run is still readable
  • node writes still visible on session.state (instruction-templating contract)
  • ctx.state.update() honours both halves of that contract (added in review)
  • an agent node's outputKey is readable by the next node, on session.state, and committed (added in review; first workflow coverage of outputKey)
  • a second invocation over the same session object is served from committed state, not the previous invocation's overlay — the invocation-id guard, which nothing exercised before (added in review)
  • the overlay does not leak across runs

unit:core green: 206 files, 2833 tests. unit:dev has 1 failure (cli_create_test) that reproduces identically on unmodified main.

Unrelated issue found while testing

reconstructNodeStates scans 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 clean main; 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.

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +256 to +262
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;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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').

Comment on lines +245 to +250
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;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +236 to +238
* 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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.toEvent never reads it (base_node.ts:191).
  • enrichEvent only writes agentState (node_runner.ts:250).
  • No caller passes actions to a NodeContext.

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.

Comment thread core/src/workflow/node_context.ts Outdated
Comment on lines +194 to +195
* `invocationId` guard covers the case where two invocations share a session
* object.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 state

Fails 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.
@kalenkevich
kalenkevich force-pushed the fix/workflow-state-rollback branch from a00c183 to 8e050d7 Compare August 11, 2026 01:10
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.
@kalenkevich

Copy link
Copy Markdown
Collaborator Author

Both comments addressed in 5cc73fc; replies inline.

  • outputKey bypass — fixed, and it was worse than late: ctx.actions.stateDelta is drained onto an event only by FunctionNode, so for an agent node that write landed nowhere — not the overlay, not session.state, not committed state. Needed ctx.state.set(...) and a delta stamp on the emitted event; the table in the inline reply shows what each half buys. First workflow test for outputKey comes with it. llm_agent.ts:708 left alone — probed it, a single_turn agent node's outputKey does reach the next node today (the runner commits the event first), so it is in the commit-lag window rather than dropped; noted under Known residual.
  • Guard doc — narrowed to sequential reuse, and tested with two NodeContexts over one session object with different invocation ids. Fails without the guard.

On reconstructNodeStates: it is #637, which fixes it rather than just tracking it — you pointed me there in the first review. Happy to open an issue too if you would rather have one for tracking.

Windows unsafe_local_code_executor_test.ts — agreed, fails on main.

unit:core green: 206 files, 2833 tests.

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kalenkevich

Copy link
Copy Markdown
Collaborator Author

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 Tests 3277 passed | 47 skipped, zero failures, and one suite failure at setup —

FAIL  integration  tests/integration/a2a/input_required/input_required_test.ts > A2A: RemoteAgent InputRequired
Error: CLI exited prematurely with code 1
 ❯ ChildProcess.<anonymous> tests/integration/test_case_utils.ts:341:13

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 AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kalenkevich
kalenkevich merged commit fcc6c1e into main Aug 12, 2026
14 of 15 checks passed
@kalenkevich
kalenkevich deleted the fix/workflow-state-rollback branch August 12, 2026 01:44
@kalenkevich kalenkevich mentioned this pull request Aug 12, 2026
kalenkevich added a commit that referenced this pull request Aug 12, 2026
…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.
kalenkevich added a commit that referenced this pull request Aug 12, 2026
…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.
ScottMansfield pushed a commit that referenced this pull request Aug 12, 2026
…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.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
…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.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants