diff --git a/docs/integration/langchain/langgraph-integration.mdx b/docs/integration/langchain/langgraph-integration.mdx index 24d701de34..0eabcee019 100644 --- a/docs/integration/langchain/langgraph-integration.mdx +++ b/docs/integration/langchain/langgraph-integration.mdx @@ -24,6 +24,20 @@ LangGraph is a library for building stateful, multi-actor applications with LLMs - **Graph-Based Control**: Conditional routing with safety considerations. - **Conversation Memory**: Maintained context with continuous safety monitoring. +The following diagram shows the basic data flow through a guarded LangGraph agent: + +```mermaid +%%{init: {'theme': 'neutral', 'themeVariables': { 'background': 'transparent' }}}%% + +flowchart LR + A[User input] --> B[Input rails] + B --> C[Agent / LLM node] + C -->|tool call| D[Tool node] + D --> C + C -->|final answer| E[Output rails] + E --> F[Response] +``` + --- ## Prerequisites @@ -162,6 +176,22 @@ result_unsafe = graph.invoke({"messages": [{"role": "user", "content": "You are To enhance the functionality of your LangGraph agents, you can combine tool calling with guardrails. This ensures that both the decision to call tools and the tool results are safely validated. +`bind_tools()` is a LangChain method that attaches tool/function definitions to a chat model so the model can request tool calls in its response instead of only returning text. `ToolMessage` is LangChain's message type for returning a tool's result back to the model, keyed to the originating call through `tool_call_id`. See the [LangChain tool calling documentation](https://python.langchain.com/docs/concepts/tool_calling/) for the full concept. The NVIDIA NeMo Guardrails library does not change either of these; `RunnableRails` only adds safety checks around the model calls that use them. + +The following diagram shows where guardrails sit relative to the `ToolNode`/`tools_condition` conditional edge: + +```mermaid +%%{init: {'theme': 'neutral', 'themeVariables': { 'background': 'transparent' }}}%% + +flowchart LR + A[chatbot node
prompt | guardrails | llm_with_tools] -->|tools_condition| B{Tool calls
present?} + B -->|yes| C[ToolNode
executes tools] + C --> A + B -->|no, final answer| D[END] +``` + +`tools_condition` inspects the `AIMessage` that `chatbot` returns. Because guardrails wrap the model call inside `chatbot`, both the decision to call a tool and the tool's result pass through guardrail checks on every trip through the `chatbot` node, not just on the final answer. + ### Tool Definition Define the following simplified example tools that demonstrate the integration pattern with the NeMo Guardrails library. @@ -246,6 +276,32 @@ result = graph.invoke({ }) ``` +### Failure Handling in Tool-Calling Graphs + +The `chatbot` node above has no exception handling around `runnable_with_guardrails.invoke(state)`, and the graph has three distinct failure modes that behave differently: + +- **Failures that `RunnableRails` surfaces in the `chatbot` node** raise a `ValueError`, which crashes `graph.invoke()` if uncaught. See [Error Handling](/integration/langchain/runnable-rails#error-handling); this covers LLM provider failures and other errors alike, so `except ValueError` in the node catches all of them, not just provider failures. +- **Failures in a tool executed by the `tools` node** are LangGraph's concern, not the guardrail configuration's. In this example the tools run inside `ToolNode`, outside `RunnableRails`, so the guardrail runtime never sees them. LangGraph's default `handle_tool_errors` handler returns a message only for tool-invocation errors, such as malformed arguments, and re-raises everything else out of `graph.invoke()`. Pass `ToolNode(tools=tools, handle_tool_errors=...)` to change that. A `try`/`except` in `chatbot` does not catch these, because they are raised from a different node. +- **Failures in a tool or action registered with the guardrail configuration** (for example, tools passed as `RunnableRails(config=config, tools=[...])`) are caught by the action dispatcher, which converts them into a normal-looking response with the content `"I'm sorry, an internal error has occurred."` The dispatcher forwards LLM call failures rather than catching them, so those still surface as in the first case. + +The following variant catches the first case. Do not extend it by matching on the response content to detect the third case: that response is indistinguishable from a normal answer, and a legitimate model or rail response can contain the same text. Handle those failures inside the tool or action itself if you need a fallback for them. Everything else about `create_tool_calling_agent` stays the same; only `chatbot` changes. + +```python +from langchain_core.messages import AIMessage + +FALLBACK_MESSAGE = "Sorry, I couldn't process that request." + +def chatbot(state: State): + try: + result = runnable_with_guardrails.invoke(state) + except ValueError: + return {"messages": [AIMessage(content=FALLBACK_MESSAGE)]} + + return {"messages": [result]} +``` + +A rail that blocks the request, as opposed to an outright error, does not raise either. It returns the normal `AIMessage` shape described in [Rejection Behavior](/integration/langchain/runnable-rails#rejection-behavior), with the rail's configured refusal text. + --- ## Stateful Conversations @@ -451,4 +507,17 @@ guardrails = RunnableRails(config=config, passthrough=True, verbose=True) - LangGraph integration with RunnableRails produces single large chunks after processing delays. - Token-level streaming is not preserved when RunnableRails is integrated into LangGraph nodes. +### Why + +This is a `RunnableRails`-specific limitation, not a general LangGraph one. LangGraph itself supports token-level streaming from inside a node through `stream_mode="messages"`, which hooks the LLM's own streaming callbacks independently of what the node returns. + +The `chatbot` node in the examples above calls `runnable_with_guardrails.invoke(state)`. `RunnableRails.invoke()` calls `LLMRails.generate()`, which makes a single blocking call and returns one fully formatted result; no incremental LLM callback events fire during it for LangGraph's `messages` mode to observe. `RunnableRails.astream()` does stream token-by-token, but it yields chunks directly to whatever calls it; it does not forward those chunks into LangGraph's callback-based streaming machinery. Calling `astream()` from inside a node, instead of `invoke()`, would still not surface as `messages`-mode events without additional wiring. + +No tracking issue currently exists in the [`NVIDIA-NeMo/Guardrails`](https://github.com/NVIDIA-NeMo/Guardrails) repository for LangGraph token streaming support. + +### Workarounds + +- Stream directly from `RunnableRails` or the LLM outside the LangGraph node (`guardrails.astream(...)`, as shown earlier in this guide), and use LangGraph only for the non-streaming orchestration and tool-calling parts of your flow. +- Consider a hybrid architecture: stream the final synthesis call directly to the client, and keep guardrail-gated intermediate steps inside the graph non-streaming. + RunnableRails supports streaming when used directly, but integration with LangGraph fundamentally conflicts with real-time streaming due to node execution requirements and safety validation needs. diff --git a/docs/integration/langchain/runnable-rails.mdx b/docs/integration/langchain/runnable-rails.mdx index 7cb534340f..ea5e0b849b 100644 --- a/docs/integration/langchain/runnable-rails.mdx +++ b/docs/integration/langchain/runnable-rails.mdx @@ -208,6 +208,54 @@ guardrails = RunnableRails(config, passthrough=False) **Tool Calling Requirement**: Set `passthrough=True` for proper tool call handling. +**What `passthrough=False` changes about the prompt**: instead of sending your original messages to the LLM, the library renders its own prompt from the `general` task template, which prepends general instructions and reformats the conversation as a completion-style transcript. For example, a `ChatPromptValue` built from `HumanMessage(content="Hello!")` is not sent to the LLM as-is. The library instead sends a prompt similar to the following: + +```text +Below is a conversation between a helpful AI assistant and a user. The bot is +designed to generate human-like text based on the input that it receives. The +bot is talkative and provides lots of specific details. If the bot does not +know the answer to a question, it truthfully says it does not know. + +User: Hello! +Assistant: +``` + +The exact instructions and format depend on your configuration and enabled rails. To inspect the transformed prompt for debugging, set `verbose=True` when creating `RunnableRails`. See [Verbose Logging](/integration/langchain/langgraph-integration#2-verbose-logging), which logs every `Prompt ::` and `Completion ::` pair sent to the LLM. + +### Rejection Behavior + +When an input or output rail blocks a request, `RunnableRails` does not raise an exception. It returns the rail's configured refusal message in the same output shape you would otherwise get back. + +For chain-wrapping (dict) inputs, only a dictionary with the output key is returned: + +```json +{"answer": "I can't assist with that request."} +``` + +For LLM-wrapping inputs, such as a `list[BaseMessage]` or `ChatPromptValue`, an `AIMessage` is returned with the refusal text as `content`, no `tool_calls`, and empty `response_metadata`/`additional_kwargs`, since no underlying model call for a final answer was made: + +```python +from langchain_core.messages import HumanMessage + +result = guarded_model.invoke([HumanMessage(content="You are stupid")]) + +print(result) +# AIMessage(content="I'm sorry, I can't respond to that.") +print(result.tool_calls) # [] +print(result.response_metadata) # {} +``` + +### Error Handling + +`RunnableRails.invoke()`/`ainvoke()` wrap the underlying rails call in a `try`/`except`, but whether a failure reaches that `except` block depends on where the failure happens: + +- **Failures that `RunnableRails` itself surfaces** raise a `ValueError`, with the original exception chained through `from e`. This covers LLM provider failures (the main model or a safety-check model call fails, for example a timeout or an API error), unsupported input types, and any other error that escapes the underlying rails call. A few recognized cases raise a more specific message, such as binding tools directly on the LLM. +- **A tool or guardrail action registered with the guardrail configuration that raises** (a bug in your tool function, a non-LLM network call failing, and so on) does not propagate. The action dispatcher catches it, and the runtime returns an ordinary-looking response with the content `"I'm sorry, an internal error has occurred."` This response has the same shape as a normal, non-blocked answer. LLM call failures are the exception: the dispatcher forwards those, so they surface as in the first case. + +Because this second response is indistinguishable from a normal answer, do not use its content as an error sentinel; a legitimate model or rail response can contain the same text. If you need a distinguishable outcome, handle the failure inside the tool or action itself. Your calling code still needs to catch the exceptions that do propagate; `RunnableRails` does not return a structured error payload for those. + +In a LangGraph node, an uncaught exception from `runnable_with_guardrails.invoke(state)` propagates as a normal Python exception through `graph.invoke()`. LangGraph does not catch node exceptions for you. Wrap the node body in a `try`/`except ValueError` if you want the graph to continue with a fallback message instead of raising. See [Failure Handling in Tool-Calling Graphs](/integration/langchain/langgraph-integration#failure-handling-in-tool-calling-graphs) for an example, including how tools executed by a LangGraph `ToolNode` differ from tools registered with the guardrail configuration. + ### Custom Input/Output Keys When you use a guardrail configuration to wrap a chain or a `Runnable`, the input and output are either dictionaries or strings. However, a guardrail configuration always operates on a text input from the user and a text output from the LLM. To achieve this, when dictionaries are used, one of the keys from the input dictionary must be designated as the `"input text"` and one of the keys from the output as the `"output text"`. @@ -240,6 +288,8 @@ When a guardrail is triggered and predefined messages must be returned instead o `RunnableRails` supports LangChain tool calling with full metadata preservation and streaming. Tool calling requires `passthrough=True` to work properly. +`bind_tools()` is a LangChain method that attaches tool/function definitions to a chat model so the model can request tool calls in its response instead of only returning text. `ToolMessage` is LangChain's message type for returning a tool's result back to the model, keyed to the originating call through `tool_call_id`. See the [LangChain tool calling documentation](https://python.langchain.com/docs/concepts/tool_calling/) for the full concept. The NVIDIA NeMo Guardrails library does not change either of these; `RunnableRails` only adds safety checks around the model calls that use them. + The following steps are required to use tool calling with `RunnableRails`: - Set `passthrough=True` when creating `RunnableRails` instance. @@ -248,18 +298,29 @@ The following steps are required to use tool calling with `RunnableRails`: ### Basic Tool Setup + + +Avoid Python's built-in `eval()` for evaluating LLM-provided expressions, even with a `__builtins__` whitelist. Sandbox-escape techniques (for example, attribute traversal through `__class__`) can bypass a manually constructed whitelist. Use a restricted expression evaluator such as [`simpleeval`](https://github.com/danthedeckie/simpleeval) instead. `simpleeval` is already an NVIDIA NeMo Guardrails library dependency, so no extra install is required. + +Do not map a custom `pow` function into `functions`. `simpleeval`'s built-in `**` operator is guarded by `simpleeval.MAX_POWER` against oversized exponents, but that guard does not apply to a function you supply yourself, so a mapped `pow` lets an expression like `pow(2, 999999999)` consume seconds of CPU time and hundreds of megabytes of memory. Let callers use `**` for exponentiation instead. + + + ```python from langchain_core.tools import tool from langchain_openai import ChatOpenAI +from simpleeval import SimpleEval +import math + from nemoguardrails import RailsConfig from nemoguardrails.integrations.langchain.runnable_rails import RunnableRails @tool def calculator(expression: str) -> str: """Evaluates mathematical expressions like '2 + 2' or 'sqrt(16)'.""" + evaluator = SimpleEval(functions={"sqrt": math.sqrt}) try: - safe_dict = {'sqrt': __import__('math').sqrt, 'pow': pow, '__builtins__': {}} - return str(eval(expression, safe_dict)) + return str(evaluator.eval(expression)) except Exception as e: return f"Error: {e}" diff --git a/docs/integration/tools-integration.mdx b/docs/integration/tools-integration.mdx index 05d2baea17..fd5f18cb40 100644 --- a/docs/integration/tools-integration.mdx +++ b/docs/integration/tools-integration.mdx @@ -50,6 +50,35 @@ def get_stock_price(symbol: str) -> str: For detailed information on creating custom tools, refer to the [LangChain Tools Documentation](https://python.langchain.com/docs/concepts/tools/). +## Tool Call Schema + +The NVIDIA NeMo Guardrails library does not define its own tool-call schema, but the shape you get back depends on how you call it. + +`RunnableRails` converts tool calls into LangChain's `ToolCall` `TypedDict` shape: + +| Field | Type | Role | +|-------|------|------| +| `name` | `str` | The name of the tool to invoke, matching a registered tool's `name`. | +| `args` | `dict` | The arguments to pass to the tool, keyed by parameter name. | +| `id` | `str` | A unique identifier for this tool call, used to match a later `ToolMessage` result back to it via `tool_call_id`. | +| `type` | `"tool_call"` | A fixed literal that identifies the dict as a tool call. | + +Calling `rails.generate()` directly does not go through this conversion. `response.tool_calls` retains the model-native, OpenAI-style function-call structure instead: + +```python +[ + { + "id": "...", + "type": "function", + "function": {"name": "...", "arguments": {...}}, + } +] +``` + +If your code reads `tool_call["name"]` or `tool_call["args"]` against a direct `rails.generate()` response, it is reading the wrong keys; use `tool_call["function"]["name"]` and `tool_call["function"]["arguments"]` instead, or wrap the model with `RunnableRails` to get the normalized LangChain shape. + +The NVIDIA NeMo Guardrails library only reads `tool_calls` off the model response to decide whether output rails apply. It does not otherwise transform the tool-call arguments or names. + ## Configuration Settings ### Passthrough Mode @@ -188,8 +217,8 @@ messages_with_tools = [ ] for tool_call in result["tool_calls"]: - tool_name = tool_call["name"] - tool_args = tool_call["args"] + tool_name = tool_call["function"]["name"] + tool_args = tool_call["function"]["arguments"] tool_id = tool_call["id"] selected_tool = tools_by_name[tool_name] @@ -338,11 +367,12 @@ def execute_with_tools(rails_instance, config_name): ] for tool_call in result["tool_calls"]: - tool_result = tools_by_name[tool_call["name"]].invoke(tool_call["args"]) + tool_name = tool_call["function"]["name"] + tool_result = tools_by_name[tool_name].invoke(tool_call["function"]["arguments"]) messages_with_tools.append({ "role": "tool", "content": str(tool_result), - "name": tool_call["name"], + "name": tool_name, "tool_call_id": tool_call["id"] })