Skip to content

Commit 51e2f52

Browse files
DeanChensjcopybara-github
authored andcommitted
refactor: Switch runners._drive_root_node to use context.run_node
Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 940603657
1 parent 7423101 commit 51e2f52

3 files changed

Lines changed: 619 additions & 110 deletions

File tree

src/google/adk/agents/context.py

Lines changed: 146 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ async def _run_node_internal(
491491
raise_on_wait: bool = False,
492492
return_ctx: bool = False,
493493
resume_inputs: dict[str, Any] | None = None,
494+
skip_run_id_validation: bool = False,
494495
) -> Any:
495496
"""Executes a node dynamically (Internal Orchestration API).
496497
@@ -516,102 +517,165 @@ async def _run_node_internal(
516517
if isinstance(node, BaseAgent) and isinstance(built_node, BaseAgent):
517518
built_node.parent_agent = node.parent_agent
518519

519-
# Mode 1: Running within a Workflow graph.
520-
# The workflow orchestrator provides a scheduler to handle resume, dedup,
521-
# etc.
522-
if self._workflow_scheduler:
523-
from ..workflow._errors import NodeInterruptedError
520+
# Output delegation: once set, the calling node's own output
521+
# events are suppressed — the child's output (annotated with
522+
# output_for) becomes the calling node's output.
523+
# We validate and set this upfront before entering the loop.
524+
if use_as_output:
525+
from ..workflow._workflow import Workflow
524526

525-
# Output delegation: once set, the calling node's own output
526-
# events are suppressed — the child's output (annotated with
527-
# output_for) becomes the calling node's output.
528-
if use_as_output:
527+
if not isinstance(self.node, Workflow):
529528
if self._output_delegated:
530529
raise ValueError(
531530
f'Node {self.node_path} already has a use_as_output delegate.'
532531
)
533532
self._output_delegated = True
534533

535-
if run_id:
536-
if run_id.isdigit():
537-
raise ValueError(
538-
f'Explicit run_id "{run_id}" for node "{built_node.name}" must'
539-
' contain non-numeric characters to prevent collision with'
540-
' auto-generated IDs.'
534+
# Pointers to track the active execution state in the transfer loop.
535+
# These will be updated dynamically if an agent transfers execution.
536+
curr_parent_ctx = self
537+
curr_node = built_node
538+
curr_run_id = run_id
539+
curr_input = node_input
540+
541+
# Active Execution Loop: Handles both standard execution and sequential Agent Transfers
542+
# (e.g. Agent A transferring to Agent B). Instead of recursive execution, we use this
543+
# loop to execute the target agent in-place, updating pointers and 'continuing' the loop.
544+
while True:
545+
curr_use_as_output = use_as_output if (curr_parent_ctx is self) else False
546+
if self._workflow_scheduler:
547+
# --- Mode 1: Workflow Execution ---
548+
# The node is running as part of a Workflow graph. We must delegate execution
549+
# to the workflow scheduler to handle graph dependencies and state.
550+
from ..workflow._errors import NodeInterruptedError
551+
552+
# Validate or auto-generate run_id for this scheduler execution.
553+
if curr_run_id:
554+
if curr_run_id.isdigit() and not skip_run_id_validation:
555+
raise ValueError(
556+
f'Explicit run_id "{curr_run_id}" for node "{curr_node.name}"'
557+
' must contain non-numeric characters to prevent collision'
558+
' with auto-generated IDs.'
559+
)
560+
elif not curr_run_id:
561+
curr_parent_ctx._child_run_counters[curr_node.name] = (
562+
curr_parent_ctx._child_run_counters.get(curr_node.name, 0) + 1
541563
)
564+
curr_run_id = str(curr_parent_ctx._child_run_counters[curr_node.name])
565+
566+
child_ctx = await curr_parent_ctx._workflow_scheduler(
567+
curr_parent_ctx,
568+
curr_node,
569+
curr_input,
570+
node_name=curr_node.name,
571+
use_as_output=curr_use_as_output,
572+
run_id=curr_run_id,
573+
use_sub_branch=use_sub_branch,
574+
override_branch=override_branch,
575+
override_isolation_scope=override_isolation_scope,
576+
)
542577
else:
543-
self._child_run_counters[built_node.name] = (
544-
self._child_run_counters.get(built_node.name, 0) + 1
578+
# --- Mode 2: Standalone Execution ---
579+
# The node is running independently (outside of a workflow).
580+
# We run it directly using NodeRunner.
581+
child_ctx = await curr_parent_ctx._run_node_standalone(
582+
curr_node,
583+
curr_input,
584+
use_as_output=curr_use_as_output,
585+
use_sub_branch=use_sub_branch,
586+
override_branch=override_branch,
587+
override_isolation_scope=override_isolation_scope,
588+
run_id=curr_run_id,
589+
resume_inputs=resume_inputs,
545590
)
546-
run_id = str(self._child_run_counters[built_node.name])
547-
548-
child_ctx = await self._workflow_scheduler(
549-
self,
550-
built_node,
551-
node_input,
552-
node_name=built_node.name,
553-
use_as_output=use_as_output,
554-
run_id=run_id,
555-
use_sub_branch=use_sub_branch,
556-
override_branch=override_branch,
557-
override_isolation_scope=override_isolation_scope,
591+
592+
# Extract the transfer target if the node requested an agent transfer.
593+
transfer_to_agent = (
594+
child_ctx.actions.transfer_to_agent if child_ctx else None
558595
)
559-
if child_ctx.error:
560-
from ..workflow._errors import DynamicNodeFailError
561596

562-
raise DynamicNodeFailError(
563-
message=f'Dynamic node {built_node.name} failed',
564-
error=child_ctx.error,
565-
error_node_path=child_ctx.error_node_path,
597+
# Post-Execution Validation: If the caller expects the raw output (not the Context),
598+
# we check for errors or interrupts and raise them immediately.
599+
if not return_ctx:
600+
if child_ctx.error:
601+
from ..workflow._errors import DynamicNodeFailError
602+
603+
raise DynamicNodeFailError(
604+
message=f'Dynamic node {curr_node.name} failed',
605+
error=child_ctx.error,
606+
error_node_path=child_ctx.error_node_path,
607+
)
608+
if child_ctx.interrupt_ids:
609+
from ..workflow._errors import NodeInterruptedError
610+
611+
# Propagate child's interrupt_ids to this node's ctx
612+
# so NodeRunner sees them after catching the error.
613+
curr_parent_ctx._interrupt_ids.update(child_ctx.interrupt_ids)
614+
raise NodeInterruptedError()
615+
# When the caller passes raise_on_wait=True, surface a child
616+
# that's WAITING (wait_for_output, no output, not transferring)
617+
# as NodeInterruptedError so the parent's NodeRunner records
618+
# the parent as WAITING instead of falsely COMPLETED.
619+
if (
620+
raise_on_wait
621+
and curr_node.wait_for_output
622+
and child_ctx.output is None
623+
and not transfer_to_agent
624+
):
625+
from ..workflow._errors import NodeInterruptedError
626+
627+
raise NodeInterruptedError()
628+
629+
# Handle Agent Transfer: If a transfer was requested, we resolve the target agent
630+
# and its parent context, update loop pointers, and continue to the next iteration.
631+
if isinstance(transfer_to_agent, str):
632+
target_name = transfer_to_agent
633+
root_agent = getattr(curr_node, 'root_agent', None)
634+
if not root_agent:
635+
raise ValueError(f'Cannot find root_agent on node {curr_node.name}')
636+
637+
# Local import to avoid runtime circular dependencies with Context
638+
from ..workflow.utils._transfer_utils import resolve_and_derive_transfer_context
639+
640+
target_agent, next_parent_ctx = resolve_and_derive_transfer_context(
641+
target_name=target_name,
642+
current_agent=curr_node,
643+
root_agent=root_agent,
644+
curr_ctx=child_ctx,
645+
curr_parent_ctx=curr_parent_ctx,
566646
)
567-
if child_ctx.interrupt_ids:
568-
# Propagate child's interrupt_ids to this node's ctx
569-
# so NodeRunner sees them after catching the error.
570-
self._interrupt_ids.update(child_ctx.interrupt_ids)
571-
raise NodeInterruptedError()
572-
# When the caller passes raise_on_wait=True, surface a child
573-
# that's WAITING (wait_for_output, no output, not transferring)
574-
# as NodeInterruptedError so the parent's NodeRunner records
575-
# the parent as WAITING instead of falsely COMPLETED.
576-
if (
577-
raise_on_wait
578-
and built_node.wait_for_output
579-
and child_ctx.output is None
580-
and not child_ctx.actions.transfer_to_agent
581-
):
582-
raise NodeInterruptedError()
583-
return child_ctx if return_ctx else child_ctx.output
584-
585-
# Mode 2: Standalone execution (outside of workflow).
586-
# Run the node directly via NodeRunner.
587-
result = await self._run_node_standalone(
588-
built_node,
589-
node_input,
590-
use_as_output=use_as_output,
591-
use_sub_branch=use_sub_branch,
592-
override_branch=override_branch,
593-
override_isolation_scope=override_isolation_scope,
594-
run_id=run_id,
595-
resume_inputs=resume_inputs,
596-
)
597-
if result.error:
598-
from ..workflow import _errors
647+
if not target_agent:
648+
raise ValueError(f"Transfer target agent '{target_name}' not found.")
649+
if not next_parent_ctx:
650+
available = []
651+
if hasattr(curr_node, '_get_available_agent_names'):
652+
available = curr_node._get_available_agent_names()
653+
available_str = (
654+
f"\nAvailable agents: {', '.join(available)}" if available else ''
655+
)
656+
raise ValueError(
657+
f"Cannot transfer from '{curr_node.name}' to unrelated agent"
658+
f" '{target_name}'.{available_str}"
659+
)
660+
curr_parent_ctx = next_parent_ctx
599661

600-
raise _errors.DynamicNodeFailError(
601-
message=f'Dynamic node {built_node.name} failed',
602-
error=result.error,
603-
error_node_path=result.error_node_path,
604-
)
605-
if (
606-
raise_on_wait
607-
and built_node.wait_for_output
608-
and result.output is None
609-
and not result.actions.transfer_to_agent
610-
):
611-
from ..workflow._errors import NodeInterruptedError
612-
613-
raise NodeInterruptedError()
614-
return result if return_ctx else result.output
662+
# Set up parameters for next iteration (the transfer target).
663+
curr_node = target_agent
664+
curr_run_id = None
665+
curr_input = None # Input for transfer target is usually empty.
666+
resume_inputs = None
667+
668+
if not curr_parent_ctx:
669+
raise AssertionError(
670+
'curr_parent_ctx cannot be None during active workflow execution'
671+
)
672+
673+
continue
674+
675+
# If no transfer occurred, execution of the branch is complete.
676+
if return_ctx:
677+
return child_ctx
678+
return child_ctx.output
615679

616680
# ============================================================================
617681
# Artifact methods

src/google/adk/runners.py

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,6 @@ async def _run_node_async(
453453
454454
Events flow through ic._event_queue via NodeRunner.
455455
"""
456-
from .workflow._node_runner import NodeRunner
457456

458457
with tracer.start_as_current_span('invocation'):
459458
# 1. Setup
@@ -518,6 +517,8 @@ async def _run_node_async(
518517
from .agents.base_agent import BaseAgent
519518
from .agents.context import Context
520519
from .workflow._dynamic_node_scheduler import DynamicNodeScheduler
520+
from .workflow._errors import DynamicNodeFailError
521+
from .workflow._errors import NodeInterruptedError
521522
from .workflow._workflow import _LoopState
522523

523524
root_ctx = Context(ic)
@@ -536,9 +537,6 @@ async def _run_node_async(
536537
# originating function-call id and so remain invisible to the
537538
# coordinator's view.
538539

539-
if not use_scheduler:
540-
root_node_runner = NodeRunner(node=root_agent, parent_ctx=root_ctx)
541-
542540
done_sentinel = object()
543541

544542
async def _drive_root_node():
@@ -548,19 +546,18 @@ async def _drive_root_node():
548546
# Stateful live EUC/LRO streams may rehydrate freshly if not yet persisted.
549547
scheduler = DynamicNodeScheduler(state=_LoopState())
550548
root_ctx._workflow_scheduler = scheduler
551-
ctx = await scheduler(
552-
root_ctx,
549+
550+
try:
551+
await root_ctx._run_node_internal(
553552
root_agent,
554-
node_input,
555-
run_id='1',
556-
)
557-
else:
558-
ctx = await root_node_runner.run(
559553
node_input=node_input,
560554
resume_inputs=resume_inputs,
561555
)
562-
if ctx.error:
563-
raise ctx.error
556+
except NodeInterruptedError:
557+
# The node was interrupted (e.g. for HITL).
558+
pass
559+
except DynamicNodeFailError as e:
560+
raise e.error
564561
finally:
565562
await ic._event_queue.put((done_sentinel, None))
566563

@@ -597,7 +594,8 @@ async def _run_node_live(
597594
"""Run a non-agent BaseNode in live mode."""
598595
from .agents.context import Context
599596
from .workflow._dynamic_node_scheduler import DynamicNodeScheduler
600-
from .workflow._node_runner import NodeRunner
597+
from .workflow._errors import DynamicNodeFailError
598+
from .workflow._errors import NodeInterruptedError
601599
from .workflow._workflow import _LoopState
602600
from .workflow._workflow import Workflow
603601

@@ -612,28 +610,23 @@ async def _run_node_live(
612610
root_agent = self.agent
613611
is_workflow = isinstance(root_agent, Workflow)
614612

615-
if not is_workflow:
616-
root_node_runner = NodeRunner(node=root_agent, parent_ctx=root_ctx)
617-
618613
done_sentinel = object()
619614

620615
async def _drive_root_node():
621616
try:
622617
if is_workflow:
623618
scheduler = DynamicNodeScheduler(state=_LoopState())
624619
root_ctx._workflow_scheduler = scheduler
625-
ctx = await scheduler(
626-
root_ctx,
620+
621+
try:
622+
await root_ctx.run_node(
627623
root_agent,
628-
None,
629-
run_id='1',
630-
)
631-
else:
632-
ctx = await root_node_runner.run(
633624
node_input=None,
634625
)
635-
if ctx.error:
636-
raise ctx.error
626+
except NodeInterruptedError:
627+
pass
628+
except DynamicNodeFailError as e:
629+
raise e.error
637630
finally:
638631
await ic._event_queue.put((done_sentinel, None))
639632

@@ -999,9 +992,9 @@ async def run_async(
999992
agent_to_run = self.agent
1000993
else:
1001994
agent_to_run = self._find_agent_to_run(session, self.agent)
1002-
from .workflow.utils._workflow_graph_utils import build_node # pylint: disable=g-import-not-at-top
1003995

1004-
agent_to_run = build_node(agent_to_run)
996+
# The agent_to_run will be built/cloned inside Context.run_node,
997+
# so we don't call build_node here to avoid double cloning.
1005998
else:
1006999
raise ValueError(
10071000
"LlmAgent as root agent must have mode='chat', but got"

0 commit comments

Comments
 (0)