Skip to content

Add Exa Search API support as internet search tool#1846

Open
maxwbuckley wants to merge 9 commits intoNVIDIA:developfrom
maxwbuckley:add-exa-internet-search
Open

Add Exa Search API support as internet search tool#1846
maxwbuckley wants to merge 9 commits intoNVIDIA:developfrom
maxwbuckley:add-exa-internet-search

Conversation

@maxwbuckley
Copy link
Copy Markdown

@maxwbuckley maxwbuckley commented Apr 7, 2026

Summary

  • Add exa_internet_search tool using langchain_exa.ExaSearchResults, mirroring the existing tavily_internet_search tool
  • Includes ExaInternetSearchToolConfig with configurable max_results, search_type (Literal["auto", "neural", "keyword"]), livecrawl (Literal["always", "fallback", "never"]), max_query_length, and api_key (via config or EXA_API_KEY env var)
  • Client instantiated lazily inside the invocation path, only when a valid API key is present
  • Adds langchain-exa>=1.1.0,<2.0.0 dependency to nvidia-nat-langchain
  • Updates tutorial documentation with an "Using Exa Search" section alongside the existing Tavily section

Closes #1848

Test plan

  • Unit tests pass (12 tests in test_exa_internet_search.py — config validation, retries, truncation, empty results, empty key)
  • Existing Tavily tests still pass (no regressions)
  • Tool registers correctly in GlobalTypeRegistry and appears in nat info components -t function
  • ruff check passes on all new/modified files
  • Integration test with a valid EXA_API_KEY against live Exa API

🤖 Generated with Claude Code

@maxwbuckley maxwbuckley requested review from a team as code owners April 7, 2026 12:47
@copy-pr-bot
Copy link
Copy Markdown

copy-pr-bot bot commented Apr 7, 2026

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds an Exa-backed LangChain internet search tool: new config, async tool implementation with retries and result formatting, automatic registration, dependency addition, docs for Exa usage, and unit tests for the config.

Changes

Cohort / File(s) Summary
Documentation
docs/source/get-started/tutorials/add-tools-to-a-workflow.md
Expanded the web-search tutorial into "Using Tavily Search" and "Using Exa Search"; added install, EXA_API_KEY, functions.internet_search config with _type: exa_internet_search, optional params (max_results, search_type, livecrawl), and workflow.tool_names example.
Dependencies
packages/nvidia_nat_langchain/pyproject.toml
Added langchain-exa>=1.1.0,<2.0.0 to the package dependencies.
Tool Implementation
packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py
New ExaInternetSearchToolConfig (registered as exa_internet_search) and exa_internet_search async tool for LangChain. Resolves API key from config or EXA_API_KEY, instantiates exa_py.AsyncExa, truncates long queries, calls search_and_contents, retries with exponential backoff, formats results into <Document href="..."/> blocks, and returns informative failure messages.
Plugin Registration
packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/register.py
Imported exa_internet_search to include the new tool in automatic registration.
Tests
packages/nvidia_nat_langchain/tests/test_exa_internet_search.py
Added tests for ExaInternetSearchToolConfig: parametrized constructor scenarios validating SecretStr handling and a test ensuring distinct api_key instances per config object.

Sequence Diagram(s)

sequenceDiagram
    participant Agent
    participant ExaTool as ExaInternetSearchTool
    participant Builder
    participant ExaClient
    participant ExaAPI
    Agent->>ExaTool: request internet_search(query)
    ExaTool->>Builder: resolve tool config & secrets
    ExaTool->>ExaClient: instantiate AsyncExa (use API key)
    ExaTool->>ExaClient: search_and_contents(query, params)
    ExaClient->>ExaAPI: HTTP request
    ExaAPI-->>ExaClient: search results
    ExaClient-->>ExaTool: results
    ExaTool-->>Agent: formatted <Document/> blocks or error message
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding Exa Search API as an internet search tool option.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/nvidia_nat_langchain/pyproject.toml (1)

65-66: Keep dependency entries sorted to match local file contract.

The new langchain-exa entry breaks the declared “Keep sorted!!!” ordering in this dependency block. Please move it after langchain-core to preserve deterministic diffs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nvidia_nat_langchain/pyproject.toml` around lines 65 - 66, The
dependency list is out of sorted order: move the "langchain-exa>=1.1.0,<2.0.0"
entry so it appears after "langchain-core>=1.2.6,<2.0.0" to restore the declared
"Keep sorted!!!" ordering; ensure the two entries remain otherwise unchanged and
the block stays alphabetically sorted.
packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py (1)

46-47: Add an explicit return type to the public registration function.

The async registration function should declare its yielded type for API clarity and static checks.

Proposed fix
+from collections.abc import AsyncGenerator
...
-async def exa_internet_search(tool_config: ExaInternetSearchToolConfig, builder: Builder):
+async def exa_internet_search(
+    tool_config: ExaInternetSearchToolConfig,
+    builder: Builder,
+) -> AsyncGenerator[FunctionInfo, None]:
As per coding guidelines: "All public APIs require Python 3.11+ type hints on parameters and return values".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
around lines 46 - 47, The public async registration function exa_internet_search
is missing an explicit return type; update its signature to include a typed
async generator return annotation (e.g., -> AsyncGenerator[Tool, None]) and add
the necessary import from typing (AsyncGenerator) and the Tool type used by the
registration system so the signature reads like: async def
exa_internet_search(tool_config: ExaInternetSearchToolConfig, builder: Builder)
-> AsyncGenerator[Tool, None]: ensuring the yielded type matches the actual
yielded objects in the function body.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Line 37: The model field max_retries can be <= 0 which causes the retry loop
in _exa_internet_search to be skipped and the function to implicitly return
None; add a guard and ensure a non-None return: validate/normalize max_retries
on model initialization (e.g., enforce min 1 or coerce negatives to 0) and
modify _exa_internet_search so that when the retry loop is skipped or all
attempts fail it explicitly returns an empty list (or other documented default)
instead of None; update references to max_retries and the retry loop inside
_exa_internet_search to use the validated value and always return a concrete
value.
- Around line 53-58: The code mutates process-wide environment EXA_API_KEY
during tool setup; remove the conditional that sets os.environ["EXA_API_KEY"]
and instead rely solely on the explicit api_key argument (falling back to
os.environ.get("EXA_API_KEY") only when constructing ExaSearchResults). Update
the ExaSearchResults instantiation (ExaSearchResults(exa_api_key=...)) to use
api_key or os.environ.get(...) but do not write to os.environ anywhere in this
module (remove the block that assigns os.environ["EXA_API_KEY"]).
- Around line 38-43: Replace the loose string types for the config fields with
enum-like types so invalid values fail at parse time: change the annotations for
search_type and livecrawl to constrained types (e.g., from typing import Literal
and use search_type: Literal["neural","keyword","auto"] and livecrawl:
Literal["always","fallback","never"] or define enums via class SearchType(Enum)
and class Livecrawl(Enum) and use those types), keep the Field(...) calls for
defaults/description but update the defaults to one of the allowed values and
add the necessary imports (Literal or Enum) so pydantic validates inputs when
parsing the model.

---

Nitpick comments:
In `@packages/nvidia_nat_langchain/pyproject.toml`:
- Around line 65-66: The dependency list is out of sorted order: move the
"langchain-exa>=1.1.0,<2.0.0" entry so it appears after
"langchain-core>=1.2.6,<2.0.0" to restore the declared "Keep sorted!!!"
ordering; ensure the two entries remain otherwise unchanged and the block stays
alphabetically sorted.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Around line 46-47: The public async registration function exa_internet_search
is missing an explicit return type; update its signature to include a typed
async generator return annotation (e.g., -> AsyncGenerator[Tool, None]) and add
the necessary import from typing (AsyncGenerator) and the Tool type used by the
registration system so the signature reads like: async def
exa_internet_search(tool_config: ExaInternetSearchToolConfig, builder: Builder)
-> AsyncGenerator[Tool, None]: ensuring the yielded type matches the actual
yielded objects in the function body.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 71f86120-bb29-4abf-929b-468819a50794

📥 Commits

Reviewing files that changed from the base of the PR and between 5816918 and 3b58630.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • docs/source/get-started/tutorials/add-tools-to-a-workflow.md
  • packages/nvidia_nat_langchain/pyproject.toml
  • packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py
  • packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/register.py
  • packages/nvidia_nat_langchain/tests/test_exa_internet_search.py

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py (3)

37-37: ⚠️ Potential issue | 🟠 Major

Guard retry bounds to avoid implicit None returns.

If max_retries <= 0, the loop at Line 73 is skipped and _exa_internet_search can return None implicitly.

Suggested fix
-    max_retries: int = Field(default=3, description="Maximum number of retries for the search request")
+    max_retries: int = Field(default=3, ge=1, description="Maximum number of retries for the search request")
...
         for attempt in range(tool_config.max_retries):
             try:
                 ...
             except Exception:
                 ...
                 await asyncio.sleep(2**attempt)
+        return f"Web search failed after {tool_config.max_retries} attempts for: {question}"

Also applies to: 73-96

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
at line 37, The max_retries Field can be zero or negative causing the retry loop
in _exa_internet_search to be skipped and the function to implicitly return
None; add a guard in either the Field validation or at start of
_exa_internet_search to coerce/validate max_retries to a positive integer (e.g.,
if max_retries is None or <=0 set to 1 or raise ValueError), and ensure
_exa_internet_search always returns an explicit value (like an empty list or a
standardized error result) rather than None so callers don’t get implicit None
returns.

38-43: ⚠️ Potential issue | 🟠 Major

Constrain search_type and livecrawl at config-parse time.

Right now, invalid strings pass validation and fail only at runtime. Use enum-like typing (Literal) so bad values are rejected early.

Suggested fix
+from typing import Literal
...
-    search_type: str = Field(
+    search_type: Literal["auto", "neural", "keyword"] = Field(
         default="auto",
         description="Type of search to perform - 'neural', 'keyword', or 'auto'")
-    livecrawl: str = Field(
+    livecrawl: Literal["always", "fallback", "never"] = Field(
         default="fallback",
         description="Livecrawl behavior - 'always', 'fallback', or 'never'")

As per coding guidelines, "Validate and sanitise all user input, especially in web or CLI interfaces".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
around lines 38 - 43, Replace the loose str types for search_type and livecrawl
so invalid values are rejected during config parsing: change the type
annotations on the model fields search_type and livecrawl from str to Literal
types (e.g., Literal["neural","keyword","auto"] for search_type and
Literal["always","fallback","never"] for livecrawl) or use an Enum, and keep the
existing Field(...) defaults and descriptions; this ensures pydantic/schema
validation fails at parse time instead of letting bad strings slip through to
runtime in functions that rely on these fields.

53-58: ⚠️ Potential issue | 🔴 Critical

Do not mutate process-wide EXA_API_KEY during tool setup.

Writing to os.environ here creates shared global state and can leak/cross wires credentials under concurrency. Resolve the key locally and pass it directly to Exa(...).

Suggested fix
-    if not os.environ.get("EXA_API_KEY"):
-        if api_key:
-            os.environ["EXA_API_KEY"] = api_key
-    # This Exa tool requires an API Key and it must be set as an environment variable (EXA_API_KEY)
-
-    exa_client = Exa(api_key=api_key or os.environ.get("EXA_API_KEY", ""))
+    resolved_api_key = api_key or os.environ.get("EXA_API_KEY", "")
+    exa_client = Exa(api_key=resolved_api_key)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
around lines 53 - 58, Do not write to process-wide os.environ; instead resolve
the key locally and pass it into the Exa constructor: compute a local variable
(e.g., resolved_api_key = api_key or os.environ.get("EXA_API_KEY", "")) and
instantiate exa_client = Exa(api_key=resolved_api_key) without assigning to
os.environ; remove the branch that mutates EXA_API_KEY and optionally validate
resolved_api_key and raise/handle missing key near where exa_client is created.
🧹 Nitpick comments (1)
packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py (1)

46-47: Add public API docstring and explicit return type annotation.

exa_internet_search(...) is a public registered function and should include a Google-style docstring plus an explicit return type (AsyncGenerator[FunctionInfo, None]).

As per coding guidelines, "Provide Google-style docstrings for every public module, class, function and CLI command" and "All public APIs require Python 3.11+ type hints on parameters and return values".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
around lines 46 - 47, Add a Google-style docstring to the public registered
function exa_internet_search describing its purpose, parameters (tool_config:
ExaInternetSearchToolConfig, builder: Builder), and yield behavior, and add an
explicit return type annotation AsyncGenerator[FunctionInfo, None] to the
function signature; ensure imports/types needed for AsyncGenerator and
FunctionInfo are available and reference the registration via register_function
so tooling recognizes the API.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Around line 75-81: The call to exa_client.search_and_contents is synchronous
inside async code; replace the blocking Exa usage by importing AsyncExa (change
`from exa_py import Exa` to `from exa_py import AsyncExa`), instantiate the
async client (replace where `exa_client = Exa(...)` is created) and call its
async method with await (use `await exa_client.search_and_contents(...)`),
ensuring any surrounding function is async and errors are awaited/handled; keep
the same arguments (question, num_results, type, livecrawl, text) and update any
teardown/close calls to the async client equivalents.

---

Duplicate comments:
In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Line 37: The max_retries Field can be zero or negative causing the retry loop
in _exa_internet_search to be skipped and the function to implicitly return
None; add a guard in either the Field validation or at start of
_exa_internet_search to coerce/validate max_retries to a positive integer (e.g.,
if max_retries is None or <=0 set to 1 or raise ValueError), and ensure
_exa_internet_search always returns an explicit value (like an empty list or a
standardized error result) rather than None so callers don’t get implicit None
returns.
- Around line 38-43: Replace the loose str types for search_type and livecrawl
so invalid values are rejected during config parsing: change the type
annotations on the model fields search_type and livecrawl from str to Literal
types (e.g., Literal["neural","keyword","auto"] for search_type and
Literal["always","fallback","never"] for livecrawl) or use an Enum, and keep the
existing Field(...) defaults and descriptions; this ensures pydantic/schema
validation fails at parse time instead of letting bad strings slip through to
runtime in functions that rely on these fields.
- Around line 53-58: Do not write to process-wide os.environ; instead resolve
the key locally and pass it into the Exa constructor: compute a local variable
(e.g., resolved_api_key = api_key or os.environ.get("EXA_API_KEY", "")) and
instantiate exa_client = Exa(api_key=resolved_api_key) without assigning to
os.environ; remove the branch that mutates EXA_API_KEY and optionally validate
resolved_api_key and raise/handle missing key near where exa_client is created.

---

Nitpick comments:
In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Around line 46-47: Add a Google-style docstring to the public registered
function exa_internet_search describing its purpose, parameters (tool_config:
ExaInternetSearchToolConfig, builder: Builder), and yield behavior, and add an
explicit return type annotation AsyncGenerator[FunctionInfo, None] to the
function signature; ensure imports/types needed for AsyncGenerator and
FunctionInfo are available and reference the registration via register_function
so tooling recognizes the API.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e896f727-5c3f-4946-8c76-57f8bc3fe183

📥 Commits

Reviewing files that changed from the base of the PR and between 3b58630 and b5962c7.

📒 Files selected for processing (1)
  • packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py

@maxwbuckley maxwbuckley force-pushed the add-exa-internet-search branch from b8da77f to a69e612 Compare April 7, 2026 13:24
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Line 35: The max_results field currently allows zero/negative values; update
the config model's declaration of max_results to enforce a minimum of 1 (e.g.,
replace "max_results: int = 3" with a pydantic constrained field like
"max_results: int = Field(3, ge=1)" or use conint(ge=1)), and add the necessary
import from pydantic (Field or conint) so model-parse time validation prevents
invalid values; ensure this change is applied to the config class that defines
max_results in exa_internet_search.py.
- Around line 57-70: In _exa_internet_search, add a fast-fail check at the top
to immediately return an error (or raise) when no Exa API key is available:
check both the configured key (tool_config.exa_api_key) and the environment
(os.environ.get('EXA_API_KEY')) and if both are empty, return/raise immediately
instead of proceeding into the retry/backoff loop that uses
tool_config.max_retries; place this check before the question truncation and the
for attempt in range(tool_config.max_retries) loop so unnecessary
retries/backoff are avoided.
- Around line 87-92: The except block in the web-search retry logic in
exa_internet_search.py currently catches all exceptions silently; change this to
import logging and create a module-level logger, narrow the except to retryable
exceptions (e.g., httpx.RequestError, httpx.ReadTimeout, asyncio.TimeoutError)
and call logger.exception(...) before each retry, and separately handle
non-retryable errors (e.g., ValueError, httpx.HTTPStatusError with 401/403) to
fail fast (log with logger.exception and return the fallback message
immediately). Ensure the final fallback return still logs the last exception
with logger.exception so the full stack trace is captured when giving up after
tool_config.max_retries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e72dc882-36a7-402a-b00f-37853e2058f5

📥 Commits

Reviewing files that changed from the base of the PR and between b5962c7 and b8da77f.

📒 Files selected for processing (2)
  • packages/nvidia_nat_langchain/pyproject.toml
  • packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py
✅ Files skipped from review due to trivial changes (1)
  • packages/nvidia_nat_langchain/pyproject.toml

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py (2)

87-92: ⚠️ Potential issue | 🟠 Major

Replace blind catch with logged, selective retry handling.

Current handling catches everything silently; this loses stack traces and may retry non-retryable failures.

🔧 Suggested direction
+import logging
@@
+logger = logging.getLogger(__name__)
@@
-            except Exception:
+            except Exception:
+                logger.exception("Exa search attempt %s/%s failed", attempt + 1, tool_config.max_retries)
                 # Return a graceful message instead of raising, so the agent can
                 # continue reasoning without web search rather than failing entirely.
                 if attempt == tool_config.max_retries - 1:
                     return f"Web search failed after {tool_config.max_retries} attempts for: {question}"
                 await asyncio.sleep(2**attempt)
In exa-py versions compatible with langchain-exa>=1.1.0,<2.0.0, which exception classes can AsyncExa.search_and_contents raise for transient network/server failures versus auth/configuration failures?

As per coding guidelines: "When catching and logging exceptions without re-raising: always use logger.exception() to capture the full stack trace information."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
around lines 87 - 92, Replace the blind except in the retry loop around
AsyncExa.search_and_contents with selective handling: catch
transient/network/server exceptions thrown by AsyncExa.search_and_contents
(e.g., connection/timeouts/retryable HTTP errors) and on those call
logger.exception(...) to record the stack trace, perform the exponential backoff
(await asyncio.sleep(2**attempt)), and only return the graceful failure message
after exhausting tool_config.max_retries; for non-retryable errors
(authentication/configuration errors) re-raise or return immediately so they are
not retried. Locate the retry block in exa_internet_search.py around the
AsyncExa.search_and_contents call and replace the broad except Exception with
specific exception classes and logger.exception usage while preserving the
existing max_retries/attempt logic.

53-71: ⚠️ Potential issue | 🟠 Major

Fail fast when no Exa API key is configured.

If both config and env are empty, the tool still enters retries and backoff, adding avoidable latency.

🔧 Suggested fix
     async def _exa_internet_search(question: str) -> str:
         """This tool retrieves relevant contexts from web search (using Exa) for the given question.
@@
         Returns:
             str: The web search results.
         """
+        if not resolved_api_key:
+            return "Web search is unavailable: `EXA_API_KEY` is not configured."
+
         # Exa API supports longer queries than Tavily but truncate at a reasonable limit
         if len(question) > 2000:
             question = question[:1997] + "..."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
around lines 53 - 71, The function _exa_internet_search currently creates
exa_client with resolved_api_key and then enters retry/backoff loop even when no
key is configured; change it to fail fast by checking resolved_api_key (or
api_key) before creating/using AsyncExa and raise/log a clear error or return
immediately if it's empty so you don't enter the for attempt in
range(tool_config.max_retries) loop; update the early check near where
resolved_api_key/api_key and exa_client are set (and before the loop that uses
tool_config.max_retries) to short-circuit execution when no API key is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/source/get-started/tutorials/add-tools-to-a-workflow.md`:
- Around line 170-187: The Exa subsection shows configuring
functions.internet_search with _type: exa_internet_search but omits wiring the
tool into the workflow; update the docs to add an explicit workflow block that
sets workflow.tool_names to include internet_search and current_datetime (and
use the correct workflow._type, e.g., react_agent) so the example demonstrates
both function registration (functions.internet_search / current_datetime) and
adding those names to workflow.tool_names.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Around line 82-84: The generated web_search_results string incorrectly uses a
self-closing opening tag plus a separate closing tag; update the formatting
where web_search_results is built (iterating over search_response.results and
using doc.url and doc.text) to use a proper opening tag with href (e.g.,
<Document href="...">) followed by the document text and then the closing
</Document> tag so the XML/HTML is well-formed.

---

Duplicate comments:
In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Around line 87-92: Replace the blind except in the retry loop around
AsyncExa.search_and_contents with selective handling: catch
transient/network/server exceptions thrown by AsyncExa.search_and_contents
(e.g., connection/timeouts/retryable HTTP errors) and on those call
logger.exception(...) to record the stack trace, perform the exponential backoff
(await asyncio.sleep(2**attempt)), and only return the graceful failure message
after exhausting tool_config.max_retries; for non-retryable errors
(authentication/configuration errors) re-raise or return immediately so they are
not retried. Locate the retry block in exa_internet_search.py around the
AsyncExa.search_and_contents call and replace the broad except Exception with
specific exception classes and logger.exception usage while preserving the
existing max_retries/attempt logic.
- Around line 53-71: The function _exa_internet_search currently creates
exa_client with resolved_api_key and then enters retry/backoff loop even when no
key is configured; change it to fail fast by checking resolved_api_key (or
api_key) before creating/using AsyncExa and raise/log a clear error or return
immediately if it's empty so you don't enter the for attempt in
range(tool_config.max_retries) loop; update the early check near where
resolved_api_key/api_key and exa_client are set (and before the loop that uses
tool_config.max_retries) to short-circuit execution when no API key is present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: db1cc117-034d-4644-b824-f2cfbfcbf8e9

📥 Commits

Reviewing files that changed from the base of the PR and between b8da77f and a69e612.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • docs/source/get-started/tutorials/add-tools-to-a-workflow.md
  • packages/nvidia_nat_langchain/pyproject.toml
  • packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py
  • packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/register.py
  • packages/nvidia_nat_langchain/tests/test_exa_internet_search.py
✅ Files skipped from review due to trivial changes (3)
  • packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/register.py
  • packages/nvidia_nat_langchain/pyproject.toml
  • packages/nvidia_nat_langchain/tests/test_exa_internet_search.py

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py (1)

85-88: ⚠️ Potential issue | 🟡 Minor

Fix malformed XML tag syntax.

The <Document> wrapper uses a self-closing opening tag (/>) combined with a separate closing tag, which is inconsistent XML.

Proposed fix
                 web_search_results = "\n\n---\n\n".join([
-                    f'<Document href="{doc.url}"/>\n{doc.text}\n</Document>'
+                    f'<Document href="{doc.url}">\n{doc.text}\n</Document>'
                     for doc in search_response.results if doc.text
                 ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
around lines 85 - 88, The XML wrapper for search results is malformed: the
opening tag in the web_search_results join uses a self-closing form ('<Document
href="..."/>') but then adds a separate closing tag; update the string
construction inside web_search_results (the list comprehension iterating over
search_response.results and using doc.url/doc.text) so the opening tag is a
proper start tag (e.g., '<Document href="...">') paired with the existing
'</Document>' closing tag to produce well-formed XML.
🧹 Nitpick comments (1)
packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py (1)

90-96: Add logging for exception handling.

The code catches all exceptions silently without logging, making production debugging difficult. Per coding guidelines, use logger.exception() when catching exceptions without re-raising.

Additionally, not all exceptions warrant retry (e.g., auth failures from 401/403 should fail fast).

Proposed improvement
+import logging
+
+logger = logging.getLogger(__name__)
+
 # ... in the function ...
             except Exception:
+                logger.exception("Exa search attempt %d failed", attempt + 1)
                 # Return a graceful message instead of raising, so the agent can
                 # continue reasoning without web search rather than failing entirely.
                 if attempt == tool_config.max_retries - 1:
                     return f"Web search failed after {tool_config.max_retries} attempts for: {question}"
                 await asyncio.sleep(2**attempt)

As per coding guidelines: "When catching and logging exceptions without re-raising, always use logger.exception() to capture the full stack trace."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`
around lines 90 - 96, The except block in the web search retry loop swallows all
exceptions—update it to catch Exception as e and call logger.exception(...) to
log the full stack trace and context (include question and attempt), and add a
fast-fail for authorization errors by checking the exception for HTTP status
401/403 (e.g., inspect e.response.status or isinstance checks for HTTPError) and
immediately return a clear failure string in that case; for other exceptions
continue the existing exponential backoff (await asyncio.sleep(2**attempt)) and
only return the final failure after tool_config.max_retries attempts. Reference
the existing variables/methods: attempt, tool_config.max_retries, question, and
logger/asyncio.sleep in exa_internet_search.py.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Around line 85-88: The XML wrapper for search results is malformed: the
opening tag in the web_search_results join uses a self-closing form ('<Document
href="..."/>') but then adds a separate closing tag; update the string
construction inside web_search_results (the list comprehension iterating over
search_response.results and using doc.url/doc.text) so the opening tag is a
proper start tag (e.g., '<Document href="...">') paired with the existing
'</Document>' closing tag to produce well-formed XML.

---

Nitpick comments:
In
`@packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py`:
- Around line 90-96: The except block in the web search retry loop swallows all
exceptions—update it to catch Exception as e and call logger.exception(...) to
log the full stack trace and context (include question and attempt), and add a
fast-fail for authorization errors by checking the exception for HTTP status
401/403 (e.g., inspect e.response.status or isinstance checks for HTTPError) and
immediately return a clear failure string in that case; for other exceptions
continue the existing exponential backoff (await asyncio.sleep(2**attempt)) and
only return the final failure after tool_config.max_retries attempts. Reference
the existing variables/methods: attempt, tool_config.max_retries, question, and
logger/asyncio.sleep in exa_internet_search.py.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 79ae3df7-b586-4594-bb27-53cf77807065

📥 Commits

Reviewing files that changed from the base of the PR and between a69e612 and 2e43508.

📒 Files selected for processing (2)
  • docs/source/get-started/tutorials/add-tools-to-a-workflow.md
  • packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/exa_internet_search.py

@bbednarski9 bbednarski9 self-assigned this Apr 7, 2026
@bbednarski9 bbednarski9 added feature request New feature or request Under Review PR is under review and should not be marked stale DO NOT MERGE PR should not be merged; see PR for details labels Apr 7, 2026
@bbednarski9
Copy link
Copy Markdown
Contributor

Hi @maxwbuckley, appreciate your interest in contributing to NAT! Can you raise an issue describing the use case and argument to include Exa integration with NeMo Agent Toolkit? Why does this need to be a built-in tool directly in nat-langchain? When you raise the issue we can get product involved to consider as an RFR.

Adding DO NOT MERGE to this PR, until issue is raised and approved

@maxwbuckley
Copy link
Copy Markdown
Author

Thanks @bbednarski9! Filed #1848 with the use case and rationale. Happy to iterate on it.

@bbednarski9
Copy link
Copy Markdown
Contributor

@maxwbuckley thanks for filing the issue. Did a first pass of the code. Would you mind addressing the comments above and I'll take another look?

-Bryan

@maxwbuckley
Copy link
Copy Markdown
Author

@bbednarski9 thanks for the detailed review! All six comments have been addressed across the follow-up commits (a69e612, 2e43508, 27786c9). Summary of changes:

  1. Copyright year — updated to Copyright (c) 2026 in source and test files.
  2. Transitive import — switched to from langchain_exa import ExaSearchResults so the runtime import matches the declared langchain-exa dependency in pyproject.toml.
  3. Lazy client instantiationExaSearchResults is now only constructed inside _exa_internet_search() after verifying resolved_api_key is non-empty; missing-key returns an early "Web search is unavailable" message.
  4. Magic truncation constant — replaced with a configurable max_query_length field on ExaInternetSearchToolConfig (default 2000, ge=1), and a logger.warning() is emitted when truncation occurs.
  5. Config surface for truncation — same as above; lives on the tool config so users can override it in YAML.
  6. Test coverage — expanded to 12 tests covering config validation (max_retries=0, max_results=0, invalid search_type, invalid livecrawl), empty-key unavailable message, query truncation via mock, empty results handling, and retry count on exception.

Ready for another pass whenever you have a moment. Thanks!

@maxwbuckley
Copy link
Copy Markdown
Author

Ran through the CodeRabbit comments — summary of resolution:

Already addressed in earlier commits:

  • max_retries / max_results ge=1 constraint — done (config fields now enforce ge=1).
  • search_type / livecrawl as Literal — done.
  • os.environ["EXA_API_KEY"] mutation — removed; key is passed directly to ExaSearchResults(exa_api_key=...).
  • Fail-fast when no API key — done (early return "Web search is unavailable").
  • Docs workflow.tool_names example — already present in the Exa subsection of add-tools-to-a-workflow.md.

Addressed in 78dbf08:

  • Exception logging in the retry loop — added logger.exception("Exa search attempt %d of %d failed", ...) so transient failures surface in logs instead of being swallowed silently.

Intentionally not changed:

  • <Document href="..."/> wrapper formatting — this matches the existing tavily_internet_search tool (packages/nvidia_nat_langchain/src/nat/plugins/langchain/tools/tavily_internet_search.py:84). Keeping parity with the sibling tool is deliberate since this PR was designed to mirror Tavily; if we want to fix the XML wrapper, it should be done consistently across both tools in a separate change.
  • Narrow exception-type handling in the retry loop — exa-py does not expose a stable public exception hierarchy, so broad except Exception with logging is the pragmatic choice. Happy to revisit if exa-py adds typed exceptions upstream.

All 12 unit tests still pass and ruff check is clean.

@bbednarski9
Copy link
Copy Markdown
Contributor

Hey @maxwbuckley, our repository requires DCO signoffs for each commit (Signed-off-by:). Can you ammend your previous commits to meet this criteria so we can move forward with a merge?

use:
git commit --ammend --signoff

If you havent done this before, Claude is pretty good at it.

In the meantime ill review your last commits and close out old comments

@bbednarski9 bbednarski9 removed DO NOT MERGE PR should not be merged; see PR for details Under Review PR is under review and should not be marked stale labels Apr 13, 2026
@maxwbuckley maxwbuckley force-pushed the add-exa-internet-search branch from 78dbf08 to e2c3e8d Compare April 13, 2026 15:52
@maxwbuckley
Copy link
Copy Markdown
Author

@bbednarski9 good catch — only the most recent commit (78dbf08d) was missing the Signed-off-by trailer; the earlier five already had it. Amended it with git commit --amend --signoff and force-pushed: 78dbf08de2c3e8db. All six commits on the branch are now DCO-signed.

@bbednarski9
Copy link
Copy Markdown
Contributor

/ok to test 2f57377

@bbednarski9
Copy link
Copy Markdown
Contributor

@maxwbuckley almost there. Can you do the following:

  1. merge and update uv.lock at project root?
git fetch origin
git rebase origin/develop
# when uv.lock conflict appears:
git checkout --theirs uv.lock
git add uv.lock
git rebase --continue
# regenerate lockfile:
uv lock
git add uv.lock
git commit -s -m "Regenerate uv.lock after rebase onto develop"
git push --force-with-lease
  1. add 'Exa' to ci/vale/styles/config/vocabularies/nat/accept.txt. Please keep in alphabetical order.

Looks good otherwise

maxwbuckley and others added 7 commits April 13, 2026 19:18
Add `exa_internet_search` tool using the langchain-exa integration,
mirroring the existing tavily_internet_search tool. Includes config
class, tool registration, unit tests, dependency, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
The langchain_exa ExaSearchResults wrapper doesn't pass num_results
and other params through its .run() method. Use the exa_py.Exa client
directly for correct behavior (max_results, search_type, livecrawl).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
- Use AsyncExa instead of sync Exa to avoid blocking the event loop
- Remove os.environ mutation; resolve API key locally
- Use Literal types for search_type and livecrawl config validation
- Add ge=1 constraint on max_retries to prevent implicit None returns
- Add explicit return after retry loop as safety fallback
- Fix dependency sort order: langchain-core before langchain-exa

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
- Add ge=1 constraint on max_results field
- Fail fast when no EXA_API_KEY is configured
- Add workflow.tool_names example to Exa docs section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
- Fix copyright year to 2026
- Use langchain_exa.ExaSearchResults instead of exa_py directly to
  match the declared dependency
- Lazily instantiate client inside invocation path, only if key exists
- Add configurable max_query_length field (default 2000) with truncation
  warning log
- Expand test coverage: retries, truncation, empty results, empty key,
  config validation for invalid search_type/livecrawl/max_retries/max_results

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
Address CodeRabbit feedback to surface failures instead of swallowing
them silently in the retry loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
Address reviewer feedback to surface all configurable fields in the
tutorial's Exa configuration example.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
@maxwbuckley maxwbuckley force-pushed the add-exa-internet-search branch from 2f57377 to 9ba9b3f Compare April 13, 2026 17:19
@maxwbuckley
Copy link
Copy Markdown
Author

@bbednarski9 both done:

  1. Rebase + uv.lock — rebased onto origin/develop, resolved the uv.lock conflict with --theirs, continued the rebase, then ran uv lock to regenerate the lockfile cleanly on top of the latest develop state.
  2. Vale vocabulary — added Exa to ci/vale/styles/config/vocabularies/nat/accept.txt in alphabetical order (between [Ee]val and [Ee]xfiltration).

Both changes are in a single signed commit 9ba9b3f7 on top of the rebased branch. Force-pushed.

@bbednarski9
Copy link
Copy Markdown
Contributor

/okt to test 9ba9b3f

@bbednarski9
Copy link
Copy Markdown
Contributor

@maxwbuckley I think there was an issue with the uv lock generation. Perhaps it didnt go through cleanly? Can you try again or pass me contributor to your fork and ill check it?

Expected:

Added packages:
- exa-py 1.16.1 MIT
- langchain-exa 1.1.0 MIT
Changed packages:
- langchain-core 1.2.16 -> 1.2.28

Got:

Added packages:
- exa-py 1.16.1 MIT
- instructor 1.12.0 MIT
- langchain-exa 1.1.0 MIT
- mem0ai 0.1.118 (License not found)
- openai 1.109.1 Apache-2.0
- openai 2.31.0 Apache-2.0
Removed packages:
- openai 2.30.0
Changed packages:
- langchain-core 1.2.16 -> 1.2.26
- strands-agents 1.34.1 -> 1.35.0

Regenerate lockfile after rebase onto develop and add 'Exa' to the
accepted Vale vocabulary so the docs linter accepts the product name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
@maxwbuckley maxwbuckley force-pushed the add-exa-internet-search branch from 9ba9b3f to 209182f Compare April 14, 2026 07:29
@maxwbuckley
Copy link
Copy Markdown
Author

@bbednarski9 you were right — the previous regen started from our feature branch's stale lockfile, which caused uv lock to re-resolve and pull in upgrades for unrelated packages (instructor, mem0ai, openai, strands-agents, etc.).

Fixed in 209182fe: I reset uv.lock to origin/develop's version first (git checkout origin/develop -- uv.lock), then ran uv lock. The resolver now reports exactly what you expected:

Added exa-py v1.16.1
Updated langchain-core v1.2.16 -> v1.2.28
Added langchain-exa v1.1.0

Force-pushed.

exa-py defines SearchType = Literal["auto", "fast", "deep", "neural",
"instant"]. The previous config exposed "keyword" which the Exa API
rejects, making any workflow that set search_type=keyword fail at runtime.
Drop "keyword", add the valid "fast", "deep", and "instant" options,
and update the docs example comment accordingly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Max Buckley <maxwbuckley@gmail.com>
Copy link
Copy Markdown
Contributor

@bbednarski9 bbednarski9 left a comment

Choose a reason for hiding this comment

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

Looks good, neeed on license review of the diffs below before merge:

Added packages:
- exa-py 1.16.1 MIT
- langchain-exa 1.1.0 MIT
Changed packages:
- langchain-core 1.2.16 -> 1.2.28

Copy link
Copy Markdown

@Salonijain27 Salonijain27 left a comment

Choose a reason for hiding this comment

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

approved from a dependency point of view

@bbednarski9
Copy link
Copy Markdown
Contributor

/ok to test 5f98c2c

@bbednarski9
Copy link
Copy Markdown
Contributor

Hey @maxwbuckley, its approved on our end, final step is just passing CI. You can run the following to make sure it will pass pre-commit hooks before. Also lets merge/rebase develop one more time

pre-commit run --all-files

yapf was failing on my end.

After it passes CI ill merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature request New feature or request non-breaking Non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Exa Search API as a built-in internet search tool

4 participants