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..976145f135 --- /dev/null +++ b/samples/tsconfig.json @@ -0,0 +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"] +} diff --git a/samples/workflows/README.md b/samples/workflows/README.md new file mode 100644 index 0000000000..f46cea2d79 --- /dev/null +++ b/samples/workflows/README.md @@ -0,0 +1,170 @@ +# 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 `. + +`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 +``` + +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 +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; 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/` + +| 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; 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 + +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`. +- **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})`. +- **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`). + +## 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..c0de56861f --- /dev/null +++ b/samples/workflows/data_handling/node_output/agent.ts @@ -0,0 +1,69 @@ +/** + * @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: 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 + */ + +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 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...'}]}, + }); + // Exactly one event sets `output`, so there is nothing to overwrite it. + 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..e7404676ed --- /dev/null +++ b/samples/workflows/data_handling/schemas/agent.ts @@ -0,0 +1,135 @@ +/** + * @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 = nodeInput.toUpperCase().match(/\b[A-Z]{3}\b/g) ?? []; + const date = nodeInput.match(/\d{4}-\d{2}-\d{2}/)?.[0]; + const passengers = Number( + 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..6d33ea76a2 --- /dev/null +++ b/samples/workflows/data_handling/session_state/agent.ts @@ -0,0 +1,75 @@ +/** + * @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`. 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 + * "user:" tied to the user, shared across their sessions + * "temp:" discarded when the current invocation ends + * "" persists for the lifetime of the session + * + * 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. 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 + */ + +import {node, NodeContext, WorkflowAgent} from '@google/adk'; + +const initStateNode = node( + (ctx: NodeContext, nodeInput: string) => { + ctx.state.set('topic', nodeInput.trim()); + // Scoped key: dropped when this invocation ends, never persisted. + ctx.state.set('temp:started_at', new Date().toISOString()); + ctx.state.set('attempts', 0); + }, + {name: 'init_state_node'}, +); + +const taskAttemptNode = node( + (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'}, +); + +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..65f9c6577b --- /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: 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..8b9497e48f --- /dev/null +++ b/samples/workflows/dynamic/human_input/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/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. + * 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. + * + * !! One TypeScript difference from the Python snippet. !! + * + * `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 + * Turn 1: describe a request. Turn 2: type "yes" or "no". + */ + +import {node, NodeContext, RequestInput, WorkflowAgent} from '@google/adk'; + +/** + * 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( + () => new RequestInput({message: 'Please approve this request (Yes/No)'}), + {name: 'get_user_approval', rerunOnResume: false}, +); + +/** 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..a7633270cd --- /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 = 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..b05051a182 --- /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: 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..a4c180a38b --- /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'] = 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: ${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..a49bb8e4dd --- /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 = 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..dc5b71788f --- /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) => 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..2a2b2f50c2 --- /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: topic.trim(), + bullets: [`${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..2bfaee82f4 --- /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) => 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]], +}); 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(); + }); + }); +}); 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"] }