@@ -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"\n Available 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
0 commit comments