docs(workflow): workflow samples (Part 8/8) - #595
Conversation
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.
AmaadMartin
left a comment
There was a problem hiding this comment.
Read the full diff and checked every symbol the 22 samples import against the barrel at c6a678e: all of them (node, NodeContext, Workflow, WorkflowAgent, JoinNode, RequestInput, DEFAULT_ROUTE, createEvent, FunctionTool, LlmAgent, AuthConfig/AuthScheme/AuthCredential/AuthCredentialTypes) are exported from @google/adk, and there is not a single deep import into core/src/... — the public-surface claim in the PR body holds. I also spot-checked the option shapes against source and they match: BuildNodeOptions (name/description/inputSchema/authConfig/rerunOnResume/parallelWorker/retryConfig), RunNodeOptions (useSubBranch/runId/useAsOutput), RequestInputParams, RetryConfig.maxAttempts/initialDelay, LlmAgentSchema (zod object or genai Schema), ToolUnion = BaseTool | BaseToolset | BaseNode (so node_as_tool passing a Workflow and a node in tools is valid), and the temp:<credentialKey> state key the two auth samples read — that is exactly what AuthHandler writes (core/src/auth/auth_handler.ts:23). No hardcoded secrets. Comments below are mostly doc/consistency; the unbounded-loop one is the only substantive one.
| | `parallel_worker` | Map a node across a list with bounded concurrency | ✅ | | ||
| | `dynamic_nodes` | Imperative `dynamicEntry` driving `ctx.runNode()` | ✅ | |
There was a problem hiding this comment.
Not a nit. Two rows in this table advertise API surface that no sample in the set actually exercises.
| `parallel_worker` | Map a node across a list with bounded concurrency | ✅ |
| `dynamic_nodes` | Imperative `dynamicEntry` driving `ctx.runNode()` | ✅ |dynamicEntry appears exactly once in the whole diff — in this line. dynamic_nodes/agent.ts uses edges: [['START', orchestrate]] with an ordinary function node, not the WorkflowConfig.dynamicEntry field (core/src/workflow/workflow.ts:48, which is mutually exclusive with edges). Likewise maxParallelWorkers appears zero times in the diff, so parallel_worker/agent.ts leaves it undefined — unbounded — and does not demonstrate the bounding this row claims.
Either wire the samples to those fields, or reword the rows (e.g. "imperative orchestrator node driving ctx.runNode()" and "map a node across a list"). Since the PR positions this directory as covering the full workflow surface, dynamicEntry having no sample at all is worth calling out in the Feature coverage section too.
| async function* (ctx: NodeContext, nodeInput: string) { | ||
| ctx.state.set('topic', nodeInput); | ||
|
|
||
| for (;;) { |
There was a problem hiding this comment.
Not a nit. This is an unbounded loop around two live model calls, with no cap and no warning to the reader.
for (;;) {
const headline = (await ctx.runNode(generateHeadline)).output as string;
const feedback = (await ctx.runNode(evaluateHeadline, headline))
.output as {grade: string};
if (feedback.grade === 'tech-related') {The only exit is the grader returning 'tech-related'. If the model keeps grading 'unrelated' (easy with an off-topic input — the sample header suggests no topic constraint), this spins forever at two gemini-2.5-flash calls per iteration and silently burns the user's quota. A sample is the worst place for that, because it is the first thing a new user runs.
const MAX_ATTEMPTS = 5;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
...
if (feedback.grade === 'tech-related') {
yield headline;
return;
}
}
yield `Gave up after ${MAX_ATTEMPTS} attempts.`;Same shape exists in loop/agent.ts — the [routeHeadline, {unrelated: generateHeadline}] edge is a graph-level cycle with no iteration limit either. That one is arguably the point of the loop sample, so at minimum the README "needs API key" column should say these two can loop indefinitely.
| * `contributing/samples/workflows/sequence`. | ||
| * | ||
| * REQUIRES an API key (both nodes call a live model). Set GEMINI_API_KEY, then: | ||
| * node dev/dist/esm/cli_entrypoint.js run samples/workflows/sequence/agent.ts |
There was a problem hiding this comment.
Nit. The samples are split down the middle on how to run them, and this half contradicts the README.
* node dev/dist/esm/cli_entrypoint.js run samples/workflows/sequence/agent.ts * npm run sample -- samples/workflows/sequence/agent.ts13 sample headers use npm run sample -- ... (which is also what the README Running section documents, and what this PR adds the root script for); 9 use the raw node dev/dist/esm/cli_entrypoint.js run ... form: agent_in_workflow, auth_api_key, auth_oauth, node_as_tool, request_input, request_input_advanced, request_input_rerun, route, sequence. Worth normalizing all nine to npm run sample -- so the docs and the samples teach one invocation.
| if (nodeInput === 'reject') { | ||
| return createEvent({route: 'rejected'}); | ||
| } | ||
| if (nodeInput === 'approve') { | ||
| return createEvent({route: 'approved'}); | ||
| } | ||
| ctx.state.set('feedback', nodeInput); |
There was a problem hiding this comment.
Nit. The four HITL samples parse the human's reply four different ways, and this is the strictest one.
if (nodeInput === 'reject') {
return createEvent({route: 'rejected'});
}
if (nodeInput === 'approve') {
return createEvent({route: 'approved'});
}
ctx.state.set('feedback', nodeInput);Exact match here and in request_input_rerun/agent.ts; request_input_advanced/agent.ts normalizes with ['yes','y','true','approve','approved'].includes(input.trim().toLowerCase()); node_as_tool/agent.ts uses ['yes','y','true'].includes(String(answer).toLowerCase()). In the interactive flow the README describes ("type your reply on the next turn"), typing Approve or approve here falls through to the revise branch, stores the word as manager feedback, and re-runs draft_email — a silent extra model call that looks like a framework bug to a first-time reader.
const reply = nodeInput.trim().toLowerCase();
if (reply === 'reject') ...
if (reply === 'approve') ...Picking one normalization and using it in all four keeps the samples from teaching three different idioms for the same thing.
| name: 'explain_topic', | ||
| model: 'gemini-2.5-flash', | ||
| instruction: | ||
| 'Explain how the following topic relates the the original topic: "{topic}".', |
There was a problem hiding this comment.
Nit. Typo in the prompt — "relates the the".
'Explain how the following topic relates the the original topic: "{topic}".', 'Explain how the following topic relates to the original topic: "{topic}".',
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Problem: The workflow API needs runnable, discoverable examples.
Solution — Part 8 of 8 (final). Stacked on Part 7. Adds
samples/workflows/covering the API surface:samples/workflows/README.mdand a rootsamplescript to run them.Testing Plan
@google/adksurface and typecheck cleanly against source (verified with a temporary tsconfig aliasing@google/adk→core/src).Manual E2E: Each sample is runnable via
npm run sample -- samples/workflows/<name>.Checklist
Additional context
Final part of the stacked split (…Part 7 → Part 8). Diff: 24 files, +1,973.