Skip to content

Commit f0cccd1

Browse files
committed
fix tool edge back to agent
1 parent 9240394 commit f0cccd1

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

packages/ai-providers/server-ai-langchain/src/ldai_langchain/langgraph_agent_graph_runner.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,10 @@ async def invoke(state: WorkflowState) -> dict:
201201
)
202202
else:
203203
# Handoff tools use Command(goto=child_key) — LangGraph routes to the
204-
# target directly without any extra edge. The ToolNode does NOT loop
205-
# back here. tools_condition exits to END when no tool is called.
204+
# target directly without any extra edge. Functional tools (if any)
205+
# return normal ToolMessages and must loop back so the LLM sees the result.
206+
if tool_fns:
207+
agent_builder.add_edge(tools_node_key, node_key)
206208
agent_builder.add_conditional_edges(
207209
node_key,
208210
tools_condition,

packages/ai-providers/server-ai-langchain/tests/test_tracking_langgraph.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)