Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ just build-all
--input "Reply with exactly: NeMo Fabric works"
```

## LangGraph examples

[`langgraph`](langgraph/README.md) contains native LangGraph calculator MCP and
email phishing analyzer examples. They establish the factory, model, MCP, state,
and structured-output boundaries that a reusable NeMo Fabric LangGraph adapter
must implement.

## Harbor

[`harbor`](harbor/README.md) demonstrates how to evaluate NeMo Fabric agents with
Expand Down
107 changes: 107 additions & 0 deletions examples/langgraph/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# LangGraph Examples

This directory contains two native LangGraph examples:

- `calculator_mcp.py` implements a per-user ReAct agent with a local
`current_timezone` tool and a per-user streamable-HTTP MCP calculator client.
- `email_phishing_analyzer.py` implements a purpose-built state graph that returns
a structured phishing assessment.

They use `meta/llama-3.1-70b-instruct` through the OpenAI-compatible NIM endpoint.
NVIDIA NeMo Fabric does not yet ship a LangGraph adapter. The YAML files are
validated by the examples and intentionally use `langgraph:` entry points; they
are not accepted by `FabricConfig` yet.

## Set Up the Python Environment

From the repository root, install the adapter and test dependency groups that
provide LangGraph, LangChain MCP adapters, and the local MCP server:

```bash
uv sync --no-default-groups --group adapters --group adapter-tests --group test
```

## Run the Calculator Example

Set an NVIDIA API key and start the included MCP server in one terminal:

```bash
export NVIDIA_API_KEY=<your-api-key>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

bad='export NVIDIA_API_KEY=<your-api-key>'
good='export NVIDIA_API_KEY="<your-api-key>"'

if printf '%s\n' "$bad" | bash -n; then
  echo "The unquoted placeholder parsed unexpectedly." >&2
  exit 1
fi

printf '%s\n' "$good" | bash -n

Repository: NVIDIA/NeMo-Fabric

Length of output: 268


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

file='examples/langgraph/README.md'
printf '%s\n' '--- matching lines ---'
rg -n -C 2 'NVIDIA_API_KEY|<your-api-key>' "$file"

printf '%s\n' '--- shell parse and assignment behavior ---'
python3 - <<'PY'
import subprocess

cases = {
    "unquoted": "export NVIDIA_API_KEY=<your-api-key>\n",
    "quoted": 'export NVIDIA_API_KEY="<your-api-key>"\n',
}
for name, script in cases.items():
    parsed = subprocess.run(["bash", "-n"], input=script, text=True, capture_output=True)
    executed = subprocess.run(
        ["bash", "-c", script + 'printf "<%s>\\n" "$NVIDIA_API_KEY"'],
        text=True,
        capture_output=True,
    )
    print(f"{name}: parse_rc={parsed.returncode}, execution_rc={executed.returncode}")
    print(f"{name}: value={executed.stdout.strip()!r}")
    if parsed.stderr:
        print(f"{name}: parse_stderr={parsed.stderr.strip()!r}")
PY

Repository: NVIDIA/NeMo-Fabric

Length of output: 822


Quote the API-key placeholder in both commands.

The unquoted < and > characters cause a Bash syntax error. Use export NVIDIA_API_KEY="<your-api-key>" at Lines 37 and 60.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/langgraph/README.md` at line 37, Quote the <your-api-key>
placeholder in both NVIDIA_API_KEY export commands in the README, including the
commands near lines 37 and 60, so Bash treats the placeholder as a value rather
than shell syntax.

Sources: Coding guidelines, Path instructions

.venv/bin/python -m examples.langgraph.mcp_math_server --port 9901
```

Run the calculator graph for a user in another terminal:

```bash
.venv/bin/python -m examples.langgraph.calculator_mcp \
--user-id alice \
--input "What is 9 multiplied by 7?"
```

Each user ID creates a separate graph, `InMemorySaver`, and
`MultiServerMCPClient`. Reusing a user ID resumes only that user's conversation.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
The example passes `verbose` to LangGraph's `debug` setting.
`retry_parsing_errors` has no direct LangGraph equivalent, so it is retained in
the source configuration as an adapter requirement rather than silently applied.

## Run the Phishing Analyzer Example

Set an NVIDIA API key, then run the structured-output graph:

```bash
export NVIDIA_API_KEY=<your-api-key>
.venv/bin/python -m examples.langgraph.email_phishing_analyzer \
--input "Provide your account and routing numbers to receive a refund."
```

The workflow has application-owned state (`body` and `assessment`) and returns a
JSON-safe result. The NIM binding uses OpenAI-compatible function calling to
produce the structured result. It does not use MCP tools or per-user
checkpointers, which makes it the contrasting workflow required to define a
reusable adapter boundary.

## Validate the Examples

Run the offline checks without an API key or a live MCP server:

```bash
.venv/bin/python -m pytest tests/examples/test_langgraph_examples.py
```

## Work Needed for a Full Adapter

The examples validate the two workflow shapes, but a generic adapter needs the
following additional work before it can support them through NVIDIA NeMo Fabric:

1. Add a LangGraph adapter package, descriptor, installation extra, wheel data,
and lifecycle host. The package needs a `langgraph_factory` entry point, not an
arbitrary compiled-graph import.
2. Define static and dynamic workflow contracts. A contract must validate
`workflow.settings`, declare each accepted normalized capability, define model,
instruction, local-tool, and MCP injection points, and contribute a digest to
the run plan.
3. Map a selected Fabric model alias to an NIM `ChatOpenAI` binding. The adapter
must validate the NIM base URL and credential environment variable before
starting a runtime.
4. Build a policy-aware complete tool inventory. It must retain MCP origins such
as `mcp_math__calculator__multiply`, enforce `tools.enabled` and
`tools.blocked` for local and MCP tools, and reject unknown selectors.
5. Create MCP clients and graph/checkpoint resources with the correct lifetime.
The calculator shows per-user state, but Fabric currently scopes a runtime to a
Fabric runtime ID rather than an end-user identity. A full adapter needs an
explicit, authenticated user/session contract before claiming per-user support.
6. Define the phishing graph's input/output projection, structured-output failure
behavior, and retry semantics. `retry_parsing_errors` needs a documented
LangGraph-equivalent policy that avoids retrying side-effecting tool calls.
7. Add conformance coverage for contract resolution, capability rejection,
multi-invocation state isolation, MCP filtering and tool policy, normalized
failures, cleanup, and opt-in NIM/MCP end-to-end tests.

These examples show why the factory-and-contract boundary is necessary: the
workflows share model and tool injection but do not share state, output projection,
retry behavior, or resource lifetime.
4 changes: 4 additions & 0 deletions examples/langgraph/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Native LangGraph examples for NVIDIA NeMo Fabric."""
139 changes: 139 additions & 0 deletions examples/langgraph/calculator_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""A per-user LangGraph ReAct agent with local and MCP calculator tools."""

from __future__ import annotations

import argparse
import asyncio
import os
from collections.abc import Callable
from typing import Any

from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.checkpoint.memory import InMemorySaver

from examples.langgraph.config import LangGraphExampleConfig
from examples.langgraph.config import McpServerConfig
from examples.langgraph.config import build_nim_chat_model
from examples.langgraph.config import load_config


@tool
def current_timezone() -> str:
"""Return the server's configured IANA time zone, or ``UTC`` when unset."""

return os.environ.get("TZ", "UTC")


def mcp_connection(server: McpServerConfig) -> dict[str, str]:
"""Translate the example's hyphenated transport into LangChain MCP syntax."""

return {"transport": "streamable_http", "url": str(server.url)}


def _selected_mcp_tools(
tools: list[Any], server: McpServerConfig
) -> list[Any]:
if not server.include:
return tools
by_name = {item.name: item for item in tools}
missing = set(server.include) - set(by_name)
if missing:
raise RuntimeError(
"MCP server did not expose configured tool(s): " + ", ".join(sorted(missing))
)
return [by_name[name] for name in server.include]


class PerUserReactAgent:
"""Create an isolated LangGraph, MCP client, and checkpoint store per user."""

def __init__(
self,
config: LangGraphExampleConfig,
*,
model_factory: Callable[[Any], Any] = build_nim_chat_model,
mcp_client_factory: Callable[[dict[str, Any]], Any] = MultiServerMCPClient,
graph_factory: Callable[..., Any] = create_agent,
) -> None:
if config.workflow.entrypoint != "langgraph:per_user_react_agent":
raise ValueError("calculator example requires langgraph:per_user_react_agent")
if config.mcp is None or "mcp_math" not in config.mcp.servers:
raise ValueError("calculator example requires mcp.servers.mcp_math")
if "current_timezone" not in config.tools:
raise ValueError("calculator example requires tools.current_timezone")

self._config = config
self._model_factory = model_factory
self._mcp_client_factory = mcp_client_factory
self._graph_factory = graph_factory
self._sessions: dict[str, Any] = {}

async def graph_for(self, user_id: str) -> Any:
"""Return the user-owned graph, creating it and its MCP client on first use."""

if not user_id.strip():
raise ValueError("user_id must be a non-empty string")
graph = self._sessions.get(user_id)
if graph is not None:
return graph

server = self._config.mcp.servers["mcp_math"] # validated in __init__
client = self._mcp_client_factory(
{"mcp_math": mcp_connection(server)}, tool_name_prefix=False
)
mcp_tools = _selected_mcp_tools(list(await client.get_tools()), server)
model = self._model_factory(self._config.selected_model())
graph = self._graph_factory(
model,
[current_timezone, *mcp_tools],
checkpointer=InMemorySaver(),
debug=bool(self._config.workflow.settings.get("verbose", False)),
name="per_user_calculator",
)
self._sessions[user_id] = graph
return graph
Comment on lines +76 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Serialize Initial Graph Creation Per User.

Two concurrent calls can both pass the cache check before await client.get_tools(). Each call then creates a different graph and checkpoint store. The last assignment replaces the cached graph, and later calls lose the other caller's conversation history.

Use a per-user initialization task or lock with a second cache check. Add an asyncio.gather regression test that verifies one graph and one MCP client are created for concurrent requests from the same user.

🧰 Tools
🪛 Ruff (0.16.1)

[warning] 76-76: Dynamically typed expressions (typing.Any) are disallowed in graph_for

(ANN401)


[warning] 80-80: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/langgraph/calculator_mcp.py` around lines 76 - 99, Update graph_for
to serialize first-time graph creation per user using a per-user initialization
task or lock, then recheck _sessions before constructing the MCP client, tools,
graph, and InMemorySaver. Ensure concurrent requests for the same user share one
initialized graph and MCP client while preserving the existing cached fast path.
Add an asyncio.gather regression test covering concurrent same-user requests and
asserting single graph and MCP client creation.


async def ainvoke(self, user_id: str, message: str) -> dict[str, Any]:
"""Run a message in the graph and persisted conversation for ``user_id``."""

graph = await self.graph_for(user_id)
return await graph.ainvoke(
{"messages": [{"role": "user", "content": message}]},
{"configurable": {"thread_id": user_id}},
)


def build_per_user_react_agent(config: LangGraphExampleConfig) -> PerUserReactAgent:
"""Build the calculator example's declared LangGraph factory entry point."""

return PerUserReactAgent(config)


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", default="examples/langgraph/configs/calculator_mcp.yaml")
parser.add_argument("--user-id", required=True)
parser.add_argument("--input", required=True)
return parser.parse_args()


async def _run() -> None:
args = _parse_args()
graph = build_per_user_react_agent(load_config(args.config))
result = await graph.ainvoke(args.user_id, args.input)
print(result["messages"][-1].content)


def main() -> None:
"""Run the calculator example from the command line."""

asyncio.run(_run())


if __name__ == "__main__":
main()
128 changes: 128 additions & 0 deletions examples/langgraph/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Configuration shared by the native LangGraph examples.

This is application configuration for the examples, not a NeMo Fabric adapter
descriptor or a supported Fabric configuration format.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import Any
from typing import Literal

import yaml
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
from pydantic import Field
from pydantic import HttpUrl
from pydantic import model_validator

NIM_OPENAI_BASE_URL = "https://integrate.api.nvidia.com/v1"


class NimModelConfig(BaseModel):
"""One NVIDIA NIM model binding used by a LangGraph example."""

provider: Literal["nim"]
model: str = Field(min_length=1)
api_key_env: str = "NVIDIA_API_KEY"
base_url: HttpUrl = NIM_OPENAI_BASE_URL
temperature: float = 0.0
max_tokens: int = Field(default=1024, gt=0)


class McpServerConfig(BaseModel):
"""The streamable HTTP MCP server exposed to the calculator workflow."""

transport: Literal["streamable-http"]
url: HttpUrl
include: list[str] = Field(default_factory=list)


class McpConfig(BaseModel):
"""MCP servers available to a LangGraph example."""

servers: dict[str, McpServerConfig] = Field(default_factory=dict)


class LocalToolConfig(BaseModel):
"""A local tool supplied by the workflow rather than an MCP server."""

kind: Literal["local"]
description: str = Field(min_length=1)


class WorkflowConfig(BaseModel):
"""Select a LangGraph graph and its workflow-owned settings."""

entrypoint: Literal[
"langgraph:per_user_react_agent", "langgraph:email_phishing_analyzer"
]
settings: dict[str, Any] = Field(default_factory=dict)


class LangGraphExampleConfig(BaseModel):
"""Validated source configuration for the two native LangGraph examples."""

models: dict[str, NimModelConfig] = Field(min_length=1)
mcp: McpConfig | None = None
tools: dict[str, LocalToolConfig] = Field(default_factory=dict)
workflow: WorkflowConfig

@model_validator(mode="after")
def _validate_workflow_references(self) -> "LangGraphExampleConfig":
llm_name = self.workflow.settings.get("llm_name")
if not isinstance(llm_name, str) or llm_name not in self.models:
raise ValueError("workflow.settings.llm_name must name a configured model")

tool_names = self.workflow.settings.get("tool_names", [])
if not isinstance(tool_names, list) or not all(
isinstance(name, str) for name in tool_names
):
raise ValueError("workflow.settings.tool_names must be a list of strings")

available = set(self.tools)
if self.mcp is not None:
available.update(self.mcp.servers)
unknown = set(tool_names) - available
if unknown:
raise ValueError(
"workflow.settings.tool_names names unknown tool source(s): "
+ ", ".join(sorted(unknown))
)
return self

def selected_model(self) -> NimModelConfig:
"""Return the NIM model selected by the workflow."""

return self.models[self.workflow.settings["llm_name"]]


def load_config(path: str | Path) -> LangGraphExampleConfig:
"""Load and validate one LangGraph example YAML configuration file."""

raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("LangGraph example configuration must be a mapping")
return LangGraphExampleConfig.model_validate(raw)


def build_nim_chat_model(model: NimModelConfig) -> ChatOpenAI:
"""Build the OpenAI-compatible NIM chat-model binding for a graph."""

api_key = os.environ.get(model.api_key_env)
if not api_key:
raise RuntimeError(
f"Set {model.api_key_env} before running a workflow that uses {model.model}."
)
return ChatOpenAI(
model=model.model,
api_key=api_key,
base_url=str(model.base_url),
temperature=model.temperature,
max_tokens=model.max_tokens,
)
Loading