11"""LangGraph agent graph runner for LaunchDarkly AI SDK."""
22
3- import operator
43import time
54from typing import Annotated , Any , Dict , List , Tuple
65
@@ -25,6 +24,40 @@ def _tool_call_id_from_entry(tc: Any) -> Any:
2524 return getattr (tc , 'id' , None )
2625
2726
27+ def _make_handoff_tool (child_key : str , description : str ) -> Any :
28+ """
29+ Create a tool that transfers control to ``child_key``.
30+
31+ Uses the ``@tool`` decorator with ``InjectedState`` + ``InjectedToolCallId``
32+ so LangGraph's ToolNode handles the ``Command`` return value correctly.
33+ The tool explicitly creates a ToolMessage in ``Command.update`` to satisfy
34+ the LangChain/OpenAI message-chain contract.
35+ """
36+ from typing import Annotated as _Annotated
37+
38+ from langchain_core .messages import ToolMessage
39+ from langchain_core .tools import tool
40+ from langchain_core .tools .base import InjectedToolCallId
41+ from langgraph .prebuilt import InjectedState
42+ from langgraph .types import Command
43+
44+ tool_name = f"transfer_to_{ child_key .replace ('-' , '_' )} "
45+
46+ @tool (tool_name , description = description )
47+ def handoff (
48+ state : _Annotated [Any , InjectedState ], # noqa: ARG001
49+ tool_call_id : _Annotated [str , InjectedToolCallId ],
50+ ) -> Command :
51+ tool_message = ToolMessage (
52+ content = f'Transferred to { child_key } ' ,
53+ name = tool_name ,
54+ tool_call_id = tool_call_id ,
55+ )
56+ return Command (goto = child_key , update = {'messages' : [tool_message ]})
57+
58+ return handoff
59+
60+
2861def _coalesce_tool_messages_for_openai (msgs : List [Any ]) -> List [Any ]:
2962 """
3063 Rewind shared LangGraph message state into OpenAI's required shape.
@@ -168,11 +201,12 @@ def _build_graph(self) -> Tuple[Any, Dict[str, str]]:
168201 """
169202 from langchain_core .messages import SystemMessage
170203 from langgraph .graph import END , START , StateGraph
204+ from langgraph .graph .message import add_messages
171205 from langgraph .prebuilt import ToolNode , tools_condition
172206 from typing_extensions import TypedDict
173207
174208 class WorkflowState (TypedDict ):
175- messages : Annotated [List [Any ], operator . add ]
209+ messages : Annotated [List [Any ], add_messages ]
176210
177211 agent_builder : StateGraph = StateGraph (WorkflowState )
178212 root_node = self ._graph .root ()
@@ -184,22 +218,16 @@ class WorkflowState(TypedDict):
184218 def handle_traversal (node : AgentGraphNode , ctx : dict ) -> None :
185219 node_config = node .get_config ()
186220 node_key = node .get_key ()
221+ instructions = node_config .instructions if hasattr (node_config , 'instructions' ) else None
222+ outgoing_edges = node .get_edges ()
187223
224+ lc_model = None
188225 tool_fns : list = []
189- model = None
190- instructions = node_config .instructions if hasattr (node_config , 'instructions' ) else None
191226 if node_config .model :
192227 # We send an empty tool registry to avoid binding tools to the model.
193228 lc_model = create_langchain_model (node_config , tool_registry = None )
194229
195- # Retrieve tool definitions to build fn_name_to_config_key map
196- config_dict = node_config .to_dict ()
197- model_dict = config_dict .get ('model' ) or {}
198- parameters = dict (model_dict .get ('parameters' ) or {})
199- tool_defs = parameters .get ('tools' , []) or []
200-
201230 tool_fns = build_structured_tools (node_config , tools_ref )
202- model = lc_model .bind_tools (tool_fns ) if tool_fns else lc_model
203231
204232 # Map tool name -> LD config key for callback attribution.
205233 # build_structured_tools returns StructuredTool instances with tool.name set
@@ -209,6 +237,33 @@ def handle_traversal(node: AgentGraphNode, ctx: dict) -> None:
209237 if tool_name :
210238 fn_name_to_config_key [tool_name ] = tool_name
211239
240+ # For nodes with multiple children, create a handoff tool per child so the
241+ # LLM decides which agent to route to. Uses Command(goto=child_key) so
242+ # LangGraph routes to the target without looping back here.
243+ handoff_fns : list = []
244+ if lc_model and len (outgoing_edges ) > 1 :
245+ for edge in outgoing_edges :
246+ child_node = self ._graph .get_node (edge .target_config )
247+ description = (
248+ (edge .handoff or {}).get ('description' )
249+ or (
250+ child_node .get_config ().instructions [:120 ]
251+ if child_node and child_node .get_config ().instructions
252+ else None
253+ )
254+ or f"Transfer control to { edge .target_config } "
255+ )
256+ handoff_fns .append (_make_handoff_tool (edge .target_config , description ))
257+
258+ all_tools = tool_fns + handoff_fns
259+ if lc_model and all_tools :
260+ # When handoff tools are present, disable parallel tool calls so the LLM
261+ # picks exactly one destination rather than routing to multiple children.
262+ bind_kwargs = {'parallel_tool_calls' : False } if handoff_fns else {}
263+ model = lc_model .bind_tools (all_tools , ** bind_kwargs )
264+ else :
265+ model = lc_model
266+
212267 def make_node_fn (bound_model : Any , node_instructions : Any , nk : str ):
213268 async def invoke (state : WorkflowState ) -> dict :
214269 if not bound_model :
@@ -234,30 +289,45 @@ async def invoke(state: WorkflowState) -> dict:
234289 if node_key == root_key :
235290 agent_builder .add_edge (START , node_key )
236291
237- outgoing_edges = node .get_edges ()
238-
239292 # Collect node info for graph structure log
240293 tool_names = [str (getattr (t , 'name' , None ) or getattr (t , '__name__' , t )) for t in tool_fns ]
241294 edge_targets = [e .target_config for e in outgoing_edges ]
242295 node_desc = node_key
243296 if tool_names :
244297 node_desc += f"[tools:{ ',' .join (tool_names )} ]"
245- node_desc += f"→{ ',' .join (edge_targets )} " if edge_targets else "(terminal)"
298+ if handoff_fns :
299+ node_desc += f"[handoff:{ ',' .join (edge_targets )} ]"
300+ elif edge_targets :
301+ node_desc += f"→{ ',' .join (edge_targets )} "
302+ else :
303+ node_desc += "(terminal)"
246304 graph_structure .append (node_desc )
247305
248- if tool_fns :
249- # Pair this node with a ToolNode and loop it back (standard LangGraph pattern).
250- # tools_condition routes to " tools" when the response has tool calls,
251- # and to END otherwise; the path_map redirects those to our named nodes .
306+ if all_tools :
307+ # ToolNode handles Command returns from handoff tools, routing to the target
308+ # node. For functional tools it returns normal ToolMessages and we loop back.
309+ # tools_condition exits to END when no tool is called .
252310 tools_node_key = f"{ node_key } __tools"
253- after_loop = outgoing_edges [0 ].target_config if outgoing_edges else END
254- agent_builder .add_node (tools_node_key , ToolNode (tool_fns ))
255- agent_builder .add_edge (tools_node_key , node_key )
256- agent_builder .add_conditional_edges (
257- node_key ,
258- tools_condition ,
259- {"tools" : tools_node_key , END : after_loop },
260- )
311+ agent_builder .add_node (tools_node_key , ToolNode (all_tools ))
312+
313+ if not handoff_fns :
314+ # No handoff tools: standard loop-back after tool execution.
315+ after_loop = outgoing_edges [0 ].target_config if outgoing_edges else END
316+ agent_builder .add_edge (tools_node_key , node_key )
317+ agent_builder .add_conditional_edges (
318+ node_key ,
319+ tools_condition ,
320+ {"tools" : tools_node_key , END : after_loop },
321+ )
322+ else :
323+ # Handoff tools use Command(goto=child_key) — LangGraph routes to the
324+ # target directly without any extra edge. The ToolNode does NOT loop
325+ # back here. tools_condition exits to END when no tool is called.
326+ agent_builder .add_conditional_edges (
327+ node_key ,
328+ tools_condition ,
329+ {"tools" : tools_node_key , END : END },
330+ )
261331 else :
262332 if node .is_terminal ():
263333 agent_builder .add_edge (node_key , END )
@@ -276,14 +346,6 @@ async def invoke(state: WorkflowState) -> dict:
276346 )
277347
278348 compiled = agent_builder .compile ()
279- # try:
280- # image_data = compiled.get_graph().draw_mermaid_png()
281- # out_path = f"{graph_key_str}_langgraph.png"
282- # with open(out_path, mode='wb') as f:
283- # f.write(image_data)
284- # except Exception as exc:
285- # log.debug('LangGraphAgentGraphRunner: could not write graph PNG (%s)', exc)
286-
287349 return compiled , fn_name_to_config_key
288350
289351 async def run (self , input : Any ) -> AgentGraphResult :
@@ -310,7 +372,7 @@ async def run(self, input: Any) -> AgentGraphResult:
310372
311373 result = await compiled .ainvoke ( # type: ignore[call-overload]
312374 {'messages' : [HumanMessage (content = str (input ))]},
313- config = {'callbacks' : [handler ]},
375+ config = {'callbacks' : [handler ], 'recursion_limit' : 25 },
314376 )
315377
316378 duration = (time .perf_counter_ns () - start_ns ) // 1_000_000
0 commit comments