Files
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

200 lines
8.9 KiB
Markdown

# Dynamic Node Scheduling
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.
## Introduction
While static graph definitions (`Workflow(edges=[...])`) are suitable for many structured tasks, some scenarios require more flexibility. For example, you might need to:
- Loop a set of nodes until a condition is met (e.g., generator-evaluator loops).
- Run a variable number of tasks in parallel based on runtime input (dynamic fan-out).
- Conditionally execute nodes based on complex logic that is difficult to express in static edges.
`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.
## Get started
The following example demonstrates how to dynamically execute a child agent from a parent node.
```python
from google.adk import Agent, Context, Event, Workflow
from google.adk.workflow import node
# Define a child agent
generate_headline = Agent(
name="generate_headline",
instruction="Write a catchy headline about the topic in the user message.",
)
# Define the parent orchestrator node (MUST have rerun_on_resume=True)
@node(rerun_on_resume=True)
async def orchestrate(ctx: Context, node_input: str) -> str:
# Dynamically execute the child agent and await its output
headline = await ctx.run_node(generate_headline, node_input=node_input)
yield Event(output=headline)
# Build the workflow
root_agent = Workflow(
name="root_agent",
edges=[("START", orchestrate)],
)
```
## How it works
When `await ctx.run_node(node_like, ...)` is called:
1. **Orchestrator Registration**: The workflow's `DynamicNodeScheduler` registers the child node execution.
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`).
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.
### Input Mapping
The `node_input` passed to `ctx.run_node(node, node_input=value)` is delivered differently depending on the type of the child node:
- **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).
- **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.
- **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.
## Requirements & Rules
### 1. `rerun_on_resume=True` is Mandatory for Parents
Any node that calls `ctx.run_node()` **must** be configured with `rerun_on_resume=True`.
If the parent node does not have this setting, calling `ctx.run_node()` will raise a `ValueError` at runtime.
### 2. Function Parameter Mapping (`node_input` vs. Dict Binding)
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.
How you receive this input depends on how you define your function:
#### Pass-through `node_input` (Default)
To receive the raw `value` directly, the function's parameter must be named exactly `node_input`.
```python
# Correct: receives the raw value passed to node_input
def my_worker(node_input: str):
return f"Done: {node_input}"
# Incorrect: will fail because it tries to look up 'data' in session state
def my_worker(data: str):
return f"Done: {data}"
```
#### Binding Dictionary Keys to Parameters (`parameter_binding='node_input'`)
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'`.
You can configure this using the `@node` decorator with `parameter_binding='node_input'`:
```python
from google.adk.workflow import node
# Decorate with parameter_binding='node_input'
@node(parameter_binding='node_input')
def my_worker(foo: str):
return f"Done: {foo}"
# Call via ctx.run_node
result = await ctx.run_node(my_worker, node_input={'foo': 'bar'}) # foo gets 'bar'
```
### 3. Nested Dynamic Nodes
If a dynamically scheduled node *itself* calls `ctx.run_node()`, it becomes a parent and must also have `rerun_on_resume=True`.
You should decorate the nested function with `@node(rerun_on_resume=True)` to ensure it has this property when executed:
```python
from google.adk.workflow import node
@node(rerun_on_resume=True)
async def inner_parent(ctx: Context):
# Calls another dynamic node internally
result = await ctx.run_node(some_child)
yield Event(output=result)
# In the outer parent:
await ctx.run_node(inner_parent)
```
### 4. Generator Returns
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.
## Method Signature
```python
async def run_node(
self,
node: NodeLike,
node_input: Any = None,
*,
use_as_output: bool = False,
run_id: str | None = None,
use_sub_branch: bool = False,
override_branch: str | None = None,
) -> Any:
```
### Parameters
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `node` | `NodeLike` | *Required* | The node to execute (Function, Agent, or Workflow). |
| `node_input` | `Any` | `None` | Input data to pass to the dynamic node. |
| `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. |
| `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. |
| `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. |
| `override_branch` | `str \| None` | `None` | Explicitly overrides the branch name for the execution context. |
## Advanced Applications
### Dynamic Fan-Out (Parallel Execution)
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.
```python
import asyncio
from google.adk import Context, Event, Agent
from google.adk.workflow import node
worker = Agent(name="worker", instruction="Process {node_input}")
@node(rerun_on_resume=True)
async def parallel_orchestrator(ctx: Context, node_input: list[str]):
tasks = []
for topic in node_input:
tasks.append(
ctx.run_node(
worker,
node_input=topic,
use_sub_branch=True, # Critical for parallel isolation
)
)
# Await all tasks concurrently
results = await asyncio.gather(*tasks)
yield Event(output=results)
```
## Best Practices
- **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.
- **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.
- *Best Practice*: Keep the parent orchestrator node's logic as light as possible, containing mostly control flow and `ctx.run_node` calls.
- *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.
## Limitations
- **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.
## Related samples
- [Dynamic Nodes Sample](../../../../contributing/samples/workflows/dynamic_nodes/)
- [Dynamic Fan-Out / Fan-In Sample](../../../../contributing/samples/workflows/dynamic_fan_out_fan_in/)