Skip to content

Commit e5c7d20

Browse files
boyangsvlcopybara-github
authored andcommitted
docs(workflow): Add and update workflow developer guides
Add comprehensive guides explaining: - Workflow Graphs (nodes, edges, validation) - Function Nodes (wrapping functions and generators) - Join Nodes (aggregating parallel branches) - Retry Config (resilience policies) - Parallel Workers (concurrent list processing) - Dynamic Nodes (runtime execution) Update the main guides index and the adk-unit-guide skill rules. Co-authored-by: Bo Yang <ybo@google.com> PiperOrigin-RevId: 933979245
1 parent 23ff66e commit e5c7d20

9 files changed

Lines changed: 915 additions & 0 deletions

File tree

.agents/skills/adk-unit-guide/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Use the following structure and instructions to create the guide for the code un
5353
- Use unit test code as a starting point for the code example, if available.
5454
- When writing a sample agent, do not set the `model` attribute.
5555
- For workflow node samples, prefer using a simple Python function rather than extending `BaseNode` to demonstrate the node's logic, unless class extension is explicitly required for the use case.
56+
- When wrapping Python functions as workflow nodes, prefer using the `@node` decorator instead of `FunctionNode` directly, whenever possible.
5657
5758
## How it works
5859
@@ -64,6 +65,8 @@ Use the following structure and instructions to create the guide for the code un
6465
## Configuration options
6566
6667
- If the code unit has configuration options (e.g., settings, configuration objects), document them in a table detailing parameters, types, default values, and descriptions.
68+
- **Do NOT** list options inherited from base classes. Focus only on options introduced by the code unit itself.
69+
- Dive into each option to provide detailed description and usage patterns, rather than just repeating the type and a brief description.
6770
- **Do NOT** list references of all attributes or methods of the classes. Exhaustive API references belong in auto-generated reference documentation, not in guides. Guides should focus on how to use the code unit.
6871
6972
## Advanced applications

docs/guides/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,12 @@ This directory contains specific developer guides for the ADK Python implementat
1111
### Events
1212
* [Event and NodeInfo](events/event/index.md) - Understanding Event and NodeInfo in workflows.
1313
* [RequestInput](events/request_input/index.md) - How to use RequestInput for human-in-the-loop interactions.
14+
15+
### Workflows
16+
* [Workflow](workflow/workflow/index.md) - Graph-based orchestration of complex, multi-step agent interactions.
17+
* [Workflow Graphs](workflow/graph/index.md) - Understanding nodes, edges, and graph structures in workflows.
18+
* [Function Nodes](workflow/function_node/index.md) - Wrapping Python functions and generators as workflow nodes.
19+
* [JoinNode](workflow/join_node/index.md) - Synchronizing parallel execution paths in workflows.
20+
* [RetryConfig](workflow/retry_config/index.md) - Configuring retry policies for resilient workflow nodes.
21+
* [ParallelWorker](workflow/parallel_worker/index.md) - Processing lists of items concurrently in workflows.
22+
* [Dynamic Nodes](workflow/dynamic_nodes/index.md) - Scheduling and executing nodes dynamically at runtime.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Dynamic Node Scheduling
2+
3+
Dynamic node scheduling allows you to execute workflow nodes dynamically at runtime using `ctx.run_node()`. This enables imperative workflow construction using standard Python control flow instead of static graph edges.
4+
5+
## Introduction
6+
7+
While static graph definitions (`Workflow(edges=[...])`) are suitable for many structured tasks, some scenarios require more flexibility. For example, you might need to:
8+
- Loop a set of nodes until a condition is met (e.g., generator-evaluator loops).
9+
- Run a variable number of tasks in parallel based on runtime input (dynamic fan-out).
10+
- Conditionally execute nodes based on complex logic that is difficult to express in static edges.
11+
12+
`ctx.run_node()` allows a parent node to execute a child node (which can be a function, an Agent, or another Workflow) and await its result.
13+
14+
## Get started
15+
16+
The following example demonstrates how to dynamically execute a child agent from a parent node.
17+
18+
```python
19+
from google.adk import Agent, Context, Event, Workflow
20+
from google.adk.workflow import node
21+
22+
# Define a child agent
23+
generate_headline = Agent(
24+
name="generate_headline",
25+
instruction="Write a catchy headline about the topic in the user message.",
26+
)
27+
28+
29+
# Define the parent orchestrator node (MUST have rerun_on_resume=True)
30+
@node(rerun_on_resume=True)
31+
async def orchestrate(ctx: Context, node_input: str) -> str:
32+
# Dynamically execute the child agent and await its output
33+
headline = await ctx.run_node(generate_headline, node_input=node_input)
34+
35+
yield Event(output=headline)
36+
37+
# Build the workflow
38+
root_agent = Workflow(
39+
name="root_agent",
40+
edges=[("START", orchestrate)],
41+
)
42+
```
43+
44+
45+
## How it works
46+
47+
When `await ctx.run_node(node_like, ...)` is called:
48+
49+
1. **Orchestrator Registration**: The workflow's `DynamicNodeScheduler` registers the child node execution.
50+
2. **State Tracking**: The execution state and events of the child node are tracked under the parent node's path (e.g., `parent_node@1/child_node@1`).
51+
3. **Resumption Support**: If the child node interrupts (e.g., waiting for user input), the parent node is also paused. When the workflow resumes, the parent node is re-run from the beginning (`rerun_on_resume=True`), but previous successful `ctx.run_node()` calls are replayed from history (cached outputs are returned) to avoid re-executing completed steps.
52+
53+
### Input Mapping
54+
55+
The `node_input` passed to `ctx.run_node(node, node_input=value)` is delivered differently depending on the type of the child node:
56+
57+
- **Python Functions / FunctionNodes**: The `value` is passed directly to the function parameter named `node_input`. Other parameters are bound from the session state (default mode).
58+
- **Agents (Single-Turn Mode)**: The `value` is converted to a user-role message (`types.Content`) and appended to the session events history. The agent receives it as the incoming user message.
59+
- **Agents (Task Mode)**: The `value` is set as `user_content` in the `InvocationContext`, serving as the fallback first user turn for the task agent if it wasn't triggered by a tool call.
60+
61+
## Requirements & Rules
62+
63+
### 1. `rerun_on_resume=True` is Mandatory for Parents
64+
65+
Any node that calls `ctx.run_node()` **must** be configured with `rerun_on_resume=True`.
66+
If the parent node does not have this setting, calling `ctx.run_node()` will raise a `ValueError` at runtime.
67+
68+
### 2. Function Parameter Mapping (`node_input` vs. Dict Binding)
69+
70+
By default, functions wrapped as nodes look up their arguments in the session state (state binding). However, the `node_input` argument passed to `ctx.run_node(..., node_input=value)` is passed directly to the node.
71+
72+
How you receive this input depends on how you define your function:
73+
74+
#### Pass-through `node_input` (Default)
75+
To receive the raw `value` directly, the function's parameter must be named exactly `node_input`.
76+
77+
```python
78+
# Correct: receives the raw value passed to node_input
79+
def my_worker(node_input: str):
80+
return f"Done: {node_input}"
81+
82+
# Incorrect: will fail because it tries to look up 'data' in session state
83+
def my_worker(data: str):
84+
return f"Done: {data}"
85+
```
86+
87+
#### Binding Dictionary Keys to Parameters (`parameter_binding='node_input'`)
88+
If you pass a dictionary to `node_input` (e.g., `node_input={'foo': 'bar'}`) and want to bind its keys to individual function parameters (e.g., `def my_worker(foo: str)`), you must configure the node with `parameter_binding='node_input'`.
89+
90+
You can configure this using the `@node` decorator with `parameter_binding='node_input'`:
91+
92+
```python
93+
from google.adk.workflow import node
94+
95+
# Decorate with parameter_binding='node_input'
96+
@node(parameter_binding='node_input')
97+
def my_worker(foo: str):
98+
return f"Done: {foo}"
99+
100+
# Call via ctx.run_node
101+
result = await ctx.run_node(my_worker, node_input={'foo': 'bar'}) # foo gets 'bar'
102+
```
103+
104+
105+
### 3. Nested Dynamic Nodes
106+
107+
If a dynamically scheduled node *itself* calls `ctx.run_node()`, it becomes a parent and must also have `rerun_on_resume=True`.
108+
You should decorate the nested function with `@node(rerun_on_resume=True)` to ensure it has this property when executed:
109+
110+
```python
111+
from google.adk.workflow import node
112+
113+
@node(rerun_on_resume=True)
114+
async def inner_parent(ctx: Context):
115+
# Calls another dynamic node internally
116+
result = await ctx.run_node(some_child)
117+
yield Event(output=result)
118+
119+
# In the outer parent:
120+
await ctx.run_node(inner_parent)
121+
```
122+
123+
124+
### 4. Generator Returns
125+
126+
In nodes that use `yield` (generators), you cannot use `return value` to produce the final output of the node due to Python syntax constraints. You must yield `Event(output=value)` instead.
127+
128+
## Method Signature
129+
130+
```python
131+
async def run_node(
132+
self,
133+
node: NodeLike,
134+
node_input: Any = None,
135+
*,
136+
use_as_output: bool = False,
137+
run_id: str | None = None,
138+
use_sub_branch: bool = False,
139+
override_branch: str | None = None,
140+
) -> Any:
141+
```
142+
143+
### Parameters
144+
145+
| Parameter | Type | Default | Description |
146+
| :--- | :--- | :--- | :--- |
147+
| `node` | `NodeLike` | *Required* | The node to execute (Function, Agent, or Workflow). |
148+
| `node_input` | `Any` | `None` | Input data to pass to the dynamic node. |
149+
| `use_as_output` | `bool` | `False` | If `True`, the child node's output is used as the calling parent node's output. The parent's own output event is suppressed. Can only be set once per parent execution. |
150+
| `run_id` | `str \| None` | `None` | Optional custom run ID. If provided, **must contain non-numeric characters** (e.g., `"run_a"`) to prevent collision with auto-generated IDs. |
151+
| `use_sub_branch` | `bool` | `False` | If `True`, executes the node in a sub-branch (appending `node_name@run_id` to the branch path). Essential for parallel runs to isolate events. |
152+
| `override_branch` | `str \| None` | `None` | Explicitly overrides the branch name for the execution context. |
153+
154+
## Advanced Applications
155+
156+
### Dynamic Fan-Out (Parallel Execution)
157+
158+
You can perform dynamic fan-out by scheduling multiple tasks in parallel using `asyncio.gather`. When doing this, you **must** set `use_sub_branch=True` to isolate the events of each parallel execution.
159+
160+
```python
161+
import asyncio
162+
from google.adk import Context, Event, Agent
163+
from google.adk.workflow import node
164+
165+
worker = Agent(name="worker", instruction="Process {node_input}")
166+
167+
@node(rerun_on_resume=True)
168+
async def parallel_orchestrator(ctx: Context, node_input: list[str]):
169+
tasks = []
170+
for topic in node_input:
171+
tasks.append(
172+
ctx.run_node(
173+
worker,
174+
node_input=topic,
175+
use_sub_branch=True, # Critical for parallel isolation
176+
)
177+
)
178+
179+
# Await all tasks concurrently
180+
results = await asyncio.gather(*tasks)
181+
yield Event(output=results)
182+
```
183+
184+
## Best Practices
185+
186+
- **Avoid Unsupervised Tasks**: Always `await` `ctx.run_node()` directly (or via `asyncio.gather`). Do **not** wrap it in `asyncio.create_task()` without awaiting it, as errors will be swallowed, and tasks won't be cancelled if the workflow is interrupted.
187+
- **Manage Side Effects and Resumption**: Because a parent node with `rerun_on_resume=True` is executed from the beginning on resumption, any code with side effects (e.g., database writes, API calls) in the parent node will run again.
188+
- *Best Practice*: Keep the parent orchestrator node's logic as light as possible, containing mostly control flow and `ctx.run_node` calls.
189+
- *Best Practice*: Move any logic with side effects into dedicated child nodes and execute them via `ctx.run_node`. Since completed child nodes are cached and replayed, their side effects will *not* be executed again on resumption.
190+
191+
192+
## Limitations
193+
194+
- **Replay Overhead**: Because the parent node is re-run from the beginning on resume, long-running parent node logic (outside of `ctx.run_node` calls) will be re-executed. Keep the orchestrator node logic light and delegate heavy lifting to child nodes.
195+
196+
## Related samples
197+
198+
- [Dynamic Nodes Sample](../../../../contributing/samples/workflows/dynamic_nodes/)
199+
- [Dynamic Fan-Out / Fan-In Sample](../../../../contributing/samples/workflows/dynamic_fan_out_fan_in/)
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Function Nodes
2+
3+
In ADK, any standard Python function, coroutine, or generator can be used as a workflow node. The framework automatically wraps these callables under the hood, allowing you to build complex graphs with minimal boilerplate.
4+
5+
## Introduction
6+
7+
Function nodes are the most common and lightweight way to implement logic in ADK workflows. Instead of subclassing `BaseNode` for every step, you can write standard Python functions.
8+
9+
Developer problems solved:
10+
- **Zero Boilerplate**: Write standard Python code without framework-specific class definitions.
11+
- **Implicit Wrapping**: Pass functions directly to workflow edges; the framework handles integration automatically.
12+
- **Declarative Signatures**: Access workflow state, input from predecessor nodes, or the execution context simply by declaring them in the function parameters.
13+
14+
## Get started
15+
16+
The following example demonstrates how to define standard Python functions and use them directly in a workflow chain.
17+
18+
```python
19+
from google.adk import START, Workflow
20+
21+
# 1. Simple sequential steps
22+
# The output of step_one is automatically passed as input to step_two
23+
def step_one(node_input: str) -> str:
24+
return f"{node_input} -> step_one"
25+
26+
def step_two(node_input: str) -> str:
27+
return f"{node_input} -> step_two"
28+
29+
# 2. Step that accesses workflow state
30+
# user_name is automatically resolved from ctx.state["user_name"]
31+
def step_three(node_input: str, user_name: str) -> str:
32+
return f"Hello {user_name}! {node_input}"
33+
34+
# Use the functions directly in the workflow edges
35+
workflow = Workflow(
36+
name="my_workflow",
37+
edges=[
38+
(START, step_one, step_two, step_three),
39+
],
40+
)
41+
```
42+
43+
## How it works
44+
45+
When a workflow executes a function node, it performs several operations automatically:
46+
47+
### Parameter Resolution
48+
The framework inspects the function signature to determine how to populate its arguments:
49+
* **`ctx`** (or any parameter type-hinted as `Context`): Injects the workflow `Context` object.
50+
* **`node_input`**: Injects the output value from the predecessor node.
51+
* **Any other parameter**: Resolved by looking up the parameter name in `ctx.state` (or `node_input` if parameter binding is customized).
52+
53+
### Type Coercion
54+
Input values are automatically validated and coerced to match the function's type hints using Pydantic:
55+
* **Pydantic Models**: If a parameter is type-hinted as a Pydantic `BaseModel` (e.g., `node_input: MyModel`) and the input is a dictionary, it is auto-converted to the model instance.
56+
* **Content to String**: If a parameter expects a `str` but receives a `types.Content` object (e.g. the raw user message from `START`), it automatically extracts and concatenates the text parts.
57+
58+
### Event Normalization
59+
Return and yield values are normalized to `Event` objects:
60+
* Returning or yielding `None` does not emit an output event, but execution continues downstream with `None` passed as the input to successor nodes.
61+
* Raw values (strings, dicts, etc.) are wrapped in `Event(output=value)`.
62+
* Pydantic models are serialized to dictionaries.
63+
* State changes made via `ctx.state` during execution are automatically captured and attached to the event to be persisted.
64+
65+
## Configuration & Explicit Wrapping
66+
67+
While implicit wrapping works for most cases, you can wrap functions explicitly using the `FunctionNode` class or the `@node` decorator when you need to configure execution behavior.
68+
69+
Use explicit configuration when you need to define:
70+
* `rerun_on_resume`: Control if the node should rerun when the workflow resumes (default is `False` for function nodes, meaning they complete with the resuming input).
71+
* `retry_config`: Enable retries on failures.
72+
* `timeout`: Set a maximum execution time.
73+
* `auth_config`: Gate execution with user authentication.
74+
75+
### Using `@node` Decorator
76+
77+
```python
78+
from google.adk.workflow import node
79+
80+
@node(rerun_on_resume=True)
81+
def process_payment(node_input: dict) -> str:
82+
# This node will rerun if the workflow is resumed after a pause
83+
...
84+
```
85+
86+
### Using `FunctionNode` Class
87+
88+
```python
89+
from google.adk.workflow import FunctionNode, RetryConfig
90+
91+
def my_func(node_input: str) -> str:
92+
...
93+
94+
# Wrap explicitly to configure retries
95+
custom_node = FunctionNode(
96+
my_func,
97+
name="payment_step",
98+
retry_config=RetryConfig(max_attempts=3),
99+
)
100+
```
101+
102+
## Advanced applications
103+
104+
### Emitting Message Events for Web UI
105+
Only the `Event.message` (user-facing content) is rendered in the Web UI, while `Event.output` is internal and passed downstream. For terminal nodes or nodes producing user-visible intermediate results, yield both a message event and an output event:
106+
107+
```python
108+
from google.adk.events.event import Event
109+
110+
async def summarize(ctx: Context, node_input: str):
111+
result = f"Summary: {node_input}"
112+
# Rendered in UI (message accepts a raw string and auto-wraps it)
113+
yield Event(message=result)
114+
# Passed to downstream nodes
115+
yield Event(output=result)
116+
```
117+
118+
### State Integration
119+
120+
You can update the shared workflow state in two ways: by mutating `ctx.state` directly, or by yielding/returning an `Event(state=...)`.
121+
122+
#### 1. Mutating `ctx.state` directly (Imperative)
123+
This is the most common way when your function already accesses the context. Mutations are tracked and automatically persisted by the framework when the node finishes execution.
124+
125+
```python
126+
def update_via_context(ctx: Context, node_input: str) -> str:
127+
# State is updated immediately in memory
128+
ctx.state["counter"] = ctx.state.get("counter", 0) + 1
129+
return node_input
130+
```
131+
132+
#### 2. Yielding/Returning `Event(state=...)` (Declarative)
133+
This is useful if you want to declare state changes as events, or if your function does not need the `ctx` object otherwise.
134+
135+
```python
136+
from google.adk.events.event import Event
137+
138+
def update_via_event(node_input: str):
139+
# Returns the state change without needing 'ctx' in the signature
140+
return Event(
141+
output=node_input,
142+
state={"last_processed": node_input}
143+
)
144+
```
145+
146+
#### Key Differences
147+
148+
| Feature | Mutating `ctx.state` | Yielding `Event(state=...)` |
149+
| :--- | :--- | :--- |
150+
| **Visibility** | Changes are visible **immediately** to subsequent lines in the same function. | Changes are only visible **after** the event is yielded and processed by the framework. |
151+
| **Signature** | Requires `ctx: Context` in the function parameters. | Can be used in any function (no `ctx` required). |
152+
| **Style** | Imperative state modification. | Declarative event-driven state update. |
153+
154+
## Limitations
155+
156+
- **Union Type Hints**: If `node_input` is hinted with a `Union` type (e.g. `str | dict`), the framework skips automatic type validation to avoid false positives. You must perform manual `isinstance` checks in the function body if you need to validate the input type.
157+
158+
## Related samples
159+
160+
The following samples demonstrate function node usage:
161+
- [Node Output](../../../../contributing/samples/workflows/node_output/agent.py) - Auto type conversion to Pydantic models.
162+
- [Route](../../../../contributing/samples/workflows/route/agent.py) - Yielding events with routes.
163+
- [State](../../../../contributing/samples/workflows/state/agent.py) - Interacting with workflow state.
164+
- [Auth API Key](../../../../contributing/samples/workflows/auth_api_key/agent.py) - Using authentication.
165+
- [Request Input Advanced](../../../../contributing/samples/workflows/request_input_advanced/agent.py) - Human-in-the-loop with schemas.

0 commit comments

Comments
 (0)