|
17 | 17 | from __future__ import annotations |
18 | 18 |
|
19 | 19 | from typing import Any |
20 | | -from typing import cast |
21 | 20 | from typing import TYPE_CHECKING |
22 | 21 |
|
23 | 22 | from ..agents.base_agent import BaseAgent |
|
27 | 26 | from ._graph import NodeLike |
28 | 27 | from ._node_runner import NodeRunner |
29 | 28 | from ._workflow import Workflow |
30 | | -from .utils._transfer_utils import resolve_and_derive_transfer_context |
31 | 29 | from .utils._workflow_graph_utils import build_node |
32 | 30 |
|
33 | 31 | if TYPE_CHECKING: |
34 | 32 | from ..agents.context import Context |
35 | | - from ._schedule_dynamic_node import ScheduleDynamicNode |
36 | 33 |
|
37 | 34 |
|
38 | 35 | async def run_node_internal( |
@@ -81,140 +78,82 @@ async def run_node_internal( |
81 | 78 | ) |
82 | 79 | ctx._output_delegated = True |
83 | 80 |
|
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.' |
138 | 90 | ) |
139 | 91 |
|
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 |
143 | 109 | ) |
144 | 110 |
|
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 | + ) |
211 | 123 |
|
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() |
213 | 153 |
|
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 |
218 | 157 |
|
219 | 158 |
|
220 | 159 | async def run_node_standalone( |
|
0 commit comments