Skip to content

Commit 198139e

Browse files
DeanChensjcopybara-github
authored andcommitted
refactor(workflow): move agent transfer loop into DynamicNodeScheduler
Migrate the sequential agent transfer (handoff) loop from _dynamic_node_executor.py into DynamicNodeScheduler.__call__. - Extract single-node execution logic in DynamicNodeScheduler into _execute_step(...), covering run tracking, replay barriers, and execution. - Implement the agent transfer loop in DynamicNodeScheduler.__call__, resolving target agents and parent contexts via resolve_and_derive_transfer_context. Each hop uses the scheduler that owns its own parent context, so run IDs keep coming from that context's state. - Simplify run_node_internal in _dynamic_node_executor.py to delegate to ScheduleDynamicNode. When ctx._workflow_scheduler is None the executor uses a throwaway DynamicNodeScheduler with enable_replay=False without attaching it to ctx: it drives transfers but skips session event rehydration and defaults run_id to '1', so a path that previously ran without a scheduler stays a direct pass-through to NodeRunner, and ctx._workflow_scheduler is not None continues to strictly mean inside a workflow. - Support resume_inputs through ScheduleDynamicNode and DynamicNodeScheduler for dynamic node resumption. - Remove Context._child_run_counters, a run counter mirror that nothing read. DynamicNodeState.run_counters is now the only counter. Behaviour change: when a transfer moves execution to a different parent context and the target then interrupts, the interrupt IDs now land on the calling node's Context rather than the transfer target's parent Context. The calling node's NodeRunner is the only reader of those IDs, so previously the caller was recorded COMPLETED while the agent was in fact waiting for user input, and no checkpoint was emitted. Reaching this required an upward transfer whose target interrupted; sibling transfers and non-interrupting hops were unaffected. - Add unit tests for transfer loop execution, interrupt halting, target validation, scheduler handover between parent contexts, the replay opt-out, and interrupt ID propagation after a transfer. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 981434445
1 parent 8f1323a commit 198139e

7 files changed

Lines changed: 896 additions & 245 deletions

File tree

.agents/skills/adk-architecture/references/interface-workflow.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ When `ctx.run_node()` is called, the scheduler checks three cases:
141141
- All resolved → re-execute with `resume_inputs` from the
142142
resolved function responses.
143143

144+
4. **Agent Transfer** — if the child node is an agent that requests an
145+
agent handoff (`child_ctx.actions.transfer_to_agent`), the scheduler
146+
drives the sequential transfer loop. It resolves the target agent and
147+
parent context, delegates single-step execution to the target context's
148+
owning scheduler, and preserves output delegation (`use_as_output`)
149+
when execution returns to the invoking context.
150+
144151
State reconstruction is **lazy**: the scheduler scans session events
145152
only on the first `ctx.run_node()` call for a given path, not
146153
upfront. This avoids scanning for dynamic nodes that won't be

src/google/adk/agents/context.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,6 @@ def __init__(
201201
self._resume_inputs = resume_inputs or {}
202202
self._workflow_scheduler = _derive_scheduler(parent_ctx)
203203
self._node_rerun_on_resume = node.rerun_on_resume if node else True
204-
# TODO: Remove. Superseded by DynamicNodeState.run_counters, which is now
205-
# the authoritative sequential run_id allocator. Kept until the
206-
# transfer-loop refactor lands to avoid churning that change.
207-
self._child_run_counters: dict[str, int] = {}
208204
self._attempt_count = attempt_count
209205
self._output_delegated = False
210206
self._output_value: Any = None

src/google/adk/workflow/_dynamic_node_executor.py

Lines changed: 70 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from __future__ import annotations
1818

1919
from typing import Any
20-
from typing import cast
2120
from typing import TYPE_CHECKING
2221

2322
from ..agents.base_agent import BaseAgent
@@ -27,12 +26,10 @@
2726
from ._graph import NodeLike
2827
from ._node_runner import NodeRunner
2928
from ._workflow import Workflow
30-
from .utils._transfer_utils import resolve_and_derive_transfer_context
3129
from .utils._workflow_graph_utils import build_node
3230

3331
if TYPE_CHECKING:
3432
from ..agents.context import Context
35-
from ._schedule_dynamic_node import ScheduleDynamicNode
3633

3734

3835
async def run_node_internal(
@@ -81,140 +78,82 @@ async def run_node_internal(
8178
)
8279
ctx._output_delegated = True
8380

84-
# Pointers to track the active execution state in the transfer loop.
85-
# These will be updated dynamically if an agent transfers execution.
86-
curr_parent_ctx = ctx
87-
curr_node = built_node
88-
curr_run_id = run_id
89-
curr_input = node_input
90-
91-
# Active Execution Loop: Handles both standard execution and sequential Agent Transfers
92-
# (e.g. Agent A transferring to Agent B). Instead of recursive execution, we use this
93-
# loop to execute the target agent in-place, updating pointers and 'continuing' the loop.
94-
while True:
95-
curr_use_as_output = use_as_output if (curr_parent_ctx is ctx) else False
96-
if ctx._workflow_scheduler:
97-
# --- Mode 1: Workflow Execution ---
98-
# The node is running as part of a Workflow graph. We must delegate execution
99-
# to the workflow scheduler to handle graph dependencies and state.
100-
101-
# Validate the caller-supplied run_id. A None run_id is passed through
102-
# unchanged: the scheduler owns sequential run_id allocation.
103-
if curr_run_id and curr_run_id.isdigit() and not skip_run_id_validation:
104-
raise ValueError(
105-
f'Explicit run_id "{curr_run_id}" for node "{curr_node.name}"'
106-
' must contain non-numeric characters to prevent collision'
107-
' with auto-generated IDs.'
108-
)
109-
110-
scheduler = cast(
111-
'ScheduleDynamicNode', curr_parent_ctx._workflow_scheduler
112-
)
113-
child_ctx = await scheduler(
114-
curr_parent_ctx,
115-
curr_node,
116-
curr_input,
117-
node_name=curr_node.name,
118-
use_as_output=curr_use_as_output,
119-
run_id=curr_run_id,
120-
use_sub_branch=use_sub_branch,
121-
override_branch=override_branch,
122-
override_isolation_scope=override_isolation_scope,
123-
)
124-
else:
125-
# --- Mode 2: Standalone Execution ---
126-
# The node is running independently (outside of a workflow).
127-
# We run it directly using NodeRunner.
128-
child_ctx = await run_node_standalone(
129-
curr_parent_ctx,
130-
curr_node,
131-
curr_input,
132-
use_as_output=curr_use_as_output,
133-
use_sub_branch=use_sub_branch,
134-
override_branch=override_branch,
135-
override_isolation_scope=override_isolation_scope,
136-
run_id=curr_run_id,
137-
resume_inputs=resume_inputs,
81+
# Validate the caller-supplied run_id when running inside a workflow.
82+
# A None run_id is passed through unchanged: the scheduler owns sequential run_id allocation.
83+
# Standalone runs (outside a workflow) allow explicit numeric IDs since there is no auto-allocation collision risk.
84+
if ctx._workflow_scheduler is not None:
85+
if run_id and run_id.isdigit() and not skip_run_id_validation:
86+
raise ValueError(
87+
f'Explicit run_id "{run_id}" for node "{built_node.name}"'
88+
' must contain non-numeric characters to prevent collision'
89+
' with auto-generated IDs.'
13890
)
13991

140-
# Extract the transfer target if the node requested an agent transfer.
141-
transfer_to_agent = (
142-
child_ctx.actions.transfer_to_agent if child_ctx else None
92+
scheduler = ctx._workflow_scheduler
93+
if scheduler is None:
94+
from ._dynamic_node_scheduler import DynamicNodeScheduler
95+
from ._dynamic_node_scheduler import DynamicNodeState
96+
97+
# No orchestrator installed one, so this call is not part of a replayable
98+
# workflow. Use a transfer-only scheduler: it drives the sequential
99+
# agent transfer loop but skips session event rehydration, keeping this
100+
# path a direct pass-through to NodeRunner as it was before, without
101+
# attaching a scheduler to ctx._workflow_scheduler.
102+
#
103+
# IMPORTANT: ctx._workflow_scheduler MUST remain None for standalone runs.
104+
# Across ADK, `ctx._workflow_scheduler is not None` is the canonical check
105+
# for whether execution is inside a workflow graph (e.g. for numeric run_id
106+
# validation and replay semantics).
107+
scheduler = DynamicNodeScheduler(
108+
state=DynamicNodeState(), enable_replay=False
143109
)
144110

145-
# Post-Execution Validation: If the caller expects the raw output (not the Context),
146-
# we check for errors or interrupts and raise them immediately.
147-
if not return_ctx:
148-
if child_ctx.error:
149-
raise DynamicNodeFailError(
150-
message=f'Dynamic node {curr_node.name} failed',
151-
error=child_ctx.error,
152-
error_node_path=child_ctx.error_node_path,
153-
)
154-
if child_ctx.interrupt_ids:
155-
# Propagate child's interrupt_ids to this node's ctx
156-
# so NodeRunner sees them after catching the error.
157-
curr_parent_ctx._interrupt_ids.update(child_ctx.interrupt_ids)
158-
raise NodeInterruptedError()
159-
# When the caller passes raise_on_wait=True, surface a child
160-
# execution that's WAITING (wait_for_output, no output, not transferring)
161-
# as NodeInterruptedError so the parent's NodeRunner records
162-
# the parent as WAITING instead of falsely COMPLETED.
163-
if raise_on_wait and child_ctx.output is None and not transfer_to_agent:
164-
if isinstance(curr_node, Workflow) or getattr(
165-
curr_node, 'wait_for_output', False
166-
):
167-
raise NodeInterruptedError()
168-
169-
# Handle Agent Transfer: If a transfer was requested, we resolve the target agent
170-
# and its parent context, update loop pointers, and continue to the next iteration.
171-
if isinstance(transfer_to_agent, str):
172-
if not isinstance(curr_node, BaseAgent):
173-
raise ValueError('Only agents can request an agent transfer.')
174-
target_name = transfer_to_agent
175-
root_agent = getattr(curr_node, 'root_agent', None)
176-
if not root_agent:
177-
raise ValueError(f'Cannot find root_agent on node {curr_node.name}')
178-
179-
target_agent, next_parent_ctx = resolve_and_derive_transfer_context(
180-
target_name=target_name,
181-
current_agent=curr_node,
182-
root_agent=root_agent,
183-
curr_ctx=child_ctx,
184-
curr_parent_ctx=curr_parent_ctx,
185-
)
186-
if not target_agent:
187-
raise ValueError(f"Transfer target agent '{target_name}' not found.")
188-
if not next_parent_ctx:
189-
available = []
190-
if hasattr(curr_node, '_get_available_agent_names'):
191-
available = curr_node._get_available_agent_names()
192-
available_str = (
193-
f"\nAvailable agents: {', '.join(available)}" if available else ''
194-
)
195-
raise ValueError(
196-
f"Cannot transfer from '{curr_node.name}' to unrelated agent"
197-
f" '{target_name}'.{available_str}"
198-
)
199-
curr_parent_ctx = next_parent_ctx
200-
201-
# Set up parameters for next iteration (the transfer target).
202-
curr_node = target_agent
203-
curr_run_id = None
204-
curr_input = None # Input for transfer target is usually empty.
205-
resume_inputs = None
206-
207-
if not curr_parent_ctx:
208-
raise AssertionError(
209-
'curr_parent_ctx cannot be None during active workflow execution'
210-
)
111+
child_ctx = await scheduler(
112+
ctx,
113+
built_node,
114+
node_input,
115+
node_name=built_node.name,
116+
use_as_output=use_as_output,
117+
run_id=run_id,
118+
use_sub_branch=use_sub_branch,
119+
override_branch=override_branch,
120+
override_isolation_scope=override_isolation_scope,
121+
resume_inputs=resume_inputs,
122+
)
211123

212-
continue
124+
transfer_to_agent = child_ctx.actions.transfer_to_agent if child_ctx else None
125+
126+
# Post-Execution Validation: If the caller expects the raw output (not the Context),
127+
# we check for errors or interrupts and raise them immediately.
128+
if not return_ctx:
129+
if child_ctx.error:
130+
executed_name = child_ctx.node.name if child_ctx.node else built_node.name
131+
raise DynamicNodeFailError(
132+
message=f'Dynamic node {executed_name} failed',
133+
error=child_ctx.error,
134+
error_node_path=child_ctx.error_node_path,
135+
)
136+
if child_ctx.interrupt_ids:
137+
# Propagate child's interrupt_ids to this node's ctx
138+
# so NodeRunner sees them after catching the error.
139+
ctx._interrupt_ids.update(child_ctx.interrupt_ids)
140+
raise NodeInterruptedError()
141+
# When the caller passes raise_on_wait=True, surface a child
142+
# execution that's WAITING (wait_for_output, no output, not transferring)
143+
# as NodeInterruptedError so the parent's NodeRunner records
144+
# the parent as WAITING instead of falsely COMPLETED.
145+
if raise_on_wait and child_ctx.output is None and not transfer_to_agent:
146+
# After a transfer chain, child_ctx belongs to the last agent that ran,
147+
# not to built_node, so the wait decision must follow child_ctx.node.
148+
executed_node = child_ctx.node
149+
if isinstance(executed_node, Workflow) or getattr(
150+
executed_node, 'wait_for_output', False
151+
):
152+
raise NodeInterruptedError()
213153

214-
# If no transfer occurred, execution of the branch is complete.
215-
if return_ctx:
216-
return child_ctx
217-
return child_ctx.output
154+
if return_ctx:
155+
return child_ctx
156+
return child_ctx.output
218157

219158

220159
async def run_node_standalone(

0 commit comments

Comments
 (0)