-
Notifications
You must be signed in to change notification settings - Fork 11
feat: add LangGraph example workflows #183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| .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. | ||
|
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. | ||
| 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.""" |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Use a per-user initialization task or lock with a second cache check. Add an 🧰 Tools🪛 Ruff (0.16.1)[warning] 76-76: Dynamically typed expressions (typing.Any) are disallowed in (ANN401) [warning] 80-80: Avoid specifying long messages outside the exception class (TRY003) 🤖 Prompt for AI Agents |
||
|
|
||
| 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() | ||
| 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, | ||
| ) |
There was a problem hiding this comment.
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:
Repository: NVIDIA/NeMo-Fabric
Length of output: 268
🏁 Script executed:
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. Useexport NVIDIA_API_KEY="<your-api-key>"at Lines 37 and 60.🤖 Prompt for AI Agents
Sources: Coding guidelines, Path instructions