Skip to content

Commit 495c85c

Browse files
authored
docs(workflow): workflow samples (Part 8) (#595)
* docs(workflow): add workflow samples Part 8/9 (final) of the feature/workflows split. Runnable examples covering the workflow API surface: - basics: sequence, loop, loop_self, route, multi_triggers, state, node_output, use_as_output, message - parallelism & dynamic: fan_out_fan_in, parallel_worker, dynamic_fan_out_fan_in, dynamic_nodes, nested_workflow - HITL & auth: request_input, request_input_advanced, request_input_rerun, auth_api_key, auth_oauth - agents & tools: agent_in_workflow, node_as_tool, retry - samples/workflows/README.md and a root `sample` script to run them Samples import only the public `@google/adk` surface and typecheck cleanly against source. * docs(workflow): address PR #595 sample review feedback - dynamic_nodes now uses a real `WorkflowConfig.dynamicEntry` (driving children via `ctx.runNode()`) instead of a static `edges` graph, so it actually demonstrates what the README row and Feature-coverage section claim — and the imperative loop is bounded by `MAX_ATTEMPTS` instead of `for (;;)`, so an off-topic input can't spin forever on live model calls. - parallel_worker sets `maxParallelWorkers: 2`, demonstrating the bounded concurrency the README advertises. - Normalize all nine sample headers that still used the raw `node dev/dist/esm/cli_entrypoint.js run ...` form to `npm run sample -- ...`, matching the README and the other samples. - Unify how the four HITL samples parse a human reply: normalize with `.trim().toLowerCase()` (so "Approve"/"approve " no longer fall through) and share one affirmative vocabulary, instead of three different idioms. - README: note that `loop`'s graph cycle is intentionally uncapped and can iterate many times; add a dynamicEntry bullet to Feature coverage. - Fix a prompt typo ("relates the the" -> "relates to the") in parallel_worker. * test(workflow): record/replay sample integration tests + per-test subfolders Add integration tests that run the real workflow samples end-to-end with only the model mocked, and reorganize tests/integration/workflows so every test lives in its own subfolder. - Harness (tests/integration/workflows/_harness/): a RecordReplayModel registered into LLMRegistry mocks the model boundary for every agent — including ones captured inside a dynamicEntry/ctx.runNode closure — matching recorded responses to requests by a stable, id-normalized fingerprint (concurrency/order independent). sample_harness runs the real sample rootAgent through an InMemoryRunner; record mode (RECORD_MODEL_RESPONSES=1) calls the live model and writes the fixture, replay is offline. rng provides a seeded PRNG for the model-free non-deterministic samples (retry, loop_self). - 21 of 22 samples covered, one folder each: agent.ts (vendored from the sample) + <sample>_test.ts + model_responses.json where model-backed; offline samples need no fixture. auth_oauth is skipped (needs a live OAuth provider). - Add `npm run record:samples` to re-record the model-backed fixtures. - Move the existing Part 6 workflow integration tests into per-test subfolders (workflow_test_utils.ts -> _harness/; node_as_tool_test.ts -> node_as_tool_llm/ to avoid colliding with the sample's node_as_tool/ folder), fixing relative imports only. No Part 6 test logic changed. * chore: drop the sampels * feat(workflow): let WorkflowAgent take Workflow options directly Adds an overload so the common case drops a layer of nesting: new WorkflowAgent({name: 'root_agent', edges: [...]}) instead of new WorkflowAgent(new Workflow({name: 'root_agent', edges: [...]})) When given a WorkflowConfig, the agent constructs the Workflow internally and takes its name/description from that config. The existing `new WorkflowAgent(workflow, {name, description})` form is unchanged and still supported, so this is purely additive. The two forms are told apart with the `isBaseNode` brand rather than `instanceof` (per the workflow conventions): a branded node is an already-built Workflow, anything else is config to build one from. * refactor(workflow): use the flattened WorkflowAgent signature in tests Adopt the new `new WorkflowAgent({name, edges})` form across the vendored agents under tests/integration/workflows, dropping the `new Workflow(...)` wrapper and one level of nesting: export const rootAgent = new WorkflowAgent({ name: 'root_agent', edges: [['START', processInput, classifyInput]], }); - Migrates the 20 vendored agents that build a Workflow (node_as_tool is a plain LlmAgent and is unchanged), dropping the now-unused `Workflow` import where nothing else needs it. nested_workflow keeps it for its sub-workflow node. - The samples this code was originally vendored from were removed in the preceding "chore: drop the sampels" commit, so only the self-contained test copies are updated here. The change is purely syntactic: the workflows built are identical, so the recorded model_responses.json fixtures still match and all 56 workflow integration tests pass unchanged.
1 parent d2ae57b commit 495c85c

84 files changed

Lines changed: 5059 additions & 31 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/src/workflow/workflow_agent.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import {InvocationContext} from '../agents/invocation_context.js';
1010
import {createEvent, Event} from '../events/event.js';
1111
import {AsyncQueue} from '../utils/async_queue.js';
1212
import {experimental} from '../utils/experimental.js';
13-
import {toContent} from './base_node.js';
13+
import {isBaseNode, toContent} from './base_node.js';
1414
import {NodeContext} from './node_context.js';
1515
import {reconstructNodeStates} from './utils/rehydration_utils.js';
16-
import {Workflow} from './workflow.js';
16+
import {Workflow, WorkflowConfig} from './workflow.js';
1717

1818
/** Options for a {@link WorkflowAgent}. */
1919
export interface WorkflowAgentConfig {
@@ -34,7 +34,29 @@ export interface WorkflowAgentConfig {
3434
export class WorkflowAgent extends BaseAgent {
3535
readonly workflow: Workflow;
3636

37-
constructor(workflow: Workflow, config: WorkflowAgentConfig = {}) {
37+
/**
38+
* Wraps an existing {@link Workflow}. The agent's name/description default to
39+
* the workflow's; pass `config` to override them.
40+
*/
41+
constructor(workflow: Workflow, config?: WorkflowAgentConfig);
42+
/**
43+
* Convenience form: pass the {@link Workflow} constructor options directly and
44+
* the workflow is created internally, so you can write
45+
* `new WorkflowAgent({name, edges})` instead of
46+
* `new WorkflowAgent(new Workflow({name, edges}))`. The agent's name and
47+
* description come from the config.
48+
*/
49+
constructor(config: WorkflowConfig);
50+
constructor(
51+
workflowOrConfig: Workflow | WorkflowConfig,
52+
config: WorkflowAgentConfig = {},
53+
) {
54+
// A branded BaseNode is an already-built Workflow; anything else is config
55+
// to build one from (avoids `instanceof`, per the workflow conventions).
56+
// The overload signatures guarantee an instance here is a Workflow.
57+
const workflow = isBaseNode(workflowOrConfig)
58+
? (workflowOrConfig as Workflow)
59+
: new Workflow(workflowOrConfig);
3860
super({
3961
name: config.name ?? workflow.name,
4062
description: config.description ?? workflow.description,

core/test/workflow/workflow_agent_test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {InvocationContext} from '../../src/agents/invocation_context.js';
99
import {Event} from '../../src/events/event.js';
1010
import {PluginManager} from '../../src/plugins/plugin_manager.js';
1111
import {createSession} from '../../src/sessions/session.js';
12+
import {isBaseNode} from '../../src/workflow/base_node.js';
13+
import {node} from '../../src/workflow/node.js';
1214
import {NodeContext} from '../../src/workflow/node_context.js';
1315
import {RequestInput} from '../../src/workflow/request_input.js';
1416
import {createRequestInputEvent} from '../../src/workflow/utils/hitl_utils.js';
@@ -79,3 +81,34 @@ describe('WorkflowAgent — plain-text resume', () => {
7981
expect(await resumeInputsFor(['first', 'second'], 'yes')).toEqual({});
8082
});
8183
});
84+
85+
describe('WorkflowAgent — constructor forms', () => {
86+
const step = node(() => 'done', {name: 'step'});
87+
88+
it('builds the Workflow from config (the convenience signature)', () => {
89+
const agent = new WorkflowAgent({
90+
name: 'from_config',
91+
edges: [['START', step]],
92+
});
93+
expect(agent.name).toBe('from_config');
94+
expect(isBaseNode(agent.workflow)).toBe(true);
95+
expect(agent.workflow.name).toBe('from_config');
96+
});
97+
98+
it('accepts a dynamicEntry config too', () => {
99+
const agent = new WorkflowAgent({
100+
name: 'dyn',
101+
dynamicEntry: async () => 'ok',
102+
});
103+
expect(agent.workflow.name).toBe('dyn');
104+
});
105+
106+
it('still accepts a pre-built Workflow, with optional overrides', () => {
107+
const workflow = new Workflow({name: 'wf', edges: [['START', step]]});
108+
expect(new WorkflowAgent(workflow).name).toBe('wf');
109+
expect(new WorkflowAgent(workflow).workflow).toBe(workflow);
110+
expect(new WorkflowAgent(workflow, {name: 'override'}).name).toBe(
111+
'override',
112+
);
113+
});
114+
});

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"scripts": {
1313
"build": "npm run build --workspaces",
14+
"sample": "node dev/dist/esm/cli_entrypoint.js run",
1415
"clean": "npm run clean --workspaces",
1516
"clean:all": "rm package-lock.json && rm -rf ./node_modules && npm run clean:all --workspaces",
1617
"rebuild": "npm run clean:all && npm install && npm run build",
@@ -27,6 +28,7 @@
2728
"test": "vitest --project unit:core --project unit:dev --project integration --project e2e",
2829
"test:unit": "vitest --project unit:core --project unit:dev",
2930
"test:integration": "vitest --project integration",
31+
"record:samples": "RECORD_MODEL_RESPONSES=1 vitest run --project integration tests/integration/workflows/*/*_test.ts",
3032
"test:e2e": "vitest --project e2e",
3133
"test:cross-language": "vitest --project cross-language",
3234
"test:coverage": "vitest run --project unit:core --project unit:dev --project integration --project e2e --coverage",
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* Record/replay model boundary for the workflow-sample integration tests.
9+
*
10+
* The samples construct `LlmAgent`s with a string model (`gemini-2.5-flash`),
11+
* which every agent resolves lazily via `LLMRegistry.newLlm(...)`. We register a
12+
* single {@link RecordReplayModel} for the Gemini model regexes so that EVERY
13+
* agent in a sample — including ones captured inside a `dynamicEntry`/`ctx.runNode`
14+
* closure that static traversal can't reach — resolves to it.
15+
*
16+
* - Replay (default): each model call is matched to a recorded response by a
17+
* stable fingerprint of its request. Concurrency- and order-independent, so
18+
* parallel samples need no special casing. A miss throws with a re-record hint.
19+
* - Record (`RECORD_MODEL_RESPONSES=1`): the call is delegated to a real Gemini
20+
* and the raw response is captured keyed by the same fingerprint.
21+
*/
22+
23+
import type {BaseLlmConnection, LlmRequest, LlmResponse} from '@google/adk';
24+
import {BaseLlm, Gemini, LLMRegistry} from '@google/adk';
25+
import type {Candidate} from '@google/genai';
26+
import {createHash} from 'node:crypto';
27+
import type {RawGenerateContentResponse} from '../../test_case_utils.js';
28+
29+
/** A single recorded model call: its request fingerprint and raw response. */
30+
export interface RecordedCall {
31+
/** Stable fingerprint of the request (see {@link fingerprint}). */
32+
key: string;
33+
/** A readable snippet of the request, for debugging the fixture. */
34+
request: {contents: unknown; systemInstruction?: unknown};
35+
/** The raw response to replay. */
36+
response: RawGenerateContentResponse;
37+
}
38+
39+
type Mode = 'record' | 'replay';
40+
41+
interface HarnessState {
42+
mode: Mode;
43+
recorded: RecordedCall[];
44+
replay: Map<
45+
string,
46+
{responses: RawGenerateContentResponse[]; cursor: number}
47+
>;
48+
/** Backend used in record mode (default: a real Gemini). Overridable in tests. */
49+
liveBackend: (model: string) => BaseLlm;
50+
}
51+
52+
let state: HarnessState | undefined;
53+
54+
/** Recursively sorts object keys so JSON serialization is stable. */
55+
function sortKeys(value: unknown): unknown {
56+
if (Array.isArray(value)) {
57+
return value.map(sortKeys);
58+
}
59+
if (value && typeof value === 'object') {
60+
const out: Record<string, unknown> = {};
61+
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
62+
out[k] = sortKeys((value as Record<string, unknown>)[k]);
63+
}
64+
return out;
65+
}
66+
return value;
67+
}
68+
69+
/**
70+
* Strips volatile fields (randomly-generated `id`s on function calls/responses)
71+
* so a request fingerprints identically across the record run and later replays.
72+
*/
73+
function normalizeContents(contents: unknown): unknown {
74+
const clone = structuredClone(contents) as unknown;
75+
const scrub = (node: unknown): void => {
76+
if (Array.isArray(node)) {
77+
node.forEach(scrub);
78+
return;
79+
}
80+
if (node && typeof node === 'object') {
81+
const obj = node as Record<string, unknown>;
82+
if ('functionCall' in obj && obj['functionCall']) {
83+
delete (obj['functionCall'] as Record<string, unknown>)['id'];
84+
}
85+
if ('functionResponse' in obj && obj['functionResponse']) {
86+
delete (obj['functionResponse'] as Record<string, unknown>)['id'];
87+
}
88+
for (const v of Object.values(obj)) scrub(v);
89+
}
90+
};
91+
scrub(clone);
92+
return clone;
93+
}
94+
95+
/** Stable fingerprint of a model request (contents + config, id-normalized). */
96+
export function fingerprint(req: LlmRequest): string {
97+
const material = JSON.stringify(
98+
sortKeys({
99+
contents: normalizeContents(req.contents),
100+
config: req.config ?? {},
101+
}),
102+
);
103+
return createHash('sha256').update(material).digest('hex').slice(0, 16);
104+
}
105+
106+
/** Reconstructs the raw response shape the fixture stores from an LlmResponse. */
107+
function toRaw(resp: LlmResponse): RawGenerateContentResponse {
108+
const candidate: Candidate = {
109+
content: resp.content,
110+
finishReason: resp.finishReason,
111+
groundingMetadata: resp.groundingMetadata,
112+
citationMetadata: resp.citationMetadata,
113+
};
114+
return {
115+
candidates: resp.content ? [candidate] : [],
116+
usageMetadata: resp.usageMetadata,
117+
};
118+
}
119+
120+
/** Inflates a recorded raw response back into an LlmResponse for replay. */
121+
function toLlmResponse(raw: RawGenerateContentResponse): LlmResponse {
122+
const candidate = raw.candidates?.[0];
123+
return {
124+
content: candidate?.content,
125+
finishReason: candidate?.finishReason,
126+
groundingMetadata: candidate?.groundingMetadata,
127+
citationMetadata: candidate?.citationMetadata,
128+
usageMetadata: raw.usageMetadata,
129+
};
130+
}
131+
132+
/**
133+
* The model registered for the Gemini regexes during a sample test. It reuses
134+
* `Gemini.supportedModels` (the same RegExp instances) so registering it
135+
* overwrites Gemini's registry entries rather than adding lower-priority ones.
136+
*/
137+
class RecordReplayModel extends BaseLlm {
138+
static override readonly supportedModels = Gemini.supportedModels;
139+
140+
override async *generateContentAsync(
141+
llmRequest: LlmRequest,
142+
stream = false,
143+
abortSignal?: AbortSignal,
144+
): AsyncGenerator<LlmResponse, void> {
145+
if (!state) {
146+
throw new Error(
147+
'RecordReplayModel used without installRecordReplay(); did the harness set it up?',
148+
);
149+
}
150+
const key = fingerprint(llmRequest);
151+
152+
if (state.mode === 'replay') {
153+
const entry = state.replay.get(key);
154+
if (!entry) {
155+
throw new Error(
156+
`No recorded model response for request fingerprint ${key}. ` +
157+
'Re-record with: npm run record:samples',
158+
);
159+
}
160+
// Deterministic: identical requests reuse the last recorded response.
161+
const raw =
162+
entry.responses[Math.min(entry.cursor, entry.responses.length - 1)];
163+
entry.cursor++;
164+
yield toLlmResponse(raw);
165+
return;
166+
}
167+
168+
// Record: delegate to a real backend and capture the raw response.
169+
const backend = state.liveBackend(this.model);
170+
for await (const resp of backend.generateContentAsync(
171+
llmRequest,
172+
stream,
173+
abortSignal,
174+
)) {
175+
state.recorded.push({
176+
key,
177+
request: {
178+
contents: normalizeContents(llmRequest.contents),
179+
systemInstruction: llmRequest.config?.systemInstruction,
180+
},
181+
response: toRaw(resp),
182+
});
183+
yield resp;
184+
}
185+
}
186+
187+
override connect(_llmRequest: LlmRequest): Promise<BaseLlmConnection> {
188+
throw new Error('RecordReplayModel does not support live connections.');
189+
}
190+
}
191+
192+
/**
193+
* Installs the record/replay model into the LLM registry and sets the mode.
194+
* Call once per test run (before the runner executes).
195+
*/
196+
export function installRecordReplay(opts: {
197+
mode: Mode;
198+
recordedCalls?: RecordedCall[];
199+
liveBackend?: (model: string) => BaseLlm;
200+
}): void {
201+
const replay = new Map<
202+
string,
203+
{responses: RawGenerateContentResponse[]; cursor: number}
204+
>();
205+
for (const call of opts.recordedCalls ?? []) {
206+
const entry = replay.get(call.key) ?? {responses: [], cursor: 0};
207+
entry.responses.push(call.response);
208+
replay.set(call.key, entry);
209+
}
210+
state = {
211+
mode: opts.mode,
212+
recorded: [],
213+
replay,
214+
liveBackend: opts.liveBackend ?? ((model: string) => new Gemini({model})),
215+
};
216+
LLMRegistry.register(RecordReplayModel);
217+
}
218+
219+
/** Restores the real Gemini registration and clears harness state. */
220+
export function restoreRecordReplay(): void {
221+
LLMRegistry.register(Gemini);
222+
state = undefined;
223+
}
224+
225+
/** Returns the calls captured during a record run. */
226+
export function drainRecordedCalls(): RecordedCall[] {
227+
return state?.recorded ?? [];
228+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* Deterministic PRNG (mulberry32) for tests that stub `Math.random`. Samples
9+
* like `retry` and `loop_self` are model-free but use `Math.random`; seeding it
10+
* makes them reproducible. A seeded generator (rather than a constant) keeps the
11+
* engine's own random event-id generation varied, so ids don't collide.
12+
*/
13+
export function mulberry32(seed: number): () => number {
14+
let a = seed >>> 0;
15+
return () => {
16+
a |= 0;
17+
a = (a + 0x6d2b79f5) | 0;
18+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
19+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
20+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
21+
};
22+
}

0 commit comments

Comments
 (0)