@@ -612,3 +612,128 @@ def model_factory(node_config, **kwargs):
612612 assert 'Agent A' in result .output
613613 # Agent B's model must never have been invoked — no fan-out
614614 agent_b_model .ainvoke .assert_not_called ()
615+
616+
617+ def _make_multi_child_graph_with_tools (mock_ld_client : MagicMock , tool_names : list ) -> 'AgentGraphDefinition' :
618+ """Build a 3-node graph where the orchestrator also has functional tools."""
619+ context = MagicMock ()
620+
621+ def _node_tracker (key : str ) -> LDAIConfigTracker :
622+ return LDAIConfigTracker (
623+ ld_client = mock_ld_client ,
624+ variation_key = 'test-variation' ,
625+ config_key = key ,
626+ version = 1 ,
627+ model_name = 'gpt-4' ,
628+ provider_name = 'openai' ,
629+ context = context ,
630+ )
631+
632+ graph_tracker = AIGraphTracker (
633+ ld_client = mock_ld_client ,
634+ variation_key = 'test-variation' ,
635+ graph_key = 'multi-child-tools-graph' ,
636+ version = 1 ,
637+ context = context ,
638+ )
639+
640+ tool_defs = [{'name' : n , 'type' : 'function' , 'description' : '' , 'parameters' : {}} for n in tool_names ]
641+ configs = {
642+ 'orchestrator' : AIAgentConfig (
643+ key = 'orchestrator' ,
644+ enabled = True ,
645+ model = ModelConfig (name = 'gpt-4' , parameters = {'tools' : tool_defs }),
646+ provider = ProviderConfig (name = 'openai' ),
647+ instructions = 'Route to a specialist after gathering info.' ,
648+ tracker = _node_tracker ('orchestrator' ),
649+ ),
650+ 'agent-a' : AIAgentConfig (
651+ key = 'agent-a' ,
652+ enabled = True ,
653+ model = ModelConfig (name = 'gpt-4' , parameters = {}),
654+ provider = ProviderConfig (name = 'openai' ),
655+ instructions = 'You handle topic A.' ,
656+ tracker = _node_tracker ('agent-a' ),
657+ ),
658+ 'agent-b' : AIAgentConfig (
659+ key = 'agent-b' ,
660+ enabled = True ,
661+ model = ModelConfig (name = 'gpt-4' , parameters = {}),
662+ provider = ProviderConfig (name = 'openai' ),
663+ instructions = 'You handle topic B.' ,
664+ tracker = _node_tracker ('agent-b' ),
665+ ),
666+ }
667+
668+ edges = [
669+ Edge (key = 'orch-to-a' , source_config = 'orchestrator' , target_config = 'agent-a' ),
670+ Edge (key = 'orch-to-b' , source_config = 'orchestrator' , target_config = 'agent-b' ),
671+ ]
672+ graph_config = AIAgentGraphConfig (
673+ key = 'multi-child-tools-graph' ,
674+ root_config_key = 'orchestrator' ,
675+ edges = edges ,
676+ enabled = True ,
677+ )
678+ nodes = AgentGraphDefinition .build_nodes (graph_config , configs )
679+ return AgentGraphDefinition (
680+ agent_graph = graph_config ,
681+ nodes = nodes ,
682+ context = context ,
683+ enabled = True ,
684+ tracker = graph_tracker ,
685+ )
686+
687+
688+ @pytest .mark .asyncio
689+ async def test_functional_tool_loops_back_when_handoff_tools_present ():
690+ """When a node has both functional tools and handoff tools, calling a functional
691+ tool must loop back to the node so the LLM sees the result — not silently terminate."""
692+ from langchain_core .messages import AIMessage
693+
694+ mock_ld_client = MagicMock ()
695+ graph = _make_multi_child_graph_with_tools (mock_ld_client , tool_names = ['search' ])
696+
697+ # Step 1: orchestrator calls functional tool 'search'
698+ tool_call_response = AIMessage (
699+ content = '' ,
700+ tool_calls = [{'name' : 'search' , 'args' : {'query' : 'topic A' }, 'id' : 'call_search_1' , 'type' : 'tool_call' }],
701+ )
702+ # Step 2: after seeing tool result, orchestrator hands off to agent-a
703+ handoff_response = AIMessage (
704+ content = '' ,
705+ tool_calls = [{'name' : 'transfer_to_agent_a' , 'args' : {}, 'id' : 'call_handoff_1' , 'type' : 'tool_call' }],
706+ )
707+ agent_a_response = _make_fake_response ('Agent A handled it.' )
708+
709+ orchestrator_model = MagicMock ()
710+ orchestrator_model .ainvoke = AsyncMock (side_effect = [tool_call_response , handoff_response ])
711+ orchestrator_model .bind_tools .return_value = orchestrator_model
712+
713+ agent_a_model = _mock_model (agent_a_response )
714+ agent_b_model = _mock_model (_make_fake_response ('Agent B handled it.' ))
715+
716+ def search (query : str = '' ) -> str :
717+ """Search for information."""
718+ return f'results for { query } '
719+
720+ tool_registry = {'search' : search }
721+
722+ def model_factory (node_config , ** kwargs ):
723+ if node_config .key == 'orchestrator' :
724+ return orchestrator_model
725+ if node_config .key == 'agent-a' :
726+ return agent_a_model
727+ return agent_b_model
728+
729+ with patch ('ldai_langchain.langgraph_agent_graph_runner.create_langchain_model' ,
730+ side_effect = model_factory ):
731+ runner = LangGraphAgentGraphRunner (graph , tool_registry )
732+ result = await runner .run ('Find info and route to the right agent.' )
733+
734+ assert result .metrics .success is True
735+ assert 'Agent A' in result .output
736+ # Orchestrator must have been called twice: once before tool result, once after
737+ assert orchestrator_model .ainvoke .call_count == 2
738+ # Agent B must never have been invoked
739+ agent_b_model .ainvoke .assert_not_called ()
0 commit comments