docs/guides/workflow/dynamic_nodes/index.md
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.
While static graph definitions (Workflow(edges=[...])) are suitable for many structured tasks, some scenarios require more flexibility. For example, you might need to:
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.
The following example demonstrates how to dynamically execute a child agent from a parent node.
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)],
)
When await ctx.run_node(node_like, ...) is called:
DynamicNodeScheduler registers the child node execution.parent_node@1/child_node@1).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.The node_input passed to ctx.run_node(node, node_input=value) is delivered differently depending on the type of the child node:
value is passed directly to the function parameter named node_input. Other parameters are bound from the session state (default mode).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.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.rerun_on_resume=True is Mandatory for ParentsAny 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.
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:
node_input (Default)To receive the raw value directly, the function's parameter must be named exactly node_input.
# 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}"
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':
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'
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:
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)
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.
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:
| 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. |
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.
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)
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.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.
ctx.run_node calls.ctx.run_node. Since completed child nodes are cached and replayed, their side effects will not be executed again on resumption.ctx.run_node calls) will be re-executed. Keep the orchestrator node logic light and delegate heavy lifting to child nodes.