From 2ea98f101cfc0a695e76cd2a2d5babde089cb700 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Thu, 6 Aug 2026 15:21:03 -0700 Subject: [PATCH 1/8] docs(workflow): add runnable ports of the graph-workflow doc snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- samples/workflows/README.md | 158 ++++++++++++++++++ .../data_handling/node_output/agent.ts | 63 +++++++ .../data_handling/routing_output/agent.ts | 65 +++++++ .../workflows/data_handling/schemas/agent.ts | 138 +++++++++++++++ .../data_handling/session_state/agent.ts | 80 +++++++++ .../data_handling/structured_access/agent.ts | 80 +++++++++ .../data_handling/structured_output/agent.ts | 51 ++++++ .../data_handling/user_message/agent.ts | 59 +++++++ .../workflows/dynamic/custom_run_ids/agent.ts | 72 ++++++++ .../workflows/dynamic/data_handling/agent.ts | 68 ++++++++ .../workflows/dynamic/get_started/agent.ts | 54 ++++++ .../workflows/dynamic/human_input/agent.ts | 93 +++++++++++ samples/workflows/dynamic/loop_route/agent.ts | 102 +++++++++++ samples/workflows/dynamic/nodes/agent.ts | 67 ++++++++ .../workflows/dynamic/parallel_route/agent.ts | 78 +++++++++ .../workflows/dynamic/sequence_route/agent.ts | 76 +++++++++ samples/workflows/graphs/get_started/agent.ts | 86 ++++++++++ .../graphs/process_pipeline/agent.ts | 78 +++++++++ .../human_input/get_started/agent.ts | 55 ++++++ .../human_input/initial_prompt/agent.ts | 74 ++++++++ .../human_input/payload_and_schema/agent.ts | 106 ++++++++++++ samples/workflows/routes/branches/agent.ts | 72 ++++++++ .../workflows/routes/fan_out_join/agent.ts | 71 ++++++++ .../workflows/routes/function_node/agent.ts | 53 ++++++ .../workflows/routes/loop_escalation/agent.ts | 88 ++++++++++ .../workflows/routes/nested_workflow/agent.ts | 99 +++++++++++ samples/workflows/routes/sequence/agent.ts | 43 +++++ 27 files changed, 2129 insertions(+) create mode 100644 samples/workflows/README.md create mode 100644 samples/workflows/data_handling/node_output/agent.ts create mode 100644 samples/workflows/data_handling/routing_output/agent.ts create mode 100644 samples/workflows/data_handling/schemas/agent.ts create mode 100644 samples/workflows/data_handling/session_state/agent.ts create mode 100644 samples/workflows/data_handling/structured_access/agent.ts create mode 100644 samples/workflows/data_handling/structured_output/agent.ts create mode 100644 samples/workflows/data_handling/user_message/agent.ts create mode 100644 samples/workflows/dynamic/custom_run_ids/agent.ts create mode 100644 samples/workflows/dynamic/data_handling/agent.ts create mode 100644 samples/workflows/dynamic/get_started/agent.ts create mode 100644 samples/workflows/dynamic/human_input/agent.ts create mode 100644 samples/workflows/dynamic/loop_route/agent.ts create mode 100644 samples/workflows/dynamic/nodes/agent.ts create mode 100644 samples/workflows/dynamic/parallel_route/agent.ts create mode 100644 samples/workflows/dynamic/sequence_route/agent.ts create mode 100644 samples/workflows/graphs/get_started/agent.ts create mode 100644 samples/workflows/graphs/process_pipeline/agent.ts create mode 100644 samples/workflows/human_input/get_started/agent.ts create mode 100644 samples/workflows/human_input/initial_prompt/agent.ts create mode 100644 samples/workflows/human_input/payload_and_schema/agent.ts create mode 100644 samples/workflows/routes/branches/agent.ts create mode 100644 samples/workflows/routes/fan_out_join/agent.ts create mode 100644 samples/workflows/routes/function_node/agent.ts create mode 100644 samples/workflows/routes/loop_escalation/agent.ts create mode 100644 samples/workflows/routes/nested_workflow/agent.ts create mode 100644 samples/workflows/routes/sequence/agent.ts diff --git a/samples/workflows/README.md b/samples/workflows/README.md new file mode 100644 index 0000000000..91e7d8cb00 --- /dev/null +++ b/samples/workflows/README.md @@ -0,0 +1,158 @@ +# Graph workflow samples + +Runnable TypeScript ports of the **Python** code snippets in the ADK +[Graph Workflows docs](https://adk.dev/graphs/). One directory per snippet, +grouped by the docs page it comes from, so a sample directory maps 1:1 to a +section anchor on adk.dev. + +Each directory exports a `rootAgent` that runs with the ADK CLI. The docs +snippets are fragments — they reference helpers they never define (`condition()`, +`task_A_node`, …) — so each port fills those in with the smallest plausible +implementation and says so in its header comment. Everything else follows the +Python source as closely as the TypeScript API allows; where the two genuinely +differ, the file comments say why. + +## Running + +Build once, then run any sample by its `agent.ts` path: + +```bash +npm run build # builds @google/adk (and the CLI); needed once / after changes +npm run sample -- samples/workflows/routes/sequence/agent.ts +``` + +`npm run sample -- ` is shorthand for +`node dev/dist/esm/cli_entrypoint.js run `. + +The CLI is interactive: type a message and press Enter to send it to the +workflow; type `exit` to quit. Node events print as `[]: ` +and the last line is the workflow's output. A node that emits only `output` (no +display content) prints nothing — that is expected. + +Pipe a single message, or script a multi-turn run with `--replay` (a JSON file +of queries, resolved relative to the working directory): + +```bash +echo "hello world" | npm run sample -- samples/workflows/routes/sequence/agent.ts + +echo '{"state":{},"queries":["start","21"]}' > replay.json +npm run sample -- samples/workflows/human_input/get_started/agent.ts --replay replay.json +``` + +## API keys + +Samples marked **key** below call a live model. Set `GEMINI_API_KEY` (a `.env` +file in the working directory is loaded automatically) before running them. The +rest are function-only and run offline. + +## Human input + +The HITL samples **pause** mid-run (you will see an `adk_request_input` +request). Just type your reply on the next turn — a plain-text reply is routed +to the pending interrupt, so you can approve, reject, or supply a value +interactively. + +## Samples + +### [`/graphs/`](https://adk.dev/graphs/) — `graphs/` + +| Sample | Docs section | Shows | Key | +| ------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------- | --- | +| `get_started` | [Get started](https://adk.dev/graphs/#get-started) | Agent → function → agent → function, in sequence | ✅ | +| `process_pipeline` | [Build processes with graphs](https://adk.dev/graphs/#build-processes-with-graphs) | Classify, then dispatch on a route **array** (multi-route) | ✅ | + +### [`/graphs/routes/`](https://adk.dev/graphs/routes/) — `routes/` + +| Sample | Docs section | Shows | Key | +| ----------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------- | --- | +| `function_node` | [Nodes](https://adk.dev/graphs/routes/#nodes) | The primary node type; bare return vs. explicit `Event` | — | +| `sequence` | [Route sequences](https://adk.dev/graphs/routes/#route-sequences) | `['START', a, b, c]` — each node once, in order | — | +| `branches` | [Route branches](https://adk.dev/graphs/routes/#route-branches-and-conditional-execution) | A router node plus a route→node dispatch map | ✅ | +| `fan_out_join` | [Fan out and join](https://adk.dev/graphs/routes/#parallel-tasks-fan-out-and-join-paths) | Parallel paths merged by a `JoinNode` barrier | — | +| `nested_workflow` | [Nested workflows](https://adk.dev/graphs/routes/#nested-workflows) | A `Workflow` used as a node inside another workflow | — | +| `loop_escalation` | [Loop and escalation exit](https://adk.dev/graphs/routes/#loop-and-escalation-exit) | A back-edge cycle with a routed exit | — | + +### [`/graphs/data-handling/`](https://adk.dev/graphs/data-handling/) — `data_handling/` + +| Sample | Docs section | Shows | Key | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --- | +| `node_output` | [Node output](https://adk.dev/graphs/data-handling/#node-output) | Return a value / an `Event` / yield; one `output` per node | — | +| `structured_output` | [Passing structured data](https://adk.dev/graphs/data-handling/#node-output-passing-structured-data) | A typed object across an edge, validated by schemas | — | +| `routing_output` | [Routing output](https://adk.dev/graphs/data-handling/#routing-output) | `route` and `output` on one event; `DEFAULT_ROUTE` | — | +| `user_message` | [User-facing messages](https://adk.dev/graphs/data-handling/#user-facing-messages) | A display message vs. data for the next node | — | +| `session_state` | [Session state and scopes](https://adk.dev/graphs/data-handling/#session-state-and-state-scopes) | `ctx.state`, the `app:`/`user:`/`temp:` prefixes | — | +| `schemas` | [Constrain node data with schemas](https://adk.dev/graphs/data-handling/#constrain-node-data-with-schemas) | `inputSchema` / `outputSchema` on an agent node, plus a tool | ✅ | +| `structured_access` | [Access structured data in agents](https://adk.dev/graphs/data-handling/#access-structured-data-in-agents) | `{Class.field}` and `` | ✅ | + +### [`/graphs/human-input/`](https://adk.dev/graphs/human-input/) — `human_input/` + +| Sample | Docs section | Shows | Key | +| -------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | --- | +| `get_started` | [Get started](https://adk.dev/graphs/human-input/#get-started) | The two-node pause: `RequestInput`, reply feeds next | — | +| `payload_and_schema` | [Message and payload](https://adk.dev/graphs/human-input/#request-input-with-a-message-and-payload) | `message` + `payload` + `responseSchema` | — | +| `initial_prompt` | [Tool-confirmation section](https://adk.dev/graphs/human-input/#tool-confirmation-approval-prompts-in-llm-agents) | A HITL node as the FIRST step of a workflow | — | + +### [`/graphs/dynamic/`](https://adk.dev/graphs/dynamic/) — `dynamic/` + +| Sample | Docs section | Shows | Key | +| ---------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | --- | +| `get_started` | [Get started](https://adk.dev/graphs/dynamic/#get-started) | An orchestrator node driving a child via `ctx.runNode()` | — | +| `nodes` | [Nodes](https://adk.dev/graphs/dynamic/#node) / [Workflows](https://adk.dev/graphs/dynamic/#workflows) | `node()` vs. `new FunctionNode()` | — | +| `data_handling` | [Data handling](https://adk.dev/graphs/dynamic/#data-handling) | `editorial_workflow`: agent → function, no state keys | ✅ | +| `sequence_route` | [Sequence route](https://adk.dev/graphs/dynamic/#sequence-route) | `city_workflow`: sequential `runNode` calls + schemas | ✅ | +| `loop_route` | [Loop route](https://adk.dev/graphs/dynamic/#loop-route) | A real `while` loop (generate → lint → fix), bounded | ✅ | +| `parallel_route` | [Parallel execution routes](https://adk.dev/graphs/dynamic/#parallel-execution-routes) | `Promise.all` fan-out (the `asyncio.gather` equivalent) | — | +| `human_input` | [Human input](https://adk.dev/graphs/dynamic/#human-input) | HITL inside an orchestrator, with re-entry on resume | — | +| `custom_run_ids` | [Custom execution IDs](https://adk.dev/graphs/dynamic/#custom-execution-ids) | `ctx.runNode(..., {runId})` for a reorderable collection | — | + +## Python → TypeScript differences + +The ports are faithful in structure; these are the places where the API itself +differs, all called out again in the affected sample's header comment. + +- **No `@node` decorator.** `node(fn, options)` is the factory form; the + explicit `new FunctionNode(name, fn, config)` constructor is also public. +- **No `Event.message`.** Python's `Event(message=...)` becomes an event with + `content` — rendered to the user, and NOT passed to the next node. +- **No `Event(state=...)`.** Write through `ctx.state`; the accumulated delta is + attached to the node's events. +- **No signature-based injection.** Python binds `node_input`/state values to + named parameters by introspection. TypeScript handlers always take + `(ctx, input)` and read state explicitly via `ctx.state`. +- **`ctx.runNode()` resolves to a node _result_,** not the output directly — read + `.output`. It also does not throw when a child interrupts: check + `.interruptIds` and bail out (see `dynamic/human_input`). +- **`LlmAgent.inputSchema` is not the node's input contract.** It is only used + when the agent is exposed as a tool. Inside a graph, put the validating schema + on the node: `node(agent, {inputSchema})`. +- **Schemas are Zod objects** (or a genai `Schema`) rather than pydantic models. +- **`{Class.field}` and `` work verbatim** — the + Python data-selection syntax is supported (see `data_handling/structured_access`). + +## Two gotchas worth knowing + +Both were found while smoke-testing these ports; each is documented in the +sample it affects. + +- **Keep every session-state key single-writer.** A node's `ctx.state` writes + land immediately, but they are also replayed when the runtime commits that + node's event — and that commit lags the graph by an event or two. So a node + that re-reads a key an _earlier_ node also wrote can observe the earlier, + already-superseded value. Move evolving values along the edges as `output` + instead of read-modify-writing one key from several nodes + (`data_handling/session_state`). +- **The `rerunOnResume: false` HITL handoff is static-graph only.** For a graph + node, "do not re-run; complete with the human's reply as my output" is what + makes the two-node pattern work (`human_input/get_started`). A dynamic + `ctx.runNode` child does not get that treatment — it is re-run — so a dynamic + HITL leaf needs the re-entry form: a stable `interruptId` plus a + `ctx.resumeInputs[id]` lookup (`dynamic/human_input`). + +## See also + +The `tests/integration/workflows/*/agent.ts` files are a second, larger set of +workflow examples — TypeScript ports of Python's +[`contributing/samples/workflows`](https://github.com/google/adk-python/tree/main/contributing/samples/workflows), +each paired with a record/replay integration test. They cover surface these +docs snippets do not: retries, parallel workers, auth (API key and OAuth), +node-as-tool, `task` mode, and multi-trigger nodes. diff --git a/samples/workflows/data_handling/node_output/agent.ts b/samples/workflows/data_handling/node_output/agent.ts new file mode 100644 index 0000000000..7a9b9f8e34 --- /dev/null +++ b/samples/workflows/data_handling/node_output/agent.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/data-handling/#node-output + * + * def my_function_node(node_input: str): + * output_value = node_input.upper() + * return Event(output=output_value) # "THE RESULT" + * + * A node hands data to its successor through the event's `output` field. Three + * equivalent ways to produce it: + * + * 1. return a bare value — boxed into `Event(output=value)` for you + * 2. return `createEvent({output})` — the explicit form, when you also need + * `route`, `content`, or `actions` + * 3. yield from a generator — to stream progress alongside the result + * + * Caution: a node may emit only ONE event carrying `output` per execution. You + * can yield as many events as you like, but only one of them may set `output` — + * the rest should carry `content` (a display message) instead. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/data_handling/node_output/agent.ts + */ + +import {createEvent, node, NodeContext, WorkflowAgent} from '@google/adk'; + +// 1. A bare return value. +const returnRawValue = node( + (_ctx: NodeContext, nodeInput: string) => nodeInput.toUpperCase(), + {name: 'return_raw_value'}, +); + +// 2. An explicit Event. +const returnEventOutput = node( + (_ctx: NodeContext, nodeInput: string) => + createEvent({output: `${nodeInput}!`}), + {name: 'return_event_output'}, +); + +// 3. A generator: stream progress, then emit the single output event last. +const yieldProgressThenOutput = node( + async function* (_ctx: NodeContext, nodeInput: string) { + yield createEvent({ + content: {role: 'model', parts: [{text: 'Working on it...'}]}, + }); + // Only this event sets `output`, so the one-payload rule holds. + yield createEvent({output: `<<${nodeInput}>>`}); + }, + {name: 'yield_progress_then_output'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'node_output_workflow', + edges: [ + ['START', returnRawValue, returnEventOutput, yieldProgressThenOutput], + ], +}); diff --git a/samples/workflows/data_handling/routing_output/agent.ts b/samples/workflows/data_handling/routing_output/agent.ts new file mode 100644 index 0000000000..9c0186de4c --- /dev/null +++ b/samples/workflows/data_handling/routing_output/agent.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/data-handling/#routing-output + * + * def router(node_input: str): + * return Event(route="BUG") + * + * `route` is the event field that drives conditional edge dispatch — it is + * independent of `output`, so a router can select a branch AND forward a payload + * in the same event. Route values may be strings, numbers, or booleans, and + * `DEFAULT_ROUTE` catches everything no other branch matched. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/data_handling/routing_output/agent.ts + * Try "the app crashed" (BUG) or "where is my order?" (falls through). + */ + +import { + createEvent, + DEFAULT_ROUTE, + node, + NodeContext, + WorkflowAgent, +} from '@google/adk'; + +const router = node( + (_ctx: NodeContext, nodeInput: string) => + createEvent({ + route: /bug|crash|error/i.test(nodeInput) ? 'BUG' : 'OTHER', + // Forwarded to whichever branch fires. + output: nodeInput, + }), + {name: 'router'}, +); + +const handleBug = node( + (_ctx: NodeContext, nodeInput: string) => `Filed a bug for: ${nodeInput}`, + {name: 'handle_bug'}, +); + +const handleAnythingElse = node( + (_ctx: NodeContext, nodeInput: string) => `No bug detected in: ${nodeInput}`, + {name: 'handle_anything_else'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'routing_output_workflow', + edges: [ + ['START', router], + [ + router, + { + BUG: handleBug, + // Fires when no other route on this node matched. + [DEFAULT_ROUTE]: handleAnythingElse, + }, + ], + ], +}); diff --git a/samples/workflows/data_handling/schemas/agent.ts b/samples/workflows/data_handling/schemas/agent.ts new file mode 100644 index 0000000000..d3ca9e9960 --- /dev/null +++ b/samples/workflows/data_handling/schemas/agent.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/data-handling/#constrain-node-data-with-schemas + * + * flight_searcher = Agent( + * name="flight_searcher", + * instruction="Search for available flights.", + * input_schema=FlightSearchInput, + * output_schema=FlightSearchOutput, + * tools=[search_flights_api], + * mode="single_turn", + * ) + * + * Schemas constrain what a node accepts and produces. Python uses pydantic + * `BaseModel`s; TypeScript uses Zod objects (a genai `Schema` also works). + * + * Where the schema goes: + * - `LlmAgent.outputSchema` forces the model to answer in that shape. + * - `LlmAgent.inputSchema` is only consulted when the agent is exposed as a + * tool. Inside a graph, the schema that VALIDATES a node's input belongs on + * the node: `node(agent, {inputSchema})`. + * + * Agents in a graph must run in `single_turn` (the default) or `task` mode. + * + * REQUIRES an API key. Set GEMINI_API_KEY, then: + * npm run sample -- samples/workflows/data_handling/schemas/agent.ts + * Try "SFO to CDG on 2026-03-15 for 2 people". + */ + +import { + FunctionTool, + LlmAgent, + node, + NodeContext, + WorkflowAgent, +} from '@google/adk'; +import {z} from 'zod'; + +const flightSearchInputSchema = z.object({ + origin: z.string().describe('Origin airport code, e.g. "SFO".'), + destination: z.string().describe('Destination airport code, e.g. "CDG".'), + departureDate: z.string().describe('Departure date, e.g. "2026-03-15".'), + passengers: z.number().describe('Number of passengers.'), +}); +type FlightSearchInput = z.infer; + +const flightSchema = z.object({ + carrier: z.string(), + flightNumber: z.string(), + price: z.number(), +}); + +const flightSearchOutputSchema = z.object({ + flights: z.array(flightSchema), + cheapestPrice: z.number(), +}); +type FlightSearchOutput = z.infer; + +/** Stands in for a real flight-search API. */ +const searchFlightsApi = new FunctionTool({ + name: 'search_flights_api', + description: 'Searches available flights for a route and date.', + parameters: flightSearchInputSchema, + execute: ({origin, destination}) => [ + { + carrier: 'AF', + flightNumber: `AF${origin.length}${destination.length}0`, + price: 812.4, + }, + { + carrier: 'UA', + flightNumber: `UA${origin.length}${destination.length}1`, + price: 947.0, + }, + ], +}); + +// Turns the free-text request into the structured node input the searcher +// expects. In a real app this would itself be an extraction agent. +const parseRequest = node( + (_ctx: NodeContext, nodeInput: string): FlightSearchInput => { + const codes = + String(nodeInput) + .toUpperCase() + .match(/\b[A-Z]{3}\b/g) ?? []; + const date = String(nodeInput).match(/\d{4}-\d{2}-\d{2}/)?.[0]; + const passengers = Number( + String(nodeInput).match(/(\d+)\s*(people|pax|passengers?)/i)?.[1], + ); + return { + origin: codes[0] ?? 'SFO', + destination: codes[1] ?? 'CDG', + departureDate: date ?? '2026-03-15', + passengers: Number.isFinite(passengers) ? passengers : 1, + }; + }, + {name: 'parse_request', outputSchema: flightSearchInputSchema}, +); + +const flightSearcher = new LlmAgent({ + name: 'flight_searcher', + model: 'gemini-2.5-flash', + mode: 'single_turn', + instruction: + 'Search for available flights with the search_flights_api tool and report ' + + 'every flight it returns plus the cheapest price.', + inputSchema: flightSearchInputSchema, + outputSchema: flightSearchOutputSchema, + tools: [searchFlightsApi], +}); + +const renderResults = node( + (_ctx: NodeContext, results: FlightSearchOutput) => + `Cheapest: $${results.cheapestPrice}\n` + + results.flights + .map((f) => ` ${f.carrier} ${f.flightNumber} — $${f.price}`) + .join('\n'), + {name: 'render_results', inputSchema: flightSearchOutputSchema}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'flight_workflow', + edges: [ + [ + 'START', + parseRequest, + // The graph-level input contract for the agent node. + node(flightSearcher, {inputSchema: flightSearchInputSchema}), + renderResults, + ], + ], +}); diff --git a/samples/workflows/data_handling/session_state/agent.ts b/samples/workflows/data_handling/session_state/agent.ts new file mode 100644 index 0000000000..0032f9c72e --- /dev/null +++ b/samples/workflows/data_handling/session_state/agent.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/data-handling/#session-state-and-state-scopes + * + * async def init_state_node(attempts: int = 0): + * yield Event(state={"attempts": attempts}) + * + * async def task_attempt_node(node_input: Content, attempts: int): + * yield Event(state={"attempts": attempts + 1}) + * + * async def read_state_node(ctx: Context): + * print(f"attempts state: {ctx.state}") # attempts state: attempts: 1 + * + * Python binds state values to named function parameters by signature + * introspection. TypeScript nodes take an explicit `(ctx, input)` pair instead, + * so you read and write the same session state through `ctx.state` — writes + * accumulate in the node's state delta and are committed with its events. + * + * State-key prefixes control lifetime and scope: + * "app:" shared across all users and sessions of the app + * "user:" tied to the user, shared across their sessions + * "temp:" discarded when the current invocation ends + * "" persists for the lifetime of the session + * + * !! Gotcha: do not read-modify-write ONE key from several nodes. !! + * A node's writes land in `ctx.state` immediately, but they are also replayed + * from that node's event when the runtime commits it — and that commit lags the + * graph by an event or two. So a later node that re-reads a key an earlier node + * also wrote can observe the earlier (already-superseded) value. Keep each + * state key single-writer, and move evolving values along the edges as node + * `output`, the way `attempts` travels below. + * + * Caution: state is a lightweight key-value store. Do not use it to move large + * payloads between nodes — use artifacts or a database tool for those. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/data_handling/session_state/agent.ts + */ + +import {node, NodeContext, WorkflowAgent} from '@google/adk'; + +const initStateNode = node( + (ctx: NodeContext, nodeInput: string) => { + ctx.state.set('topic', String(nodeInput).trim()); + // Scoped key: dropped when this invocation ends, never persisted. + ctx.state.set('temp:started_at', new Date().toISOString()); + // The counter travels as node output, not as a re-read state key. + return 0; + }, + {name: 'init_state_node'}, +); + +const taskAttemptNode = node( + (ctx: NodeContext, attempts: number) => { + const next = attempts + 1; + // Single writer for this key, so downstream reads are stable. + ctx.state.set('attempts', next); + return next; + }, + {name: 'task_attempt_node'}, +); + +const readStateNode = node( + (ctx: NodeContext) => + `attempts state: ${ctx.state.get('attempts')} ` + + `(topic: ${ctx.state.get('topic')}, ` + + `started: ${ctx.state.get('temp:started_at')})`, + {name: 'read_state_node'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'session_state_workflow', + edges: [['START', initStateNode, taskAttemptNode, readStateNode]], +}); diff --git a/samples/workflows/data_handling/structured_access/agent.ts b/samples/workflows/data_handling/structured_access/agent.ts new file mode 100644 index 0000000000..70a2e84134 --- /dev/null +++ b/samples/workflows/data_handling/structured_access/agent.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/data-handling/#access-structured-data-in-agents + * + * instruction=""" + * Return a sentence in the following format: + * It is in + * right now. + * """ + * + * adk-js supports Python's data-selection syntax verbatim in agent + * instructions: + * + * {Class.field} reads a field off THIS node's input + * reads a field off a named predecessor's + * output — more restrictive, and unambiguous + * when several upstream nodes share a field + * + * Both are distinct from `{state_key}`, which reads session state. The `Class.` + * prefix is documentation only: resolution uses the field name after the dot. + * + * REQUIRES an API key. Set GEMINI_API_KEY, then: + * npm run sample -- samples/workflows/data_handling/structured_access/agent.ts + */ + +import {LlmAgent, node, NodeContext, WorkflowAgent} from '@google/adk'; +import {z} from 'zod'; + +const cityTimeSchema = z.object({ + timeInfo: z.string().describe('Time information.'), + city: z.string().describe('City name.'), +}); +type CityTime = z.infer; + +const cityGeneratorAgent = new LlmAgent({ + name: 'city_generator_agent', + model: 'gemini-2.5-flash', + instruction: 'Return the name of a random city. Return only the name.', +}); + +/** Simulates returning the current time in the specified city. */ +const lookupTimeFunction = node( + (_ctx: NodeContext, city: string): CityTime => ({ + timeInfo: '10:10 AM', + city: String(city).trim(), + }), + {name: 'lookup_time_function', outputSchema: cityTimeSchema}, +); + +const cityReportAgent = new LlmAgent({ + name: 'city_report_agent', + model: 'gemini-2.5-flash', + + // Data selection based on class and parameter — reads this node's own input: + // instruction: `Return a sentence in the following format: + // It is {CityTime.timeInfo} in {CityTime.city} right now.`, + + // More restrictive data selection, qualified by source node name: + instruction: `Return a sentence in the following format: + It is in + right now.`, +}); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [ + [ + 'START', + cityGeneratorAgent, + lookupTimeFunction, + node(cityReportAgent, {inputSchema: cityTimeSchema}), + ], + ], +}); diff --git a/samples/workflows/data_handling/structured_output/agent.ts b/samples/workflows/data_handling/structured_output/agent.ts new file mode 100644 index 0000000000..748e9317a4 --- /dev/null +++ b/samples/workflows/data_handling/structured_output/agent.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/data-handling/#node-output-passing-structured-data + * + * def my_function_node_3(): + * yield Event(output={"city_name": "Paris", "city_time": "10:10 AM"}) + * + * `output` is not limited to text — any serializable value flows to the next + * node, which receives it as a typed object. Attaching an `outputSchema` to the + * producing node (and/or an `inputSchema` to the consumer) makes the contract + * explicit and validates it at runtime. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/data_handling/structured_output/agent.ts + */ + +import {createEvent, node, NodeContext, WorkflowAgent} from '@google/adk'; +import {z} from 'zod'; + +const cityInfoSchema = z.object({ + cityName: z.string(), + cityTime: z.string(), +}); +type CityInfo = z.infer; + +const emitStructuredOutput = node( + async function* () { + yield createEvent({ + output: {cityName: 'Paris', cityTime: '10:10 AM'} satisfies CityInfo, + }); + }, + {name: 'emit_structured_output', outputSchema: cityInfoSchema}, +); + +// The successor receives the object itself — no JSON parsing, no state reads. +const consumeStructuredOutput = node( + (_ctx: NodeContext, cityInfo: CityInfo) => + `It is ${cityInfo.cityTime} in ${cityInfo.cityName} right now.`, + {name: 'consume_structured_output', inputSchema: cityInfoSchema}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'structured_output_workflow', + edges: [['START', emitStructuredOutput, consumeStructuredOutput]], +}); diff --git a/samples/workflows/data_handling/user_message/agent.ts b/samples/workflows/data_handling/user_message/agent.ts new file mode 100644 index 0000000000..1889356ac3 --- /dev/null +++ b/samples/workflows/data_handling/user_message/agent.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/data-handling/#user-facing-messages + * + * async def user_message(node_input: str): + * yield Event(message="Beginning research process...") + * + * `message` is for the human, `output` is for the next node. TypeScript events + * have no `message` field: a user-facing message is the event's `content`, which + * the runtime renders but the graph does NOT forward as node input. + * + * Because the first node below emits content only, the next node's input is + * `undefined` — exactly the Python behaviour of a node that yields a message + * and no output. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/data_handling/user_message/agent.ts + */ + +import {createEvent, node, NodeContext, WorkflowAgent} from '@google/adk'; + +/** Emits a user-facing message (Python's `Event(message=...)`). */ +const message = (text: string) => + createEvent({content: {role: 'model', parts: [{text}]}}); + +// Tell the user the research process is starting. No `output`, so nothing is +// handed to the next node. +const userMessage = node( + async function* (_ctx: NodeContext, nodeInput: string) { + yield message(`Beginning research process for "${nodeInput}"...`); + }, + {name: 'user_message'}, +); + +// A message AND an output in one node: two events, only one carrying `output`. +const research = node( + async function* (_ctx: NodeContext) { + yield message('Gathering sources...'); + yield createEvent({output: ['source-a', 'source-b', 'source-c']}); + }, + {name: 'research'}, +); + +const report = node( + (_ctx: NodeContext, sources: string[]) => + `Research complete. ${sources.length} sources: ${sources.join(', ')}.`, + {name: 'report'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'user_message_workflow', + edges: [['START', userMessage, research, report]], +}); diff --git a/samples/workflows/dynamic/custom_run_ids/agent.ts b/samples/workflows/dynamic/custom_run_ids/agent.ts new file mode 100644 index 0000000000..96684759be --- /dev/null +++ b/samples/workflows/dynamic/custom_run_ids/agent.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/dynamic/#custom-execution-ids + * + * task = ctx.run_node(process_order, order, run_id=f"order-{order.order_id}") + * + * ADK gives every child execution a deterministic id derived from the parent id + * and a per-node-name counter ("1", "2", "3", ...). Those ids are how a resumed + * or retried workflow recognises work that already completed and skips it. + * + * Warning: avoid custom run ids. Because ids drive checkpoint lookup, a + * non-deterministic or reshuffled id makes a resume re-run work it should have + * skipped (or skip work it should have re-run). The one legitimate case is a + * REORDERABLE collection, where position is not stable but identity is — key + * the run id off the item's own id, as below. + * + * A custom run id must contain at least one non-numeric character so it cannot + * collide with the auto-generated sequential ids. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/dynamic/custom_run_ids/agent.ts + */ + +import {node, NodeContext, WorkflowAgent} from '@google/adk'; + +interface Order { + orderId: string; + cartItems: string[]; +} + +/** Stands in for loading orders from a database. */ +async function getOrders(): Promise { + return [ + {orderId: 'a91', cartItems: ['keyboard', 'mouse']}, + {orderId: 'b02', cartItems: ['monitor']}, + {orderId: 'c73', cartItems: ['dock', 'cable', 'hub']}, + ]; +} + +const processOrder = node( + (_ctx: NodeContext, order: Order) => + `order ${order.orderId}: ${order.cartItems.length} item(s) shipped`, + {name: 'process_order'}, +); + +const processAllOrders = node( + async (ctx: NodeContext) => { + const orders = await getOrders(); + + const processTasks = orders.map((order) => + // Use runId to provide a custom identifier. It must contain at least one + // non-numeric character to avoid colliding with the auto-generated + // sequential numeric ids. + ctx.runNode(processOrder, order, {runId: `order-${order.orderId}`}), + ); + + const results = await Promise.all(processTasks); + return results.map((result) => result.output).join('\n'); + }, + {name: 'process_all_orders', rerunOnResume: true}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', processAllOrders]], +}); diff --git a/samples/workflows/dynamic/data_handling/agent.ts b/samples/workflows/dynamic/data_handling/agent.ts new file mode 100644 index 0000000000..9d0b10a591 --- /dev/null +++ b/samples/workflows/dynamic/data_handling/agent.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/dynamic/#data-handling + * + * @node(rerun_on_resume=True) + * async def editorial_workflow(ctx: Context, user_request: str): + * raw_draft = await ctx.run_node(draft_agent, user_request) + * formatted_text = await ctx.run_node(format_function_node, raw_draft) + * return formatted_text + * + * Passing data in a dynamic workflow is simpler than in a graph: `ctx.runNode()` + * hands you the child's result directly, so there are no session-state keys to + * read and write just to move a value one step downstream. + * + * In TypeScript `ctx.runNode()` resolves to a node result — read `.output`. + * + * REQUIRES an API key (draft_agent calls a live model). Set GEMINI_API_KEY: + * npm run sample -- samples/workflows/dynamic/data_handling/agent.ts + * Try "a short paragraph about why graphs beat long prompts". + */ + +import {LlmAgent, node, NodeContext, WorkflowAgent} from '@google/adk'; + +const draftAgent = node( + new LlmAgent({ + name: 'draft_agent', + model: 'gemini-2.5-flash', + instruction: 'Write a short draft for the user request.', + }), +); + +const formatFunctionNode = node( + (_ctx: NodeContext, rawDraft: string) => + rawDraft + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `| ${line}`) + .join('\n'), + {name: 'format_function_node'}, +); + +const editorialWorkflow = node( + async (ctx: NodeContext, userRequest: string) => { + // Agent node generates output. + const rawDraft = await ctx.runNode(draftAgent, userRequest); + + // Function node formats text. + const formattedText = await ctx.runNode( + formatFunctionNode, + rawDraft.output, + ); + + return formattedText.output; + }, + {name: 'editorial_workflow', rerunOnResume: true}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', editorialWorkflow]], +}); diff --git a/samples/workflows/dynamic/get_started/agent.ts b/samples/workflows/dynamic/get_started/agent.ts new file mode 100644 index 0000000000..445df0672f --- /dev/null +++ b/samples/workflows/dynamic/get_started/agent.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/dynamic/#get-started + * + * @node(name="hello_node") + * def my_node(node_input: Any): + * return "Hello World" + * + * @node(rerun_on_resume=True) + * async def my_workflow(ctx: Context, node_input: str) -> str: + * result = await ctx.run_node(my_node, node_input="hello") + * return result + * + * root_agent = Workflow(name="root_agent", edges=[("START", my_workflow)]) + * + * A dynamic workflow drops the static edge graph and orchestrates in plain + * code: an outer node calls `ctx.runNode(child, input)` to execute children in + * whatever order your loops and conditionals dictate. + * + * TypeScript differences from Python: + * - There is no `@node` decorator. `node(fn, options)` is the factory form. + * - `ctx.runNode()` resolves to a node RESULT, so read `.output` (Python + * returns the output directly). + * - An orchestrator that calls `ctx.runNode` must set `rerunOnResume: true`, + * so its body re-runs on resume and already-finished children are replayed + * from their checkpoints rather than executed again. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/dynamic/get_started/agent.ts + */ + +import {node, NodeContext, WorkflowAgent} from '@google/adk'; + +const myNode = node(() => 'Hello World', {name: 'hello_node'}); + +const myWorkflow = node( + async (ctx: NodeContext, _nodeInput: string) => { + // runNode executes a node and resolves to its result. + const result = await ctx.runNode(myNode, 'hello'); + return result.output; + }, + {name: 'my_workflow', rerunOnResume: true}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', myWorkflow]], +}); diff --git a/samples/workflows/dynamic/human_input/agent.ts b/samples/workflows/dynamic/human_input/agent.ts new file mode 100644 index 0000000000..7752d00438 --- /dev/null +++ b/samples/workflows/dynamic/human_input/agent.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/dynamic/#human-input + * + * @node(rerun_on_resume=False) + * async def get_user_approval(ctx: Context, node_input: Any): + * yield RequestInput(message="Please approve this request (Yes/No)") + * + * @node(rerun_on_resume=True) + * async def handle_process(ctx: Context, node_input: Any): + * user_response = await ctx.run_node(get_user_approval) + * if user_response.lower() == "yes": + * return "Approved" + * return "Denied" + * + * Important: a parent node that calls `ctx.runNode` must set + * `rerunOnResume: true`, or it cannot handle an interrupt raised by a child. + * + * !! Two TypeScript differences from the Python snippet. !! + * + * 1. `ctx.runNode()` does NOT throw when a child interrupts. It resolves with a + * result whose `interruptIds` are populated and whose `output` is still + * undefined, so the orchestrator has to check and bail out — otherwise it + * decides on an answer the human never gave. + * + * 2. The `rerun_on_resume=False` leaf ("complete with the human's reply as my + * output") is implemented for STATIC GRAPH nodes only — see + * samples/workflows/human_input/get_started, where that handoff is exactly + * what makes the two-node pattern work. A dynamic `ctx.runNode` child is + * always re-run instead, so the leaf here uses the re-entry form: a stable + * `interruptId`, and a `ctx.resumeInputs[id]` lookup that returns the reply + * on the second pass. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/dynamic/human_input/agent.ts + * Turn 1: describe a request. Turn 2: type "yes" or "no". + */ + +import {node, NodeContext, RequestInput, WorkflowAgent} from '@google/adk'; + +/** Stable id so the reply can be matched back to this pause on resume. */ +const APPROVAL_INTERRUPT_ID = 'user_approval'; + +/** Pauses the workflow and waits for user input. */ +const getUserApproval = node( + (ctx: NodeContext) => { + const reply = ctx.resumeInputs[APPROVAL_INTERRUPT_ID]; + if (reply === undefined) { + // First pass: raise the interrupt and pause. + return new RequestInput({ + interruptId: APPROVAL_INTERRUPT_ID, + message: 'Please approve this request (Yes/No)', + }); + } + // Second pass: the human's reply becomes this node's output. + return reply; + }, + {name: 'get_user_approval', rerunOnResume: true}, +); + +/** The orchestrator calling the interactive step. */ +const handleProcess = node( + async (ctx: NodeContext, nodeInput: unknown) => { + const approval = await ctx.runNode(getUserApproval, nodeInput); + + // Still waiting on the human: return without deciding. The workflow pauses + // and this body re-runs once the reply arrives. + if (approval.interruptIds.length > 0) { + return undefined; + } + + const userResponse = String(approval.output ?? '') + .trim() + .toLowerCase(); + + if (userResponse === 'yes') { + return 'Approved'; + } + return 'Denied'; + }, + {name: 'handle_process', rerunOnResume: true}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', handleProcess]], +}); diff --git a/samples/workflows/dynamic/loop_route/agent.ts b/samples/workflows/dynamic/loop_route/agent.ts new file mode 100644 index 0000000000..d99cbf3fb9 --- /dev/null +++ b/samples/workflows/dynamic/loop_route/agent.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/dynamic/#loop-route + * + * @node # workflow node + * async def code_workflow(ctx: Context, user_request: str): + * code = await ctx.run_node(coder_agent, user_request) + * check_resp = await ctx.run_node(compile_lint_check, code) + * + * while check_resp.findings: + * yield Event(state={"code": code, "findings": check_resp.findings}) + * code = await ctx.run_node(fixer_agent, {"code": code, "findings": ...}) + * check_resp = await ctx.run_node(compile_lint_check, code) + * + * return code + * + * This is where dynamic workflows earn their keep: the iteration is an ordinary + * `while` loop, not a back-edge you have to reason about. Values live in local + * variables; state is written only where an agent's instruction template needs + * to read it back (`{code}`, `{findings}`). + * + * Unlike a graph cycle, the loop is trivially bounded — `MAX_FIX_ROUNDS` here — + * so a stubborn model cannot spin forever burning live model calls. + * + * REQUIRES an API key (two agents call a live model). Set GEMINI_API_KEY: + * npm run sample -- samples/workflows/dynamic/loop_route/agent.ts + * Try "a function that returns the nth fibonacci number". + */ + +import {LlmAgent, node, NodeContext, WorkflowAgent} from '@google/adk'; + +/** Safety bound on the refine loop. */ +const MAX_FIX_ROUNDS = 3; + +const coderAgent = node( + new LlmAgent({ + name: 'generator_agent', + model: 'gemini-2.5-flash', + instruction: 'Write python code for the user request. Output code only.', + }), +); + +/** Simulates a compile / lint pass. Empty findings means "clean". */ +const compileLintCheck = node( + (_ctx: NodeContext, code: string) => { + const findings: string[] = []; + if (!/"""/.test(code)) { + findings.push('every function needs a docstring'); + } + if (!/->/.test(code)) { + findings.push('add return type annotations'); + } + return {findings: findings.join('; ')}; + }, + {name: 'lint_reviewer'}, +); + +const fixerAgent = node( + new LlmAgent({ + name: 'fixer_agent', + model: 'gemini-2.5-flash', + instruction: `Refactor current code {code}. + Based on compile & lint review: {findings} + Output code only.`, + }), +); + +const codeWorkflow = node( + async (ctx: NodeContext, userRequest: string) => { + let code = (await ctx.runNode(coderAgent, userRequest)).output as string; + let checkResp = (await ctx.runNode(compileLintCheck, code)).output as { + findings: string; + }; + + for (let round = 0; checkResp.findings && round < MAX_FIX_ROUNDS; round++) { + // The fixer agent reads `{code}` / `{findings}` from session state. + ctx.state.set('code', code); + ctx.state.set('findings', checkResp.findings); + + code = ( + await ctx.runNode(fixerAgent, {code, findings: checkResp.findings}) + ).output as string; + checkResp = (await ctx.runNode(compileLintCheck, code)).output as { + findings: string; + }; + } + + return code; + }, + {name: 'code_workflow', rerunOnResume: true}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', codeWorkflow]], +}); diff --git a/samples/workflows/dynamic/nodes/agent.ts b/samples/workflows/dynamic/nodes/agent.ts new file mode 100644 index 0000000000..e3a8354522 --- /dev/null +++ b/samples/workflows/dynamic/nodes/agent.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippets in + * https://adk.dev/graphs/dynamic/#node and + * https://adk.dev/graphs/dynamic/#workflows + * + * @node(name="hello_node") + * def my_function_node(node_input: Any): + * return "Hello World" + * + * # ...the same thing without the decorator: + * success_node = FunctionNode(my_function_node, name="hello", + * rerun_on_resume=True) + * + * The two ways to build a node, and how an orchestrator composes them. + * + * node(fn, options) the factory — Python's `@node` + * new FunctionNode(name, fn, config) the explicit constructor + * + * Reach for the explicit constructor when you are wrapping a function from + * another library, need several differently-configured nodes from one function, + * or keep node references in a registry for advanced orchestration. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/dynamic/nodes/agent.ts + */ + +import {FunctionNode, node, NodeContext, WorkflowAgent} from '@google/adk'; + +/** The plain function both node forms wrap. */ +function myFunctionNode(_ctx: NodeContext, nodeInput: unknown): string { + return `Hello ${nodeInput ?? 'World'}`; +} + +// Form 1 — the `node()` factory (Python's `@node(name="hello_node")`). +const helloNode = node(myFunctionNode, {name: 'hello_node'}); + +// Form 2 — the explicit constructor, same function, different configuration. +const successNode = new FunctionNode('hello', myFunctionNode, { + rerunOnResume: true, +}); + +const myFormattingNode = node( + (_ctx: NodeContext, nodeInput: string) => `>> ${nodeInput.trim()} <<`, + {name: 'my_formatting_node'}, +); + +// The orchestrator: run children in order and return the last result. +const myWorkflow = node( + async (ctx: NodeContext, nodeInput: unknown) => { + const greeted = await ctx.runNode(helloNode, nodeInput); + const again = await ctx.runNode(successNode, greeted.output); + const formatted = await ctx.runNode(myFormattingNode, again.output); + return formatted.output; + }, + {name: 'my_workflow', rerunOnResume: true}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', myWorkflow]], +}); diff --git a/samples/workflows/dynamic/parallel_route/agent.ts b/samples/workflows/dynamic/parallel_route/agent.ts new file mode 100644 index 0000000000..95bbe46cea --- /dev/null +++ b/samples/workflows/dynamic/parallel_route/agent.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/dynamic/#parallel-execution-routes + * + * @node(rerun_on_resume=True) + * async def parallel_supervisor(ctx, node_input: list[Any], real_node): + * tasks = [] + * for item in node_input: + * # ctx.run_node returns a future. Append instead of awaiting. + * tasks.append(ctx.run_node(real_node, item)) + * results = await asyncio.gather(*tasks) + * return results + * + * `ctx.runNode()` returns a promise, so starting every child before awaiting any + * of them runs them concurrently — `Promise.all` is the `asyncio.gather` + * equivalent. Run ids are assigned in CALL order, so kick the children off in a + * synchronous loop to keep them deterministic across a resume. + * + * Resuming parallel nodes: on resume only the failed or interrupted workers + * re-execute; children that already completed are replayed from their + * checkpoints. + * + * Prefer the built-in when the shape is "map one node over a list": + * node(worker, {parallelWorker: true, maxParallelWorkers: 4}) + * It does the fan-out for you and bounds concurrency (default 8). Hand-rolling + * it, as below, is for when you need custom scheduling or partial-failure + * handling. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/dynamic/parallel_route/agent.ts + * Enter a comma-separated list, e.g. "alpha, beta, gamma". + */ + +import {node, NodeContext, WorkflowAgent} from '@google/adk'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** The worker run once per list item. */ +const realNode = node( + async (_ctx: NodeContext, item: string) => { + await sleep(200); // stand-in for real work + return {item, length: item.length}; + }, + {name: 'analyze_item'}, +); + +const parallelSupervisor = node( + async (ctx: NodeContext, nodeInput: string) => { + const items = String(nodeInput) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + // Start every child first (no await inside the loop), then gather. + const tasks = items.map((item) => ctx.runNode(realNode, item)); + const results = await Promise.all(tasks); + + return results.map((result) => result.output); + }, + {name: 'parallel_supervisor', rerunOnResume: true}, +); + +const summarize = node( + (_ctx: NodeContext, results: Array<{item: string; length: number}>) => + results.map((r) => `${r.item}: ${r.length} chars`).join('\n'), + {name: 'summarize'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', parallelSupervisor, summarize]], +}); diff --git a/samples/workflows/dynamic/sequence_route/agent.ts b/samples/workflows/dynamic/sequence_route/agent.ts new file mode 100644 index 0000000000..b43733aa22 --- /dev/null +++ b/samples/workflows/dynamic/sequence_route/agent.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippets in + * https://adk.dev/graphs/dynamic/#data-handling (the `CityTime` variant) and + * https://adk.dev/graphs/dynamic/#sequence-route + * + * @node # workflow node + * async def city_workflow(ctx: Context): + * city = await ctx.run_node(city_generator_agent) + * city_time = await ctx.run_node(city_time_function, city) + * report_text = await ctx.run_node(city_report_agent, city_time) + * return report_text + * + * A sequential route in a dynamic workflow is just awaiting `ctx.runNode()` + * calls one after another — each finishes before the next starts. Schemas work + * the same as in a graph: attach them to the nodes you run. + * + * REQUIRES an API key (two nodes call a live model). Set GEMINI_API_KEY, then: + * npm run sample -- samples/workflows/dynamic/sequence_route/agent.ts + */ + +import {LlmAgent, node, NodeContext, WorkflowAgent} from '@google/adk'; +import {z} from 'zod'; + +const cityTimeSchema = z.object({ + timeInfo: z.string().describe('Time information.'), + city: z.string().describe('City name.'), +}); +type CityTime = z.infer; + +const cityGeneratorAgent = node( + new LlmAgent({ + name: 'city_generator_agent', + model: 'gemini-2.5-flash', + instruction: 'Return the name of a random city. Return only the name.', + }), +); + +/** Simulates returning the current time in a specified city. */ +const cityTimeFunction = node( + (_ctx: NodeContext, city: string): CityTime => ({ + timeInfo: '10:10 AM', + city: String(city).trim(), + }), + {name: 'city_time_function', outputSchema: cityTimeSchema}, +); + +const cityReportAgent = node( + new LlmAgent({ + name: 'city_report_agent', + model: 'gemini-2.5-flash', + instruction: 'Output the data provided by the previous node as a sentence.', + }), + {inputSchema: cityTimeSchema}, +); + +const cityWorkflow = node( + async (ctx: NodeContext) => { + const city = await ctx.runNode(cityGeneratorAgent); + const cityTime = await ctx.runNode(cityTimeFunction, city.output); + const reportText = await ctx.runNode(cityReportAgent, cityTime.output); + + return reportText.output; + }, + {name: 'city_workflow', rerunOnResume: true}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', cityWorkflow]], +}); diff --git a/samples/workflows/graphs/get_started/agent.ts b/samples/workflows/graphs/get_started/agent.ts new file mode 100644 index 0000000000..bd2250df57 --- /dev/null +++ b/samples/workflows/graphs/get_started/agent.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/#get-started + * + * A sequential graph workflow that alternates between AI reasoning and plain + * code: an agent names a random city, a code function looks up the time there, + * a second agent reports it, and a final function appends a completion message. + * + * REQUIRES an API key (two nodes call a live model). Set GEMINI_API_KEY, then: + * npm run sample -- samples/workflows/graphs/get_started/agent.ts + */ + +import { + createEvent, + LlmAgent, + node, + NodeContext, + WorkflowAgent, +} from '@google/adk'; +import {z} from 'zod'; + +const cityGeneratorAgent = new LlmAgent({ + name: 'city_generator_agent', + model: 'gemini-2.5-flash', + instruction: `Return the name of a random city. + Return only the name, nothing else.`, +}); + +/** Python: `class CityTime(BaseModel)`. */ +const cityTimeSchema = z.object({ + timeInfo: z.string().describe('Time information.'), + city: z.string().describe('City name.'), +}); +type CityTime = z.infer; + +/** Simulates returning the current time in the specified city. */ +function lookupTimeFunction(_ctx: NodeContext, nodeInput: string): CityTime { + return {timeInfo: '10:10 AM', city: nodeInput.trim()}; +} + +const cityReportAgent = new LlmAgent({ + name: 'city_report_agent', + model: 'gemini-2.5-flash', + // `{CityTime.}` selects a field off THIS node's input. adk-js supports + // Python's data-selection syntax verbatim; the `CityTime.` prefix is + // documentation, only the field name after the dot is resolved. + instruction: `Output the following line: + It is {CityTime.timeInfo} in {CityTime.city} right now.`, +}); + +function completedMessageFunction(_ctx: NodeContext, nodeInput: string) { + // Python's `Event(message=...)`. TypeScript events have no `message` field — + // a user-facing message is the event's `content` (and, unlike `output`, it is + // not handed to the next node). + return createEvent({ + content: { + role: 'model', + parts: [{text: `${nodeInput}\n WORKFLOW COMPLETED.`}], + }, + }); +} + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [ + [ + 'START', + cityGeneratorAgent, + node(lookupTimeFunction, { + name: 'lookup_time_function', + outputSchema: cityTimeSchema, + }), + // Python declares `input_schema=CityTime` on the Agent. In a graph the + // validating schema belongs to the node wrapping the agent — an + // `LlmAgent.inputSchema` is only used when the agent is exposed as a tool. + node(cityReportAgent, {inputSchema: cityTimeSchema}), + node(completedMessageFunction, {name: 'completed_message_function'}), + ], + ], +}); diff --git a/samples/workflows/graphs/process_pipeline/agent.ts b/samples/workflows/graphs/process_pipeline/agent.ts new file mode 100644 index 0000000000..545d71dd48 --- /dev/null +++ b/samples/workflows/graphs/process_pipeline/agent.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/#build-processes-with-graphs + * + * A prompt-based agent turned into a graph: one agent classifies the message, + * a router node emits the categories as routes, and the graph dispatches to the + * matching handler(s). Because the classifier may return more than one + * category, the router emits an ARRAY of routes — every matching branch fires. + * + * REQUIRES an API key (classification calls a live model). Set GEMINI_API_KEY: + * npm run sample -- samples/workflows/graphs/process_pipeline/agent.ts + * Try "the checkout page throws a 500" or "where is my parcel?". + */ + +import { + createEvent, + LlmAgent, + node, + NodeContext, + WorkflowAgent, +} from '@google/adk'; + +const processMessage = new LlmAgent({ + name: 'process_message', + model: 'gemini-2.5-flash', + instruction: `Classify user message into either "BUG", "CUSTOMER_SUPPORT", + or "LOGISTICS". If you think a message applies to more than one category, + reply with a comma separated list of categories. + Reply with the categories only, nothing else.`, +}); + +// Python: `return Event(route=routes)`. A route array fires EVERY branch whose +// route key matches one of the listed values (multi-route dispatch). +const router = node( + (_ctx: NodeContext, nodeInput: string) => + createEvent({ + route: nodeInput + .split(',') + .map((route) => route.trim().toUpperCase()) + .filter(Boolean), + }), + {name: 'router'}, +); + +/** Emits a user-facing message (Python's `Event(message=...)`). */ +const message = (text: string) => + createEvent({content: {role: 'model', parts: [{text}]}}); + +const response1Bug = node(() => message('Handling bug...'), { + name: 'response_1_bug', +}); +const response2Support = node(() => message('Handling customer support...'), { + name: 'response_2_support', +}); +const response3Logistics = node(() => message('Handling logistics...'), { + name: 'response_3_logistics', +}); + +export const rootAgent = new WorkflowAgent({ + name: 'routing_workflow', + edges: [ + ['START', processMessage, router], + [ + router, + { + BUG: response1Bug, + CUSTOMER_SUPPORT: response2Support, + LOGISTICS: response3Logistics, + }, + ], + ], +}); diff --git a/samples/workflows/human_input/get_started/agent.ts b/samples/workflows/human_input/get_started/agent.ts new file mode 100644 index 0000000000..65657eaca3 --- /dev/null +++ b/samples/workflows/human_input/get_started/agent.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/human-input/#get-started + * + * def step1(): # Human input step + * yield RequestInput(message="Enter a number:") + * + * def step2(node_input): + * return node_input * 2 + * + * root_agent = Workflow(name="root_agent", edges=[('START', step1, step2)]) + * + * `step1` pauses the workflow until the user replies; the reply is then handed + * to the next node as its input. This is the default `rerunOnResume: false` + * handoff: the interrupted node does NOT re-run — it completes with the user's + * reply as its output. (For the re-entry variant, where the paused node itself + * re-runs and receives the reply, see samples/workflows/dynamic/human_input.) + * + * A HITL node needs no model, which makes the pause fully deterministic. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/human_input/get_started/agent.ts + * Turn 1: anything. Turn 2: type a number, e.g. "21". + */ + +import {node, NodeContext, RequestInput, WorkflowAgent} from '@google/adk'; + +const step1 = node( + async function* () { + yield new RequestInput({message: 'Enter a number:'}); + }, + {name: 'step1'}, +); + +const step2 = node( + (_ctx: NodeContext, nodeInput: string | number) => { + // An interactive reply arrives as text, so coerce before doing maths. + const value = Number(nodeInput); + return Number.isFinite(value) + ? value * 2 + : `"${nodeInput}" is not a number.`; + }, + {name: 'step2'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'root_agent', + edges: [['START', step1, step2]], +}); diff --git a/samples/workflows/human_input/initial_prompt/agent.ts b/samples/workflows/human_input/initial_prompt/agent.ts new file mode 100644 index 0000000000..369c1f3675 --- /dev/null +++ b/samples/workflows/human_input/initial_prompt/agent.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python `initial_prompt` snippet in + * https://adk.dev/graphs/human-input/#tool-confirmation-approval-prompts-in-llm-agents + * + * async def initial_prompt(ctx: Context): + * yield RequestInput(message=input_message, response_schema=str) + * + * A HITL node as the FIRST step of a workflow: instead of guessing what the + * user wants, the graph opens by asking, pauses, and then routes the reply into + * the rest of the process. + * + * `response_schema=str` becomes `responseSchema: z.string()` — a plain text + * reply. Nothing coerces the human's answer into that shape; the schema tells a + * client what to collect. + * + * (The same docs section also covers tool-confirmation, an LlmAgent-level + * mechanism rather than a graph node: set `requireConfirmation: true` on a + * `FunctionTool` and the agent pauses for approval before that tool runs.) + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/human_input/initial_prompt/agent.ts + * Turn 1: anything. Turn 2: e.g. "Lisbon, 34, cycling, liked the tram tour". + */ + +import {node, NodeContext, RequestInput, WorkflowAgent} from '@google/adk'; +import {z} from 'zod'; + +/** Asks the user for itinerary information. */ +const initialPrompt = node( + async function* () { + const inputMessage = ` + This is an interactive concierge workflow tasked with making you a great + itinerary for you in your city of choice. If you give some details about + yourself or what you are generally looking for I can better personalize + your itinerary. + For example, input your: + City (Required), + Age, + Hobby, + Example of attraction you liked + `; + yield new RequestInput({ + message: inputMessage, + responseSchema: z.string(), + }); + }, + {name: 'initial_prompt'}, +); + +// Receives the user's reply as its input and kicks off the real work. +const buildItinerary = node( + (_ctx: NodeContext, nodeInput: string) => { + const [city = 'your city'] = String(nodeInput).split(','); + return ( + `Personalized itinerary for ${city.trim()}:\n` + + ' 1. Morning walk through the old town\n' + + ' 2. Lunch at a neighbourhood favourite\n' + + ' 3. An afternoon activity matched to your hobby\n\n' + + `(based on: ${String(nodeInput).trim()})` + ); + }, + {name: 'build_itinerary'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'concierge_workflow', + edges: [['START', initialPrompt, buildItinerary]], +}); diff --git a/samples/workflows/human_input/payload_and_schema/agent.ts b/samples/workflows/human_input/payload_and_schema/agent.ts new file mode 100644 index 0000000000..804ff062ca --- /dev/null +++ b/samples/workflows/human_input/payload_and_schema/agent.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/human-input/#request-input-with-a-message-and-payload + * + * yield RequestInput( + * message=message, + * payload=node_input, + * response_schema=UserFeedback, + * ) + * + * `RequestInput` takes three configuration options: + * message text shown to the user explaining what is being asked + * payload structured data sent alongside the prompt, so a client can + * render richer context (here, the full itinerary) + * responseSchema the shape the reply is expected to take + * + * Note on `responseSchema`: `RequestInput` does NOT reformat a human reply to + * fit the schema — the reply must already be in that shape. For a good UX, + * either collect structured data in your UI, or put an agent node after the + * pause to normalize whatever the human typed. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/human_input/payload_and_schema/agent.ts + * Turn 1: a city, e.g. "Lisbon". Turn 2: say which activities appeal to you. + */ + +import {node, NodeContext, RequestInput, WorkflowAgent} from '@google/adk'; +import {z} from 'zod'; + +/** + * Itinerary is a list of activities. Each activity has a name and a + * description. + */ +const activitiesListSchema = z.object({ + itinerary: z.array(z.object({name: z.string(), description: z.string()})), +}); +type ActivitiesList = z.infer; + +/** Expected response structure from the user. */ +const userFeedbackSchema = z.object({ + userResponse: z.string(), +}); + +// Stands in for the agent node that composes the base itinerary. +const buildItinerary = node( + (_ctx: NodeContext, city: string): ActivitiesList => { + const place = String(city).trim() || 'your city'; + return { + itinerary: [ + {name: 'Morning walk', description: `A stroll through old ${place}.`}, + {name: 'Local lunch', description: `Regional food in ${place}.`}, + {name: 'Museum visit', description: `The main museum of ${place}.`}, + ], + }; + }, + {name: 'build_itinerary', outputSchema: activitiesListSchema}, +); + +/** + * Retrieves the user's thoughts on the agent's initial itinerary in order to + * either expand on it, change the list, or exit the loop. + */ +const getUserFeedback = node( + async function* (_ctx: NodeContext, nodeInput: ActivitiesList) { + const rendered = nodeInput.itinerary + .map((a, i) => ` ${i + 1}. ${a.name} — ${a.description}`) + .join('\n'); + + yield new RequestInput({ + message: + `Here is your recommended base itinerary:\n${rendered}\n\n` + + 'Which of these items appeal to you (if any)?', + payload: nodeInput, + responseSchema: userFeedbackSchema, + }); + }, + {name: 'get_user_feedback'}, +); + +// Receives the human's reply as its input (default handoff on resume). +const applyFeedback = node( + (_ctx: NodeContext, nodeInput: unknown) => { + // The reply is either the structured `UserFeedback` shape or, from an + // interactive client, plain text. + const feedback = + typeof nodeInput === 'string' + ? nodeInput + : String( + (nodeInput as {userResponse?: unknown} | null)?.userResponse ?? + JSON.stringify(nodeInput), + ); + return `Noted. Building the final itinerary around: ${feedback}`; + }, + {name: 'apply_feedback'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'concierge_workflow', + edges: [['START', buildItinerary, getUserFeedback, applyFeedback]], +}); diff --git a/samples/workflows/routes/branches/agent.ts b/samples/workflows/routes/branches/agent.ts new file mode 100644 index 0000000000..46140381b5 --- /dev/null +++ b/samples/workflows/routes/branches/agent.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/routes/#route-branches-and-conditional-execution + * + * Branching is a node that emits a `route`, plus an edge row mapping each route + * value to the node that handles it. A branch target can be anything node-like: + * `task_B_node` here is an `LlmAgent`, `task_C_node` a plain function. + * + * The docs snippet leaves `task_A_node` and `condition()` undefined; this port + * defines them (the condition is "the input mentions a number"). + * + * REQUIRES an API key when the RUN_TASK_B branch is taken (it calls a live + * model). Set GEMINI_API_KEY, then: + * npm run sample -- samples/workflows/routes/branches/agent.ts + * Try "tell me about graphs" (task B) or "give me 3 facts" (task C). + */ + +import { + createEvent, + LlmAgent, + node, + NodeContext, + WorkflowAgent, +} from '@google/adk'; + +const taskANode = node( + (_ctx: NodeContext, nodeInput: string) => String(nodeInput).trim(), + {name: 'task_A_node'}, +); + +/** Stands in for the docs' unspecified `condition()`. */ +const condition = (nodeInput: string) => /\d/.test(nodeInput); + +/** Routes to task B or C based on nodeInput. */ +const router = node( + (_ctx: NodeContext, nodeInput: string) => + condition(nodeInput) + ? createEvent({route: 'RUN_TASK_C', output: nodeInput}) + : createEvent({route: 'RUN_TASK_B', output: nodeInput}), + {name: 'router'}, +); + +// An agent to execute node B. +const taskBNode = new LlmAgent({ + name: 'task_B_agent', + model: 'gemini-2.5-flash', + instruction: 'Answer the user in a single short sentence.', +}); + +// A FunctionNode to execute node C. +const taskCNode = node(() => 'Task C completed', {name: 'task_C_node'}); + +export const rootAgent = new WorkflowAgent({ + name: 'routing_workflow', + edges: [ + ['START', taskANode, router], + [ + router, + { + // "route value": node_to_run + RUN_TASK_B: taskBNode, + RUN_TASK_C: taskCNode, + }, + ], + ], +}); diff --git a/samples/workflows/routes/fan_out_join/agent.ts b/samples/workflows/routes/fan_out_join/agent.ts new file mode 100644 index 0000000000..c3bb32a5f1 --- /dev/null +++ b/samples/workflows/routes/fan_out_join/agent.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/routes/#parallel-tasks-fan-out-and-join-paths + * + * my_join_node = JoinNode(name="my_join_node") + * edges=[ + * ("START", parallel_task_A, my_join_node), + * ("START", parallel_task_B, my_join_node), + * ("START", parallel_task_C, my_join_node), + * (my_join_node, final_task_D), + * ] + * + * A `JoinNode` is a fan-in barrier: it waits for EVERY predecessor to finish and + * then hands the next node an object keyed by predecessor node name. + * + * Caution: a JoinNode proceeds only once all upstream nodes have produced an + * output. If one fails to produce output the join is stuck and the workflow + * stops — give any node feeding a join a failsafe output (or a `retryConfig`). + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/routes/fan_out_join/agent.ts + */ + +import {JoinNode, node, NodeContext, WorkflowAgent} from '@google/adk'; + +const parallelTaskA = node( + (_ctx: NodeContext, text: string) => text.toUpperCase(), + {name: 'parallel_task_A'}, +); + +const parallelTaskB = node((_ctx: NodeContext, text: string) => text.length, { + name: 'parallel_task_B', +}); + +const parallelTaskC = node( + (_ctx: NodeContext, text: string) => text.split('').reverse().join(''), + {name: 'parallel_task_C'}, +); + +const myJoinNode = new JoinNode({name: 'my_join_node'}); + +// The join hands its successor a record keyed by predecessor node name. +const finalTaskD = node( + (_ctx: NodeContext, results: Record) => + [ + `Uppercase: ${results['parallel_task_A']}`, + `Length: ${results['parallel_task_B']}`, + `Reversed: ${results['parallel_task_C']}`, + ].join('\n'), + {name: 'final_task_D'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'fan_out_workflow', + // One edge row per parallel path, exactly as in the Python snippet. The + // equivalent TypeScript shorthand nests the parallel nodes in an array: + // [['START', [parallelTaskA, parallelTaskB, parallelTaskC], myJoinNode, + // finalTaskD]] + edges: [ + ['START', parallelTaskA, myJoinNode], + ['START', parallelTaskB, myJoinNode], + ['START', parallelTaskC, myJoinNode], + [myJoinNode, finalTaskD], + ], +}); diff --git a/samples/workflows/routes/function_node/agent.ts b/samples/workflows/routes/function_node/agent.ts new file mode 100644 index 0000000000..5595603cd2 --- /dev/null +++ b/samples/workflows/routes/function_node/agent.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/routes/#nodes + * + * The simplest node type: a plain function wrapped as a FunctionNode. It takes + * text in, returns text out, and the framework hands that value to the next + * node as its input — no session-state writes needed. + * + * Python returns `Event(output=...)` explicitly; in TypeScript a bare return + * value is boxed into an `Event` with that `output` for you, so both forms + * below are equivalent. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/routes/function_node/agent.ts + */ + +import { + createEvent, + node, + NodeContext, + WorkflowAgent, + type FunctionNodeHandler, +} from '@google/adk'; + +/** Python: `return Event(output=node_input.upper())`. */ +const myFunctionNode: FunctionNodeHandler = ( + _ctx: NodeContext, + nodeInput: string, +) => { + const inputTextModified = nodeInput.toUpperCase(); + return inputTextModified; +}; + +/** The explicit form — identical behaviour, useful when you also set `route`. */ +const myExplicitEventNode = (_ctx: NodeContext, nodeInput: string) => + createEvent({output: `${nodeInput} IS AWESOME!`}); + +export const rootAgent = new WorkflowAgent({ + name: 'function_node_pipeline', + edges: [ + [ + 'START', + node(myFunctionNode, {name: 'my_function_node'}), + node(myExplicitEventNode, {name: 'add_suffix'}), + ], + ], +}); diff --git a/samples/workflows/routes/loop_escalation/agent.ts b/samples/workflows/routes/loop_escalation/agent.ts new file mode 100644 index 0000000000..c02c8f4be4 --- /dev/null +++ b/samples/workflows/routes/loop_escalation/agent.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of + * https://adk.dev/graphs/routes/#loop-and-escalation-exit + * + * A loop is a BACK-EDGE in the graph: a downstream node routes back to an + * earlier node, and the engine re-activates that node with a fresh lifecycle on + * each iteration. The loop exits when the router picks the terminal branch + * instead — the graph equivalent of a LoopAgent's escalation exit. + * + * START -> seed_draft -> critic -> router --REVISE--> refine --+ + * ^ | + * +----------------------------------+ + * router --DONE--> finalize + * + * The docs page shows the generic router snippet for this section; this port + * adds the back-edge that actually makes it a loop, and keeps the exit + * condition deterministic so the sample terminates. + * + * Note: a graph cycle is NOT capped by the framework. Make sure your exit + * condition always becomes true (here the draft gains a bullet each pass), or + * bound the loop yourself — see samples/workflows/dynamic/loop_route. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/routes/loop_escalation/agent.ts + */ + +import {createEvent, node, NodeContext, WorkflowAgent} from '@google/adk'; + +interface Draft { + topic: string; + bullets: string[]; +} + +/** The critic is satisfied once the draft has at least this many bullets. */ +const REQUIRED_BULLETS = 3; + +const seedDraft = node( + (_ctx: NodeContext, topic: string): Draft => ({ + topic: String(topic).trim(), + bullets: [`${String(topic).trim()} — point 1`], + }), + {name: 'seed_draft'}, +); + +// Runs once per incoming trigger: first from seed_draft, then from every +// refine pass around the back-edge. +const critic = node( + (_ctx: NodeContext, draft: Draft) => + createEvent({ + route: draft.bullets.length >= REQUIRED_BULLETS ? 'DONE' : 'REVISE', + output: draft, + }), + {name: 'critic'}, +); + +const refine = node( + (_ctx: NodeContext, draft: Draft): Draft => ({ + ...draft, + bullets: [ + ...draft.bullets, + `${draft.topic} — point ${draft.bullets.length + 1}`, + ], + }), + {name: 'refine'}, +); + +const finalize = node( + (_ctx: NodeContext, draft: Draft) => + `Approved after ${draft.bullets.length} bullets:\n` + + draft.bullets.map((b) => ` • ${b}`).join('\n'), + {name: 'finalize'}, +); + +export const rootAgent = new WorkflowAgent({ + name: 'loop_workflow', + edges: [ + ['START', seedDraft, critic], + [critic, {REVISE: refine, DONE: finalize}], + // The back-edge that closes the loop. + [refine, critic], + ], +}); diff --git a/samples/workflows/routes/nested_workflow/agent.ts b/samples/workflows/routes/nested_workflow/agent.ts new file mode 100644 index 0000000000..907a5fe436 --- /dev/null +++ b/samples/workflows/routes/nested_workflow/agent.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/routes/#nested-workflows + * + * root_agent = Workflow( + * name="parent_workflow", + * edges=[ + * ("START", task_A1, router), + * (router, {"RUN_WORKFLOW_B": workflow_B, "RUN_WORKFLOW_C": workflow_C}), + * ], + * ) + * + * A `Workflow` is itself a node, so it can be dropped straight into another + * workflow's edges to encapsulate a reusable sub-process. + * + * Nested workflow data output: while the inner workflow runs, each of its node + * events bubbles up to the parent for traceability. When it finishes, the output + * of its terminal node becomes the output of the nested-workflow node. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/routes/nested_workflow/agent.ts + * Try "hello there" (workflow B) or "HELLO THERE" (workflow C). + */ + +import { + createEvent, + node, + NodeContext, + Workflow, + WorkflowAgent, +} from '@google/adk'; + +const taskA1 = node( + (_ctx: NodeContext, nodeInput: string) => String(nodeInput).trim(), + {name: 'task_A1'}, +); + +const router = node( + (_ctx: NodeContext, text: string) => + createEvent({ + route: text === text.toUpperCase() ? 'RUN_WORKFLOW_C' : 'RUN_WORKFLOW_B', + output: text, + }), + {name: 'router'}, +); + +// --- Sub-workflow B: title-case each word, then frame it. --- +const workflowB = new Workflow({ + name: 'workflow_B', + edges: [ + [ + 'START', + node( + (_ctx: NodeContext, text: string) => + text.replace(/\b\w/g, (c) => c.toUpperCase()), + {name: 'b_title_case'}, + ), + node((_ctx: NodeContext, text: string) => `[B] ${text}`, { + name: 'b_frame', + }), + ], + ], +}); + +// --- Sub-workflow C: lower-case, then frame it. --- +const workflowC = new Workflow({ + name: 'workflow_C', + edges: [ + [ + 'START', + node((_ctx: NodeContext, text: string) => text.toLowerCase(), { + name: 'c_lower_case', + }), + node((_ctx: NodeContext, text: string) => `[C] ${text}`, { + name: 'c_frame', + }), + ], + ], +}); + +export const rootAgent = new WorkflowAgent({ + name: 'parent_workflow', + edges: [ + ['START', taskA1, router], + [ + router, + { + RUN_WORKFLOW_B: workflowB, + RUN_WORKFLOW_C: workflowC, + }, + ], + ], +}); diff --git a/samples/workflows/routes/sequence/agent.ts b/samples/workflows/routes/sequence/agent.ts new file mode 100644 index 0000000000..8ee3a4c7a8 --- /dev/null +++ b/samples/workflows/routes/sequence/agent.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TypeScript port of the Python snippet in + * https://adk.dev/graphs/routes/#route-sequences + * + * edges=[("START", task_A_node)] # single node + * edges=[("START", task_A_node, task_B_node, task_C_node)] # 3 in order + * + * A sequential route runs each node once, in the listed order. Each node's + * return value is delivered to the next node as its input. + * + * Run (offline, no API key): + * npm run sample -- samples/workflows/routes/sequence/agent.ts + */ + +import {node, NodeContext, WorkflowAgent} from '@google/adk'; + +const taskANode = node( + (_ctx: NodeContext, nodeInput: string) => `Summary: ${nodeInput.trim()}`, + {name: 'task_A_node'}, +); + +const taskBNode = node( + (_ctx: NodeContext, summary: string) => summary.toUpperCase(), + {name: 'task_B_node'}, +); + +const taskCNode = node( + (_ctx: NodeContext, shouted: string) => `${shouted} (done)`, + {name: 'task_C_node'}, +); + +// A single-node graph would simply be: +// edges: [['START', taskANode]] +export const rootAgent = new WorkflowAgent({ + name: 'sequential_workflow', + edges: [['START', taskANode, taskBNode, taskCNode]], +}); From 69679f2fe6d5f1a1daac80247058891f4ed53578 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Mon, 10 Aug 2026 14:31:49 -0700 Subject: [PATCH 2/8] 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. --- .github/workflows/validation.yaml | 3 +++ package.json | 1 + samples/tsconfig.json | 5 +++++ samples/workflows/README.md | 8 ++++++++ 4 files changed, 17 insertions(+) create mode 100644 samples/tsconfig.json diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml index 06a9ec83e8..fef6ff269e 100644 --- a/.github/workflows/validation.yaml +++ b/.github/workflows/validation.yaml @@ -44,6 +44,9 @@ jobs: - name: Run type check run: npm run ts:check + - name: Type check samples + run: npm run ts:check:samples + - name: Run tests and check code coverage run: npm run test:coverage diff --git a/package.json b/package.json index 44ca7be4f2..e7f4c585bc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "clean:all": "rm package-lock.json && rm -rf ./node_modules && npm run clean:all --workspaces", "rebuild": "npm run clean:all && npm install && npm run build", "ts:check": "tsc --noEmit", + "ts:check:samples": "tsc --noEmit -p samples", "lint": "eslint \"**/*.ts\"", "lint:fix": "eslint --fix \"**/*.ts\"", "format": "prettier \"**/*.ts\" --write", diff --git a/samples/tsconfig.json b/samples/tsconfig.json new file mode 100644 index 0000000000..54a7c47b83 --- /dev/null +++ b/samples/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/samples/workflows/README.md b/samples/workflows/README.md index 91e7d8cb00..d3cad4dea8 100644 --- a/samples/workflows/README.md +++ b/samples/workflows/README.md @@ -24,6 +24,14 @@ npm run sample -- samples/workflows/routes/sequence/agent.ts `npm run sample -- ` is shorthand for `node dev/dist/esm/cli_entrypoint.js run `. +`samples/` is not an npm workspace, so `npm run build` does not compile it. It +has its own `samples/tsconfig.json` and is type-checked separately, in CI and +locally: + +```bash +npm run ts:check:samples +``` + The CLI is interactive: type a message and press Enter to send it to the workflow; type `exit` to quit. Node events print as `[]: ` and the last line is the workflow's output. A node that emits only `output` (no From 2322fdcf85f0b030df74bcf7fc473912e7cf897b Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Tue, 11 Aug 2026 19:05:49 -0700 Subject: [PATCH 3/8] 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. --- samples/workflows/README.md | 72 +++++++++++-------- .../data_handling/node_output/agent.ts | 16 +++-- .../workflows/dynamic/human_input/agent.ts | 47 +++++------- 3 files changed, 72 insertions(+), 63 deletions(-) diff --git a/samples/workflows/README.md b/samples/workflows/README.md index d3cad4dea8..e6ad0fbf26 100644 --- a/samples/workflows/README.md +++ b/samples/workflows/README.md @@ -32,6 +32,16 @@ locally: npm run ts:check:samples ``` +CI also executes them, in `tests/integration/docs_samples/`: every sample is +constructed (a `WorkflowAgent` validates its graph in its constructor), and the +offline ones are run end-to-end with the model stubbed out, so a stray model +call in one of them fails too. A new sample directory has to be added to that +test's offline or model-backed list, or it fails for being uncovered. + +```bash +npx vitest run --project integration tests/integration/docs_samples +``` + The CLI is interactive: type a message and press Enter to send it to the workflow; type `exit` to quit. Node events print as `[]: ` and the last line is the workflow's output. A node that emits only `output` (no @@ -82,15 +92,15 @@ interactively. ### [`/graphs/data-handling/`](https://adk.dev/graphs/data-handling/) — `data_handling/` -| Sample | Docs section | Shows | Key | -| ------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --- | -| `node_output` | [Node output](https://adk.dev/graphs/data-handling/#node-output) | Return a value / an `Event` / yield; one `output` per node | — | -| `structured_output` | [Passing structured data](https://adk.dev/graphs/data-handling/#node-output-passing-structured-data) | A typed object across an edge, validated by schemas | — | -| `routing_output` | [Routing output](https://adk.dev/graphs/data-handling/#routing-output) | `route` and `output` on one event; `DEFAULT_ROUTE` | — | -| `user_message` | [User-facing messages](https://adk.dev/graphs/data-handling/#user-facing-messages) | A display message vs. data for the next node | — | -| `session_state` | [Session state and scopes](https://adk.dev/graphs/data-handling/#session-state-and-state-scopes) | `ctx.state`, the `app:`/`user:`/`temp:` prefixes | — | -| `schemas` | [Constrain node data with schemas](https://adk.dev/graphs/data-handling/#constrain-node-data-with-schemas) | `inputSchema` / `outputSchema` on an agent node, plus a tool | ✅ | -| `structured_access` | [Access structured data in agents](https://adk.dev/graphs/data-handling/#access-structured-data-in-agents) | `{Class.field}` and `` | ✅ | +| Sample | Docs section | Shows | Key | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | --- | +| `node_output` | [Node output](https://adk.dev/graphs/data-handling/#node-output) | Return a value / an `Event` / yield; last `output` event wins | — | +| `structured_output` | [Passing structured data](https://adk.dev/graphs/data-handling/#node-output-passing-structured-data) | A typed object across an edge, validated by schemas | — | +| `routing_output` | [Routing output](https://adk.dev/graphs/data-handling/#routing-output) | `route` and `output` on one event; `DEFAULT_ROUTE` | — | +| `user_message` | [User-facing messages](https://adk.dev/graphs/data-handling/#user-facing-messages) | A display message vs. data for the next node | — | +| `session_state` | [Session state and scopes](https://adk.dev/graphs/data-handling/#session-state-and-state-scopes) | `ctx.state`, the `app:`/`user:`/`temp:` prefixes | — | +| `schemas` | [Constrain node data with schemas](https://adk.dev/graphs/data-handling/#constrain-node-data-with-schemas) | `inputSchema` / `outputSchema` on an agent node, plus a tool | ✅ | +| `structured_access` | [Access structured data in agents](https://adk.dev/graphs/data-handling/#access-structured-data-in-agents) | `{Class.field}` and `` | ✅ | ### [`/graphs/human-input/`](https://adk.dev/graphs/human-input/) — `human_input/` @@ -102,16 +112,16 @@ interactively. ### [`/graphs/dynamic/`](https://adk.dev/graphs/dynamic/) — `dynamic/` -| Sample | Docs section | Shows | Key | -| ---------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | --- | -| `get_started` | [Get started](https://adk.dev/graphs/dynamic/#get-started) | An orchestrator node driving a child via `ctx.runNode()` | — | -| `nodes` | [Nodes](https://adk.dev/graphs/dynamic/#node) / [Workflows](https://adk.dev/graphs/dynamic/#workflows) | `node()` vs. `new FunctionNode()` | — | -| `data_handling` | [Data handling](https://adk.dev/graphs/dynamic/#data-handling) | `editorial_workflow`: agent → function, no state keys | ✅ | -| `sequence_route` | [Sequence route](https://adk.dev/graphs/dynamic/#sequence-route) | `city_workflow`: sequential `runNode` calls + schemas | ✅ | -| `loop_route` | [Loop route](https://adk.dev/graphs/dynamic/#loop-route) | A real `while` loop (generate → lint → fix), bounded | ✅ | -| `parallel_route` | [Parallel execution routes](https://adk.dev/graphs/dynamic/#parallel-execution-routes) | `Promise.all` fan-out (the `asyncio.gather` equivalent) | — | -| `human_input` | [Human input](https://adk.dev/graphs/dynamic/#human-input) | HITL inside an orchestrator, with re-entry on resume | — | -| `custom_run_ids` | [Custom execution IDs](https://adk.dev/graphs/dynamic/#custom-execution-ids) | `ctx.runNode(..., {runId})` for a reorderable collection | — | +| Sample | Docs section | Shows | Key | +| ---------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | --- | +| `get_started` | [Get started](https://adk.dev/graphs/dynamic/#get-started) | An orchestrator node driving a child via `ctx.runNode()` | — | +| `nodes` | [Nodes](https://adk.dev/graphs/dynamic/#node) / [Workflows](https://adk.dev/graphs/dynamic/#workflows) | `node()` vs. `new FunctionNode()` | — | +| `data_handling` | [Data handling](https://adk.dev/graphs/dynamic/#data-handling) | `editorial_workflow`: agent → function, no state keys | ✅ | +| `sequence_route` | [Sequence route](https://adk.dev/graphs/dynamic/#sequence-route) | `city_workflow`: sequential `runNode` calls + schemas | ✅ | +| `loop_route` | [Loop route](https://adk.dev/graphs/dynamic/#loop-route) | A real `while` loop (generate → lint → fix), bounded | ✅ | +| `parallel_route` | [Parallel execution routes](https://adk.dev/graphs/dynamic/#parallel-execution-routes) | `Promise.all` fan-out (the `asyncio.gather` equivalent) | — | +| `human_input` | [Human input](https://adk.dev/graphs/dynamic/#human-input) | HITL inside an orchestrator; the leaf keeps `rerunOnResume: false` | — | +| `custom_run_ids` | [Custom execution IDs](https://adk.dev/graphs/dynamic/#custom-execution-ids) | `ctx.runNode(..., {runId})` for a reorderable collection | — | ## Python → TypeScript differences @@ -127,9 +137,22 @@ differs, all called out again in the affected sample's header comment. - **No signature-based injection.** Python binds `node_input`/state values to named parameters by introspection. TypeScript handlers always take `(ctx, input)` and read state explicitly via `ctx.state`. +- **A workflow's input is a `string` only for a text-only turn** — for anything + else the entry node is handed the raw `Content`. Every entry node here + declares `nodeInput: string` and calls string methods on it directly, so a + non-text first turn fails loudly rather than stringifying to + `"[object Object]"`; take a `Content` (or `unknown`) if you need to accept + one. Values that genuinely are untyped — `ctx.runNode(...).output`, a + `ctx.resumeInputs[id]` reply — are coerced explicitly at the point of use. - **`ctx.runNode()` resolves to a node _result_,** not the output directly — read `.output`. It also does not throw when a child interrupts: check `.interruptIds` and bail out (see `dynamic/human_input`). +- **A second `output` event overwrites the first, silently.** The Python page + gives two accounts of emitting `output` more than once from a node — each + `yield` "adds to a list of data objects on the Event", and two yields carrying + `Event.output` are "a runtime error". Neither is what happens here: there is + no list and no error, the last event to set `output` wins, and the successor + never sees the rest. Emit it once (see `data_handling/node_output`). - **`LlmAgent.inputSchema` is not the node's input contract.** It is only used when the agent is exposed as a tool. Inside a graph, put the validating schema on the node: `node(agent, {inputSchema})`. @@ -137,10 +160,9 @@ differs, all called out again in the affected sample's header comment. - **`{Class.field}` and `` work verbatim** — the Python data-selection syntax is supported (see `data_handling/structured_access`). -## Two gotchas worth knowing +## A gotcha worth knowing -Both were found while smoke-testing these ports; each is documented in the -sample it affects. +Found while smoke-testing these ports, and documented in the sample it affects. - **Keep every session-state key single-writer.** A node's `ctx.state` writes land immediately, but they are also replayed when the runtime commits that @@ -149,12 +171,6 @@ sample it affects. already-superseded value. Move evolving values along the edges as `output` instead of read-modify-writing one key from several nodes (`data_handling/session_state`). -- **The `rerunOnResume: false` HITL handoff is static-graph only.** For a graph - node, "do not re-run; complete with the human's reply as my output" is what - makes the two-node pattern work (`human_input/get_started`). A dynamic - `ctx.runNode` child does not get that treatment — it is re-run — so a dynamic - HITL leaf needs the re-entry form: a stable `interruptId` plus a - `ctx.resumeInputs[id]` lookup (`dynamic/human_input`). ## See also diff --git a/samples/workflows/data_handling/node_output/agent.ts b/samples/workflows/data_handling/node_output/agent.ts index 7a9b9f8e34..c0de56861f 100644 --- a/samples/workflows/data_handling/node_output/agent.ts +++ b/samples/workflows/data_handling/node_output/agent.ts @@ -20,9 +20,14 @@ * `route`, `content`, or `actions` * 3. yield from a generator — to stream progress alongside the result * - * Caution: a node may emit only ONE event carrying `output` per execution. You - * can yield as many events as you like, but only one of them may set `output` — - * the rest should carry `content` (a display message) instead. + * Caution: emit `output` from ONE event per execution — but nothing enforces + * that here, so getting it wrong is silent. A node may yield any number of + * events carrying `output`; each one overwrites the last, and the successor + * receives only the final value. The Python page describes two other + * behaviours, and neither holds in TypeScript: it says each `yield` "adds to a + * list of data objects on the Event", and then cautions that two yields + * carrying `Event.output` are "a runtime error". There is no list and no + * error — just last-write-wins. Carry progress on `content` instead. * * Run (offline, no API key): * npm run sample -- samples/workflows/data_handling/node_output/agent.ts @@ -43,13 +48,14 @@ const returnEventOutput = node( {name: 'return_event_output'}, ); -// 3. A generator: stream progress, then emit the single output event last. +// 3. A generator: stream progress, then emit the output event last. const yieldProgressThenOutput = node( async function* (_ctx: NodeContext, nodeInput: string) { + // Progress goes on `content`: displayed, and not passed to the successor. yield createEvent({ content: {role: 'model', parts: [{text: 'Working on it...'}]}, }); - // Only this event sets `output`, so the one-payload rule holds. + // Exactly one event sets `output`, so there is nothing to overwrite it. yield createEvent({output: `<<${nodeInput}>>`}); }, {name: 'yield_progress_then_output'}, diff --git a/samples/workflows/dynamic/human_input/agent.ts b/samples/workflows/dynamic/human_input/agent.ts index 7752d00438..8b9497e48f 100644 --- a/samples/workflows/dynamic/human_input/agent.ts +++ b/samples/workflows/dynamic/human_input/agent.ts @@ -21,21 +21,16 @@ * * Important: a parent node that calls `ctx.runNode` must set * `rerunOnResume: true`, or it cannot handle an interrupt raised by a child. + * The leaf keeps the snippet's `rerun_on_resume=False`: on resume it does not + * re-run its body, it completes with the human's reply as its output, and + * `ctx.runNode()` hands that back to the caller. * - * !! Two TypeScript differences from the Python snippet. !! + * !! One TypeScript difference from the Python snippet. !! * - * 1. `ctx.runNode()` does NOT throw when a child interrupts. It resolves with a - * result whose `interruptIds` are populated and whose `output` is still - * undefined, so the orchestrator has to check and bail out — otherwise it - * decides on an answer the human never gave. - * - * 2. The `rerun_on_resume=False` leaf ("complete with the human's reply as my - * output") is implemented for STATIC GRAPH nodes only — see - * samples/workflows/human_input/get_started, where that handoff is exactly - * what makes the two-node pattern work. A dynamic `ctx.runNode` child is - * always re-run instead, so the leaf here uses the re-entry form: a stable - * `interruptId`, and a `ctx.resumeInputs[id]` lookup that returns the reply - * on the second pass. + * `ctx.runNode()` does NOT throw when a child interrupts. It resolves with a + * result whose `interruptIds` are populated and whose `output` is still + * undefined, so the orchestrator has to check and bail out — otherwise it + * decides on an answer the human never gave. * * Run (offline, no API key): * npm run sample -- samples/workflows/dynamic/human_input/agent.ts @@ -44,24 +39,16 @@ import {node, NodeContext, RequestInput, WorkflowAgent} from '@google/adk'; -/** Stable id so the reply can be matched back to this pause on resume. */ -const APPROVAL_INTERRUPT_ID = 'user_approval'; - -/** Pauses the workflow and waits for user input. */ +/** + * Pauses the workflow and waits for user input. + * + * `rerunOnResume: false` (the default, explicit here to mirror the decorator) + * is what makes this a one-liner: the reply is handed to the node as its + * output instead of the body running a second time to collect it. + */ const getUserApproval = node( - (ctx: NodeContext) => { - const reply = ctx.resumeInputs[APPROVAL_INTERRUPT_ID]; - if (reply === undefined) { - // First pass: raise the interrupt and pause. - return new RequestInput({ - interruptId: APPROVAL_INTERRUPT_ID, - message: 'Please approve this request (Yes/No)', - }); - } - // Second pass: the human's reply becomes this node's output. - return reply; - }, - {name: 'get_user_approval', rerunOnResume: true}, + () => new RequestInput({message: 'Please approve this request (Yes/No)'}), + {name: 'get_user_approval', rerunOnResume: false}, ); /** The orchestrator calling the interactive step. */ From 50502992b4e8d6690a307c3f743f16c58b92a02e Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Tue, 11 Aug 2026 19:06:00 -0700 Subject: [PATCH 4/8] 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. --- samples/workflows/data_handling/schemas/agent.ts | 9 +++------ samples/workflows/data_handling/session_state/agent.ts | 2 +- .../workflows/data_handling/structured_access/agent.ts | 2 +- samples/workflows/dynamic/parallel_route/agent.ts | 2 +- samples/workflows/dynamic/sequence_route/agent.ts | 2 +- samples/workflows/human_input/initial_prompt/agent.ts | 4 ++-- .../workflows/human_input/payload_and_schema/agent.ts | 2 +- samples/workflows/routes/branches/agent.ts | 2 +- samples/workflows/routes/loop_escalation/agent.ts | 4 ++-- samples/workflows/routes/nested_workflow/agent.ts | 2 +- 10 files changed, 14 insertions(+), 17 deletions(-) diff --git a/samples/workflows/data_handling/schemas/agent.ts b/samples/workflows/data_handling/schemas/agent.ts index d3ca9e9960..e7404676ed 100644 --- a/samples/workflows/data_handling/schemas/agent.ts +++ b/samples/workflows/data_handling/schemas/agent.ts @@ -85,13 +85,10 @@ const searchFlightsApi = new FunctionTool({ // expects. In a real app this would itself be an extraction agent. const parseRequest = node( (_ctx: NodeContext, nodeInput: string): FlightSearchInput => { - const codes = - String(nodeInput) - .toUpperCase() - .match(/\b[A-Z]{3}\b/g) ?? []; - const date = String(nodeInput).match(/\d{4}-\d{2}-\d{2}/)?.[0]; + const codes = nodeInput.toUpperCase().match(/\b[A-Z]{3}\b/g) ?? []; + const date = nodeInput.match(/\d{4}-\d{2}-\d{2}/)?.[0]; const passengers = Number( - String(nodeInput).match(/(\d+)\s*(people|pax|passengers?)/i)?.[1], + nodeInput.match(/(\d+)\s*(people|pax|passengers?)/i)?.[1], ); return { origin: codes[0] ?? 'SFO', diff --git a/samples/workflows/data_handling/session_state/agent.ts b/samples/workflows/data_handling/session_state/agent.ts index 0032f9c72e..6f3b801226 100644 --- a/samples/workflows/data_handling/session_state/agent.ts +++ b/samples/workflows/data_handling/session_state/agent.ts @@ -47,7 +47,7 @@ import {node, NodeContext, WorkflowAgent} from '@google/adk'; const initStateNode = node( (ctx: NodeContext, nodeInput: string) => { - ctx.state.set('topic', String(nodeInput).trim()); + ctx.state.set('topic', nodeInput.trim()); // Scoped key: dropped when this invocation ends, never persisted. ctx.state.set('temp:started_at', new Date().toISOString()); // The counter travels as node output, not as a re-read state key. diff --git a/samples/workflows/data_handling/structured_access/agent.ts b/samples/workflows/data_handling/structured_access/agent.ts index 70a2e84134..65f9c6577b 100644 --- a/samples/workflows/data_handling/structured_access/agent.ts +++ b/samples/workflows/data_handling/structured_access/agent.ts @@ -48,7 +48,7 @@ const cityGeneratorAgent = new LlmAgent({ const lookupTimeFunction = node( (_ctx: NodeContext, city: string): CityTime => ({ timeInfo: '10:10 AM', - city: String(city).trim(), + city: city.trim(), }), {name: 'lookup_time_function', outputSchema: cityTimeSchema}, ); diff --git a/samples/workflows/dynamic/parallel_route/agent.ts b/samples/workflows/dynamic/parallel_route/agent.ts index 95bbe46cea..a7633270cd 100644 --- a/samples/workflows/dynamic/parallel_route/agent.ts +++ b/samples/workflows/dynamic/parallel_route/agent.ts @@ -52,7 +52,7 @@ const realNode = node( const parallelSupervisor = node( async (ctx: NodeContext, nodeInput: string) => { - const items = String(nodeInput) + const items = nodeInput .split(',') .map((item) => item.trim()) .filter(Boolean); diff --git a/samples/workflows/dynamic/sequence_route/agent.ts b/samples/workflows/dynamic/sequence_route/agent.ts index b43733aa22..b05051a182 100644 --- a/samples/workflows/dynamic/sequence_route/agent.ts +++ b/samples/workflows/dynamic/sequence_route/agent.ts @@ -45,7 +45,7 @@ const cityGeneratorAgent = node( const cityTimeFunction = node( (_ctx: NodeContext, city: string): CityTime => ({ timeInfo: '10:10 AM', - city: String(city).trim(), + city: city.trim(), }), {name: 'city_time_function', outputSchema: cityTimeSchema}, ); diff --git a/samples/workflows/human_input/initial_prompt/agent.ts b/samples/workflows/human_input/initial_prompt/agent.ts index 369c1f3675..a4c180a38b 100644 --- a/samples/workflows/human_input/initial_prompt/agent.ts +++ b/samples/workflows/human_input/initial_prompt/agent.ts @@ -56,13 +56,13 @@ const initialPrompt = node( // Receives the user's reply as its input and kicks off the real work. const buildItinerary = node( (_ctx: NodeContext, nodeInput: string) => { - const [city = 'your city'] = String(nodeInput).split(','); + const [city = 'your city'] = nodeInput.split(','); return ( `Personalized itinerary for ${city.trim()}:\n` + ' 1. Morning walk through the old town\n' + ' 2. Lunch at a neighbourhood favourite\n' + ' 3. An afternoon activity matched to your hobby\n\n' + - `(based on: ${String(nodeInput).trim()})` + `(based on: ${nodeInput.trim()})` ); }, {name: 'build_itinerary'}, diff --git a/samples/workflows/human_input/payload_and_schema/agent.ts b/samples/workflows/human_input/payload_and_schema/agent.ts index 804ff062ca..a49bb8e4dd 100644 --- a/samples/workflows/human_input/payload_and_schema/agent.ts +++ b/samples/workflows/human_input/payload_and_schema/agent.ts @@ -50,7 +50,7 @@ const userFeedbackSchema = z.object({ // Stands in for the agent node that composes the base itinerary. const buildItinerary = node( (_ctx: NodeContext, city: string): ActivitiesList => { - const place = String(city).trim() || 'your city'; + const place = city.trim() || 'your city'; return { itinerary: [ {name: 'Morning walk', description: `A stroll through old ${place}.`}, diff --git a/samples/workflows/routes/branches/agent.ts b/samples/workflows/routes/branches/agent.ts index 46140381b5..dc5b71788f 100644 --- a/samples/workflows/routes/branches/agent.ts +++ b/samples/workflows/routes/branches/agent.ts @@ -30,7 +30,7 @@ import { } from '@google/adk'; const taskANode = node( - (_ctx: NodeContext, nodeInput: string) => String(nodeInput).trim(), + (_ctx: NodeContext, nodeInput: string) => nodeInput.trim(), {name: 'task_A_node'}, ); diff --git a/samples/workflows/routes/loop_escalation/agent.ts b/samples/workflows/routes/loop_escalation/agent.ts index c02c8f4be4..2a2b2f50c2 100644 --- a/samples/workflows/routes/loop_escalation/agent.ts +++ b/samples/workflows/routes/loop_escalation/agent.ts @@ -42,8 +42,8 @@ const REQUIRED_BULLETS = 3; const seedDraft = node( (_ctx: NodeContext, topic: string): Draft => ({ - topic: String(topic).trim(), - bullets: [`${String(topic).trim()} — point 1`], + topic: topic.trim(), + bullets: [`${topic.trim()} — point 1`], }), {name: 'seed_draft'}, ); diff --git a/samples/workflows/routes/nested_workflow/agent.ts b/samples/workflows/routes/nested_workflow/agent.ts index 907a5fe436..2bfaee82f4 100644 --- a/samples/workflows/routes/nested_workflow/agent.ts +++ b/samples/workflows/routes/nested_workflow/agent.ts @@ -37,7 +37,7 @@ import { } from '@google/adk'; const taskA1 = node( - (_ctx: NodeContext, nodeInput: string) => String(nodeInput).trim(), + (_ctx: NodeContext, nodeInput: string) => nodeInput.trim(), {name: 'task_A1'}, ); From 6389d10b7d64184683b9cdcbc44305107ad60b20 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Tue, 11 Aug 2026 19:06:12 -0700 Subject: [PATCH 5/8] 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_samples/docs_samples_test.ts | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/integration/docs_samples/docs_samples_test.ts diff --git a/tests/integration/docs_samples/docs_samples_test.ts b/tests/integration/docs_samples/docs_samples_test.ts new file mode 100644 index 0000000000..3d4e16422c --- /dev/null +++ b/tests/integration/docs_samples/docs_samples_test.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Executes the docs-page ports in `samples/workflows/`. + * + * Lint, Prettier, the license check and `ts:check:samples` all read these files + * already, so a syntax, style, license or type error fails CI. Nothing ran + * them, which left the interesting failure 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. + * + * Every sample is constructed. The ones that call no model are also run through + * a real `InMemoryRunner`, with the record/replay model installed on an empty + * response set — so an accidental model call in an "offline" sample throws + * rather than silently reaching the network. + * + * The model-backed samples are constructed only. Driving them would mean + * checking in a fixture per sample, and the behaviour they add over the + * sibling `tests/integration/workflows/` set is prompt wording, not graph + * shape. + */ + +import {Event} from '@google/adk'; +import {readdirSync, statSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; +import { + allEvents, + finalOutput, + runSample, +} from '../workflows/_harness/sample_harness.js'; + +const SAMPLES_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../samples/workflows', +); + +/** Turns to drive an offline sample with, and what to expect back. */ +interface OfflineSample { + /** User turns, in order. A second turn resumes a sample that pauses. */ + turns: string[]; + /** Set for a sample that pauses for a human on its first turn. */ + pausesOnFirstTurn?: boolean; +} + +/** + * The samples that run without an API key, and the turns that drive them. + * + * `hello world` is the generic turn; a sample gets something more specific only + * where its nodes actually parse the input. + */ +const OFFLINE: Record = { + 'data_handling/node_output': {turns: ['hello world']}, + 'data_handling/routing_output': {turns: ['this is a bug report']}, + 'data_handling/session_state': {turns: ['hello world']}, + 'data_handling/structured_output': {turns: ['hello world']}, + 'data_handling/user_message': {turns: ['hello world']}, + 'dynamic/custom_run_ids': {turns: ['hello world']}, + 'dynamic/get_started': {turns: ['hello world']}, + 'dynamic/human_input': { + turns: ['please approve', 'yes'], + pausesOnFirstTurn: true, + }, + 'dynamic/nodes': {turns: ['hello world']}, + 'dynamic/parallel_route': {turns: ['alpha, beta, gamma']}, + 'human_input/get_started': {turns: ['start', '21'], pausesOnFirstTurn: true}, + 'human_input/initial_prompt': { + turns: ['start', 'Paris, 30, hiking'], + pausesOnFirstTurn: true, + }, + 'human_input/payload_and_schema': { + turns: ['Paris', 'the museum'], + pausesOnFirstTurn: true, + }, + 'routes/fan_out_join': {turns: ['hello world']}, + 'routes/function_node': {turns: ['hello world']}, + 'routes/loop_escalation': {turns: ['graph workflows']}, + 'routes/nested_workflow': {turns: ['hello world']}, + 'routes/sequence': {turns: ['hello world']}, +}; + +/** The samples that call a live model, so they are constructed but not run. */ +const MODEL_BACKED = [ + 'data_handling/schemas', + 'data_handling/structured_access', + 'dynamic/data_handling', + 'dynamic/loop_route', + 'dynamic/sequence_route', + 'graphs/get_started', + 'graphs/process_pipeline', + 'routes/branches', +]; + +/** Every `/` directory holding an `agent.ts`, from disk. */ +function discoverSamples(): string[] { + const found: string[] = []; + for (const category of readdirSync(SAMPLES_ROOT)) { + const categoryPath = path.join(SAMPLES_ROOT, category); + if (!statSync(categoryPath).isDirectory()) continue; + for (const name of readdirSync(categoryPath)) { + const agent = path.join(categoryPath, name, 'agent.ts'); + if (statSync(agent, {throwIfNoEntry: false})?.isFile()) { + found.push(`${category}/${name}`); + } + } + } + return found.sort(); +} + +async function loadRootAgent(sample: string) { + const module = (await import( + path.join(SAMPLES_ROOT, sample, 'agent.ts') + )) as {rootAgent?: unknown}; + return module.rootAgent; +} + +function isPaused(events: Event[]): boolean { + return events.some((e) => (e.longRunningToolIds?.length ?? 0) > 0); +} + +describe('workflow docs samples', () => { + // Guards the two lists above: a sample added to samples/workflows/ has to be + // classified here, rather than silently gaining no coverage. + it('covers every sample on disk', () => { + const registered = [...Object.keys(OFFLINE), ...MODEL_BACKED].sort(); + expect(registered).toEqual(discoverSamples()); + }); + + describe.each(MODEL_BACKED)('%s (model-backed)', (sample) => { + it('builds a valid graph', async () => { + // A WorkflowAgent validates its edges in its constructor, so importing + // the module is the assertion. + expect(await loadRootAgent(sample)).toBeDefined(); + }); + }); + + describe.each(Object.entries(OFFLINE))('%s (offline)', (sample, spec) => { + it('runs without a model', async () => { + const rootAgent = await loadRootAgent(sample); + expect(rootAgent).toBeDefined(); + + const perTurn = await runSample({ + name: sample, + rootAgent: rootAgent as Parameters[0]['rootAgent'], + turns: spec.turns, + offline: true, + }); + + expect(perTurn[0].length).toBeGreaterThan(0); + if (spec.pausesOnFirstTurn) { + expect(isPaused(perTurn[0])).toBe(true); + expect(isPaused(perTurn[perTurn.length - 1])).toBe(false); + } + // The last turn produces the workflow's answer. + expect( + finalOutput(allEvents([perTurn[perTurn.length - 1]])), + ).toBeDefined(); + }); + }); +}); From 53d57227e5af1b57ec6acc2a1aa4b840f47b3b69 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Tue, 11 Aug 2026 19:08:56 -0700 Subject: [PATCH 6/8] docs(workflow): restore the state-based counter now that #636 fixed the read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- samples/workflows/README.md | 12 ------- .../data_handling/session_state/agent.ts | 31 ++++++++----------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/samples/workflows/README.md b/samples/workflows/README.md index e6ad0fbf26..f46cea2d79 100644 --- a/samples/workflows/README.md +++ b/samples/workflows/README.md @@ -160,18 +160,6 @@ differs, all called out again in the affected sample's header comment. - **`{Class.field}` and `` work verbatim** — the Python data-selection syntax is supported (see `data_handling/structured_access`). -## A gotcha worth knowing - -Found while smoke-testing these ports, and documented in the sample it affects. - -- **Keep every session-state key single-writer.** A node's `ctx.state` writes - land immediately, but they are also replayed when the runtime commits that - node's event — and that commit lags the graph by an event or two. So a node - that re-reads a key an _earlier_ node also wrote can observe the earlier, - already-superseded value. Move evolving values along the edges as `output` - instead of read-modify-writing one key from several nodes - (`data_handling/session_state`). - ## See also The `tests/integration/workflows/*/agent.ts` files are a second, larger set of diff --git a/samples/workflows/data_handling/session_state/agent.ts b/samples/workflows/data_handling/session_state/agent.ts index 6f3b801226..6d33ea76a2 100644 --- a/samples/workflows/data_handling/session_state/agent.ts +++ b/samples/workflows/data_handling/session_state/agent.ts @@ -19,8 +19,10 @@ * * Python binds state values to named function parameters by signature * introspection. TypeScript nodes take an explicit `(ctx, input)` pair instead, - * so you read and write the same session state through `ctx.state` — writes - * accumulate in the node's state delta and are committed with its events. + * so you read and write the same session state through `ctx.state`. A write is + * visible to every later node in the same run, and is committed with the + * writing node's events — so `attempts` can be incremented by one node and read + * back by another, exactly as the snippet does. * * State-key prefixes control lifetime and scope: * "app:" shared across all users and sessions of the app @@ -28,16 +30,11 @@ * "temp:" discarded when the current invocation ends * "" persists for the lifetime of the session * - * !! Gotcha: do not read-modify-write ONE key from several nodes. !! - * A node's writes land in `ctx.state` immediately, but they are also replayed - * from that node's event when the runtime commits it — and that commit lags the - * graph by an event or two. So a later node that re-reads a key an earlier node - * also wrote can observe the earlier (already-superseded) value. Keep each - * state key single-writer, and move evolving values along the edges as node - * `output`, the way `attempts` travels below. - * * Caution: state is a lightweight key-value store. Do not use it to move large - * payloads between nodes — use artifacts or a database tool for those. + * payloads between nodes — use artifacts or a database tool for those. Passing + * a value along an edge as node `output` is also the better choice when only + * the next node needs it; reach for state when a value has to outlive the run, + * or be read by a tool, a callback, or `{key}` instruction templating. * * Run (offline, no API key): * npm run sample -- samples/workflows/data_handling/session_state/agent.ts @@ -50,18 +47,16 @@ const initStateNode = node( ctx.state.set('topic', nodeInput.trim()); // Scoped key: dropped when this invocation ends, never persisted. ctx.state.set('temp:started_at', new Date().toISOString()); - // The counter travels as node output, not as a re-read state key. - return 0; + ctx.state.set('attempts', 0); }, {name: 'init_state_node'}, ); const taskAttemptNode = node( - (ctx: NodeContext, attempts: number) => { - const next = attempts + 1; - // Single writer for this key, so downstream reads are stable. - ctx.state.set('attempts', next); - return next; + (ctx: NodeContext) => { + // Reads the value init_state_node wrote earlier in this same run. + const attempts = ctx.state.get('attempts') ?? 0; + ctx.state.set('attempts', attempts + 1); }, {name: 'task_attempt_node'}, ); From 83dbd6684637813ddfeb5f4e0e4ec2ca7a7d3732 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Tue, 11 Aug 2026 19:02:47 -0700 Subject: [PATCH 7/8] ci(samples): keep samples resolving @google/adk through node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- samples/tsconfig.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/samples/tsconfig.json b/samples/tsconfig.json index 54a7c47b83..976145f135 100644 --- a/samples/tsconfig.json +++ b/samples/tsconfig.json @@ -1,5 +1,14 @@ { "extends": "../tsconfig.json", + "compilerOptions": { + // The root config aliases `@google/adk` to `core/src`, so that a test + // type-checks against the sources vitest runs it against. A sample is a + // consumer, not part of the build, so it has to resolve the package the + // way a user's project does: through `node_modules`, against the + // published types. `core`, `dev` and `integrations` reset this for the + // same reason. + "paths": {} + }, "include": ["**/*.ts"], "exclude": ["node_modules"] } From 09c3146ead0e47902ffb0b8f1146c2729f22e9d5 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Tue, 11 Aug 2026 19:17:09 -0700 Subject: [PATCH 8/8] ci(samples): keep the repo-wide type check out of samples/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tsconfig.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index d236b90528..ace625dd2c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,11 @@ // the preceding build step, so CI and a fresh clone check different files. // The three workspace configs set their own `exclude`, so this does not // reach them. - "exclude": ["node_modules", "**/dist"] + // + // `samples` is excluded because the `paths` above are wrong for it: a sample + // is a consumer of the published package, not part of the build, so it has + // to resolve `@google/adk` through `node_modules` the way a user's project + // does. It is type-checked by `npm run ts:check:samples`, against + // `samples/tsconfig.json`, which resets `paths` for exactly that reason. + "exclude": ["node_modules", "**/dist", "samples"] }