Skip to content

Commit 3377654

Browse files
authored
feat(langgraph): manage standalone ToolNode calls (#902)
#### Overview Add managed NeMo Relay execution for standalone LangGraph `ToolNode` calls. - [x] I confirm this contribution is my own work, or I have the right to submit it under this project's license. - [x] I searched existing issues and open pull requests, and this does not duplicate existing work. #### Details - Add `create_tool_node`, `wrap_tool_call`, and `awrap_tool_call` to the LangGraph integration. - Preserve LangGraph argument injection, parallel execution, error handling, and `Command` results while routing tool calls through Relay. - Add focused integration coverage and standalone ToolNode documentation. #### Where should the reviewer start? Start with `python/nemo_relay/integrations/langgraph/tool_node.py`; the tests demonstrate the sync, async, interception, injection, parallel, command, and error paths. #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) - Relates to: none ## Summary by CodeRabbit * **New Features** * Added managed LangGraph `ToolNode` integration for synchronous and asynchronous tool calls. * Added helpers for creating and wrapping tool nodes with managed execution. * Preserved tool-call metadata, command results, nested tool messages, and graph interruptions. * Added support for tool-error handling and callback instrumentation with `NemoRelayCallbackHandler`. * **Documentation** * Added examples covering tool registration, graph wiring, invocation, callbacks, and direct tool composition. Authors: - Will Killian (https://github.com/willkill07) Approvers: - Eric Evans II (https://github.com/ericevans-nv) URL: #902
1 parent 6d0cb87 commit 3377654

4 files changed

Lines changed: 606 additions & 0 deletions

File tree

docs/supported-integrations/langgraph.mdx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,53 @@ with nemo_relay.scope.scope("langgraph-request", nemo_relay.ScopeType.Agent):
6464
print(result)
6565
```
6666

67+
## Managed ToolNode Calls
68+
69+
For a standalone LangGraph `ToolNode`, construct the node with
70+
`create_tool_node`. It routes each model-requested tool call through Relay's
71+
managed execution pipeline while preserving LangGraph state, store, and runtime
72+
argument injection.
73+
74+
```python
75+
from langchain_core.messages import AIMessage
76+
from langchain_core.tools import tool
77+
from langgraph.graph import END, START, MessagesState, StateGraph
78+
from nemo_relay.integrations.langgraph import (
79+
NemoRelayCallbackHandler,
80+
create_tool_node,
81+
)
82+
83+
@tool
84+
def get_weather(location: str) -> str:
85+
"""Return the weather for a location."""
86+
return f"Sunny in {location}"
87+
88+
builder = StateGraph(MessagesState)
89+
builder.add_node("tools", create_tool_node([get_weather]))
90+
builder.add_edge(START, "tools")
91+
builder.add_edge("tools", END)
92+
graph = builder.compile()
93+
94+
with nemo_relay.scope.scope("langgraph-request", nemo_relay.ScopeType.Agent):
95+
result = graph.invoke(
96+
{
97+
"messages": [
98+
AIMessage(
99+
content="",
100+
tool_calls=[{"name": "get_weather", "args": {"location": "Boston"}, "id": "call-1"}],
101+
)
102+
]
103+
},
104+
config={"callbacks": [NemoRelayCallbackHandler()]},
105+
)
106+
```
107+
108+
`NemoRelayCallbackHandler` records the graph lifecycle and provides its scopes.
109+
`create_tool_node` provides managed tool execution; use both for complete
110+
standalone LangGraph instrumentation. For custom ToolNode wrapper composition,
111+
construct `ToolNode` directly and pass `wrap_tool_call` and `awrap_tool_call`
112+
from this integration.
113+
67114
For LangChain agents inside a LangGraph workflow, use `NemoRelayMiddleware` from
68115
this package the same way as the LangChain integration and pass the LangGraph
69116
`config` into the nested agent call:

python/nemo_relay/integrations/langgraph/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55

66
from nemo_relay.integrations.langchain import NemoRelayMiddleware
77
from nemo_relay.integrations.langgraph.callbacks import NemoRelayCallbackHandler
8+
from nemo_relay.integrations.langgraph.tool_node import awrap_tool_call, create_tool_node, wrap_tool_call
89

910
__all__ = [
1011
"NemoRelayCallbackHandler",
1112
"NemoRelayMiddleware",
13+
"awrap_tool_call",
14+
"create_tool_node",
15+
"wrap_tool_call",
1216
]
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Managed NeMo Relay wrappers for standalone LangGraph ``ToolNode`` objects."""
5+
6+
from __future__ import annotations
7+
8+
from collections.abc import Awaitable, Callable, Sequence
9+
from dataclasses import replace
10+
from typing import Any, cast
11+
12+
from langchain_core.messages import ToolMessage
13+
from langchain_core.tools import BaseTool
14+
from langgraph.errors import GraphBubbleUp
15+
from langgraph.prebuilt import ToolNode
16+
from langgraph.prebuilt.tool_node import ToolCallRequest, ToolInvocationError
17+
from langgraph.types import Command
18+
19+
import nemo_relay
20+
from nemo_relay.typed import Codec
21+
from nemo_relay.utils import run_sync
22+
23+
24+
class _ToolNodeResultCodec(nemo_relay.typed.BestEffortAnyCodec):
25+
"""Restore ToolMessages nested in LangGraph Command updates."""
26+
27+
_LIST_TAG = "__nemo_relay_langgraph_list__"
28+
29+
def to_json(self, value: object) -> nemo_relay.Json:
30+
if isinstance(value, list):
31+
return {self._LIST_TAG: [self.to_json(item) for item in value]}
32+
return super().to_json(value)
33+
34+
def from_json(self, data: nemo_relay.Json) -> object:
35+
if isinstance(data, dict) and isinstance(data.get(self._LIST_TAG), list):
36+
return [self.from_json(item) for item in data[self._LIST_TAG]]
37+
38+
result = super().from_json(data)
39+
if not isinstance(result, Command):
40+
return result
41+
42+
if isinstance(result.update, dict):
43+
messages = result.update.get("messages")
44+
if isinstance(messages, list):
45+
result.update["messages"] = _restore_tool_messages(messages)
46+
elif isinstance(result.update, list):
47+
return replace(result, update=_restore_tool_messages(result.update))
48+
return result
49+
50+
51+
def _restore_tool_messages(messages: list[object]) -> list[object]:
52+
"""Reconstruct serialized ToolMessages in a LangGraph command update."""
53+
return [ToolMessage.model_validate(message) if isinstance(message, dict) else message for message in messages]
54+
55+
56+
_DEFAULT_HANDLE_TOOL_ERRORS = object()
57+
_TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
58+
_GRAPH_BUBBLE_RESULT = {"__nemo_relay_langgraph_graph_bubble_up__": True}
59+
60+
61+
def _handle_tool_error(error: Exception, policy: object) -> str:
62+
"""Preserve ToolNode error handling while allowing graph bubbles to escape."""
63+
if isinstance(error, GraphBubbleUp):
64+
raise error
65+
if policy is _DEFAULT_HANDLE_TOOL_ERRORS:
66+
if isinstance(error, ToolInvocationError):
67+
return error.message
68+
raise error
69+
if isinstance(policy, tuple):
70+
if not isinstance(error, policy):
71+
raise error
72+
return _TOOL_CALL_ERROR_TEMPLATE.format(error=repr(error))
73+
if isinstance(policy, type) and issubclass(policy, Exception):
74+
if not isinstance(error, policy):
75+
raise error
76+
return _TOOL_CALL_ERROR_TEMPLATE.format(error=repr(error))
77+
if policy is True:
78+
return _TOOL_CALL_ERROR_TEMPLATE.format(error=repr(error))
79+
if isinstance(policy, str):
80+
return policy
81+
if callable(policy):
82+
return cast(Callable[[Exception], str], policy)(error)
83+
raise ValueError(f"unexpected handle_tool_errors value: {policy}")
84+
85+
86+
def _tool_details(request: ToolCallRequest) -> tuple[nemo_relay.ScopeHandle, str, dict[str, Any], str | None]:
87+
"""Extract the model-controlled tool-call fields managed by Relay."""
88+
return (
89+
nemo_relay.scope.get_handle(),
90+
request.tool_call["name"],
91+
request.tool_call.get("args") or {},
92+
request.tool_call.get("id"),
93+
)
94+
95+
96+
def wrap_tool_call(
97+
request: ToolCallRequest,
98+
execute: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
99+
) -> ToolMessage | Command[Any]:
100+
"""Run one synchronous LangGraph tool call through NeMo Relay.
101+
102+
Args:
103+
request: LangGraph's tool-call request.
104+
execute: LangGraph callback that invokes the requested tool.
105+
106+
Returns:
107+
The LangGraph tool result after managed Relay execution.
108+
"""
109+
parent, tool_name, tool_args, tool_call_id = _tool_details(request)
110+
args_codec = cast(Codec[dict[str, Any]], nemo_relay.typed.BestEffortAnyCodec())
111+
result_codec = cast(Codec[ToolMessage | Command[Any] | dict[str, bool]], _ToolNodeResultCodec())
112+
graph_bubble: GraphBubbleUp | None = None
113+
114+
def _call(args: dict[str, Any]) -> nemo_relay.ToolExecutionResult[ToolMessage | Command[Any] | dict[str, bool]]:
115+
nonlocal graph_bubble
116+
try:
117+
result = execute(request.override(tool_call={**request.tool_call, "args": args}))
118+
except GraphBubbleUp as error:
119+
# Relay's native callback boundary cannot propagate arbitrary Python
120+
# exceptions. Preserve the original graph bubble locally and re-raise
121+
# it after managed execution returns.
122+
graph_bubble = error
123+
result = _GRAPH_BUBBLE_RESULT
124+
return nemo_relay.ToolExecutionResult(result)
125+
126+
outcome = run_sync(
127+
nemo_relay.typed.tool_execute(
128+
name=tool_name,
129+
args=tool_args,
130+
func=_call,
131+
args_codec=args_codec,
132+
result_codec=result_codec,
133+
handle=parent,
134+
tool_call_id=tool_call_id,
135+
)
136+
)
137+
if graph_bubble is not None:
138+
raise graph_bubble
139+
return cast(ToolMessage | Command[Any], outcome.result)
140+
141+
142+
async def awrap_tool_call(
143+
request: ToolCallRequest,
144+
execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
145+
) -> ToolMessage | Command[Any]:
146+
"""Run one asynchronous LangGraph tool call through NeMo Relay.
147+
148+
Args:
149+
request: LangGraph's tool-call request.
150+
execute: Async LangGraph callback that invokes the requested tool.
151+
152+
Returns:
153+
The LangGraph tool result after managed Relay execution.
154+
"""
155+
parent, tool_name, tool_args, tool_call_id = _tool_details(request)
156+
args_codec = cast(Codec[dict[str, Any]], nemo_relay.typed.BestEffortAnyCodec())
157+
result_codec = cast(Codec[ToolMessage | Command[Any] | dict[str, bool]], _ToolNodeResultCodec())
158+
graph_bubble: GraphBubbleUp | None = None
159+
160+
async def _call(
161+
args: dict[str, Any],
162+
) -> nemo_relay.ToolExecutionResult[ToolMessage | Command[Any] | dict[str, bool]]:
163+
nonlocal graph_bubble
164+
try:
165+
result = await execute(request.override(tool_call={**request.tool_call, "args": args}))
166+
except GraphBubbleUp as error:
167+
# See the synchronous wrapper: retain the original exception instead
168+
# of letting the native callback boundary convert it to RuntimeError.
169+
graph_bubble = error
170+
result = _GRAPH_BUBBLE_RESULT
171+
return nemo_relay.ToolExecutionResult(result)
172+
173+
outcome = await nemo_relay.typed.tool_execute(
174+
name=tool_name,
175+
args=tool_args,
176+
func=_call,
177+
args_codec=args_codec,
178+
result_codec=result_codec,
179+
handle=parent,
180+
tool_call_id=tool_call_id,
181+
)
182+
if graph_bubble is not None:
183+
raise graph_bubble
184+
return cast(ToolMessage | Command[Any], outcome.result)
185+
186+
187+
def create_tool_node(
188+
tools: Sequence[BaseTool | Callable[..., Any]],
189+
**tool_node_kwargs: Any,
190+
) -> ToolNode:
191+
"""Create a LangGraph ``ToolNode`` whose tool calls use managed Relay execution.
192+
193+
For custom LangGraph wrapper composition, construct ``ToolNode`` directly
194+
and pass :func:`wrap_tool_call` and :func:`awrap_tool_call` explicitly.
195+
196+
Args:
197+
tools: LangGraph tools available to the returned node.
198+
**tool_node_kwargs: Remaining native ``ToolNode`` constructor options,
199+
excluding the Relay-managed wrapper options.
200+
201+
Returns:
202+
A native ``ToolNode`` configured with Relay's sync and async wrappers.
203+
"""
204+
configured_wrappers = {"wrap_tool_call", "awrap_tool_call"}.intersection(tool_node_kwargs)
205+
if configured_wrappers:
206+
names = ", ".join(sorted(configured_wrappers))
207+
raise ValueError(f"create_tool_node configures {names}; construct ToolNode directly to compose custom wrappers")
208+
error_policy = tool_node_kwargs.pop("handle_tool_errors", _DEFAULT_HANDLE_TOOL_ERRORS)
209+
if error_policy is False:
210+
error_handler: bool | Callable[[Exception], str] = False
211+
else:
212+
213+
def error_handler(error: Exception) -> str:
214+
return _handle_tool_error(error, error_policy)
215+
216+
return ToolNode(
217+
tools,
218+
wrap_tool_call=wrap_tool_call,
219+
awrap_tool_call=awrap_tool_call,
220+
handle_tool_errors=error_handler,
221+
**tool_node_kwargs,
222+
)
223+
224+
225+
__all__ = ["awrap_tool_call", "create_tool_node", "wrap_tool_call"]

0 commit comments

Comments
 (0)