diff --git a/.secrets.baseline b/.secrets.baseline
index 0e6f9882..addd2602 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -142,7 +142,7 @@
"filename": "deploy/.env.example",
"hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684",
"is_verified": false,
- "line_number": 30
+ "line_number": 32
}
],
"deploy/compose/README.md": [
@@ -290,5 +290,5 @@
}
]
},
- "generated_at": "2026-05-22T20:01:44Z"
+ "generated_at": "2026-06-01T05:45:53Z"
}
diff --git a/deploy/.env.example b/deploy/.env.example
index 03b5d713..53e12aff 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -17,8 +17,10 @@ AIQ_DEV_ENV=cli
NVIDIA_API_KEY=
-# Web search (Required)
+# Web search (Required — set at least one of TAVILY_API_KEY, EXA_API_KEY, or NIMBLE_API_KEY)
TAVILY_API_KEY=
+# EXA_API_KEY=
+# NIMBLE_API_KEY=
# Paper search (Optional)
# SERPER_API_KEY= # to enable, set API key and update the relevant config in configs/ directory
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 0c5eb391..b0bb2bca 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -77,6 +77,8 @@ RUN uv pip install --no-deps -e . \
&& uv pip install --no-deps -e ./sources/google_scholar_paper_search \
&& uv pip install --no-deps -e ./sources/tavily_web_search \
&& uv pip install --no-deps -e ./sources/exa_web_search \
+ && uv pip install --no-deps -e ./sources/nimble_web_search \
+ && uv pip install langchain-nimble==3.0.0 nimble-python==0.18.0 \
&& uv pip install --no-deps -e "./sources/knowledge_layer[all]" \
&& uv pip install --no-deps -e ./frontends/aiq_api \
&& uv pip install "psycopg[binary]>=3.0.0"
diff --git a/docs/source/customization/configuration-reference.md b/docs/source/customization/configuration-reference.md
index a47a21f0..87ec7600 100644
--- a/docs/source/customization/configuration-reference.md
+++ b/docs/source/customization/configuration-reference.md
@@ -197,6 +197,48 @@ functions:
- **`fast`** -- Optimized for low latency. Returns results quickly at the cost of recall and semantic depth. Use for interactive UIs, high-volume calls, or when the query is narrow and keyword-like.
- **`deep`** -- Optimized for thoroughness. Runs a more expensive semantic search with broader retrieval. Use for research-quality queries where completeness matters more than speed.
+### `nimble_web_search`
+
+Web search powered by the [Nimble API](https://nimbleway.com/) via `langchain-nimble`.
+
+```yaml
+functions:
+ web_search_tool:
+ _type: nimble_web_search
+ max_results: 5
+ max_content_length: 10000
+
+ deep_web_search_tool:
+ _type: nimble_web_search
+ max_results: 5
+ search_depth: deep
+```
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `max_results` | `int` | `5` | Maximum number of search results to return. |
+| `api_key` | `str` | `None` | Nimble API key. Falls back to `NIMBLE_API_KEY` environment variable. |
+| `max_retries` | `int` | `3` | Number of retry attempts on search failure. |
+| `search_depth` | `str` | `"lite"` | Nimble search depth. See options below. |
+| `focus` | `str` | `"general"` | Nimble focus mode. See options below. |
+| `country` | `str` | `"US"` | ISO country code passed to Nimble (e.g. `US`, `UK`, `FR`). |
+| `locale` | `str` | `"en"` | Language/locale passed to Nimble (e.g. `en`, `fr`, `es`). |
+| `max_content_length` | `int` | `10000` | Max characters per result's page content. Set to `None` to disable truncation. |
+
+**`search_depth` options:**
+
+- **`lite`** (default) -- Returns metadata only (title, URL, description). Fastest, lowest token cost, safe default for general lookups.
+- **`fast`** -- Returns rich content at low latency. **Enterprise-tier only**; non-enterprise accounts receive a 403 with a clear entitlement message.
+- **`deep`** -- Returns full page content for each result. Use for research workflows that need the body text, not just URLs.
+
+**`focus` options:**
+
+- **`general`** (default) -- Broad web/research queries. The right choice for almost all agent use.
+- **`news`** -- Restricts results to news-publisher sources, ordered by recency. There is no recency threshold -- older articles still appear; it changes the source mix, not the time window. (Recency windowing is a separate Nimble `time_range` capability that also works with `focus=general`; not exposed in this initial integration.)
+- **`location`**, **`shopping`**, **`geo`**, **`social`** -- Domain-specific routing; set only when the tool targets that domain.
+
+`focus` is a workflow-config setting, not an agent-chosen parameter -- the model only passes a query, so general research queries cannot silently switch to `news`. Answer generation (`include_answer`) is **not exposed** in this initial integration.
+
### `paper_search`
Academic paper search through Google Scholar (using the [Serper API](https://serper.dev/)).
diff --git a/docs/source/deployment/docker-build.md b/docs/source/deployment/docker-build.md
index 52b5712a..a2a7e9e4 100644
--- a/docs/source/deployment/docker-build.md
+++ b/docs/source/deployment/docker-build.md
@@ -48,6 +48,7 @@ The builder stage handles all compilation and package installation:
- `sources/google_scholar_paper_search` -- Google Scholar search
- `sources/tavily_web_search` -- Tavily web search
- `sources/exa_web_search` -- Exa web search
+ - `sources/nimble_web_search` -- Nimble web search
- `sources/knowledge_layer[all]` -- Knowledge layer with all extras
- `frontends/aiq_api` -- [FastAPI](https://fastapi.tiangolo.com/) frontend
- `psycopg[binary]>=3.0.0` -- PostgreSQL driver (psycopg v3, installed non-editable)
diff --git a/docs/source/deployment/docker-compose.md b/docs/source/deployment/docker-compose.md
index 535f61ce..558a45ed 100644
--- a/docs/source/deployment/docker-compose.md
+++ b/docs/source/deployment/docker-compose.md
@@ -44,6 +44,7 @@ The sections below explain each group of variables.
| `NVIDIA_API_KEY` | Yes | NVIDIA API key for NIM model access. |
| `TAVILY_API_KEY` | Conditional | Web search provider key (required if using `tavily_web_search`). |
| `EXA_API_KEY` | Conditional | Web search provider key (required if using `exa_web_search`). |
+| `NIMBLE_API_KEY` | Conditional | Web search provider key (required if using `nimble_web_search`). |
| `SERPER_API_KEY` | No | Google Scholar paper search key (optional). |
### API keys (optional)
diff --git a/docs/source/deployment/kubernetes.md b/docs/source/deployment/kubernetes.md
index 008ddeef..115b0950 100644
--- a/docs/source/deployment/kubernetes.md
+++ b/docs/source/deployment/kubernetes.md
@@ -263,6 +263,7 @@ For complete examples with NGC-specific flags, see `deploy/helm/README.md` in th
| Key | Description |
|-----|-------------|
| `EXA_API_KEY` | Exa API key for web search |
+| `NIMBLE_API_KEY` | Nimble API key for web search |
| `SERPER_API_KEY` | Serper API key for Google search |
| `JINA_API_KEY` | Jina API key |
| `WANDB_API_KEY` | Weights & Biases API key |
diff --git a/docs/source/extending/adding-a-data-source.md b/docs/source/extending/adding-a-data-source.md
index b829bd04..74e76218 100644
--- a/docs/source/extending/adding-a-data-source.md
+++ b/docs/source/extending/adding-a-data-source.md
@@ -460,6 +460,7 @@ async def search(self, query: str) -> str:
|---|---|---|---|
| Tavily Web Search | `tavily_web_search` | `sources/tavily_web_search` | General web search through Tavily API |
| Exa Web Search | `exa_web_search` | `sources/exa_web_search` | General web search through Exa API |
+| Nimble Web Search | `nimble_web_search` | `sources/nimble_web_search` | General web search through Nimble API (`langchain-nimble`) |
| Google Scholar | `paper_search` | `sources/google_scholar_paper_search` | Academic papers through Serper/Google Scholar |
| Knowledge Layer | `knowledge_retrieval` | `sources/knowledge_layer` | Document retrieval through pluggable backends |
diff --git a/docs/source/extending/adding-a-tool.md b/docs/source/extending/adding-a-tool.md
index af00a06d..08d05798 100644
--- a/docs/source/extending/adding-a-tool.md
+++ b/docs/source/extending/adding-a-tool.md
@@ -420,6 +420,7 @@ f'\n\n{title}\n\n{content}\n'
|---|---|---|---|
| Tavily Web Search | `tavily_web_search` | `sources/tavily_web_search` | `TAVILY_API_KEY` |
| Exa Web Search | `exa_web_search` | `sources/exa_web_search` | `EXA_API_KEY` |
+| Nimble Web Search | `nimble_web_search` | `sources/nimble_web_search` | `NIMBLE_API_KEY` |
| Google Scholar | `paper_search` | `sources/google_scholar_paper_search` | `SERPER_API_KEY` |
| Knowledge Layer | `knowledge_retrieval` | `sources/knowledge_layer` | (varies by backend) |
diff --git a/docs/source/get-started/installation.md b/docs/source/get-started/installation.md
index d5fee400..2d9e77e5 100644
--- a/docs/source/get-started/installation.md
+++ b/docs/source/get-started/installation.md
@@ -51,7 +51,7 @@ The script performs the following steps:
3. Installs the core package with dev dependencies
4. Installs all frontends (CLI, debug console, API server)
5. Installs benchmark packages (freshqa, deepsearch_qa)
-6. Installs all data source plugins (Tavily, Exa, Google Scholar, knowledge layer)
+6. Installs all data source plugins (Tavily, Exa, Nimble, Google Scholar, knowledge layer)
7. Sets up pre-commit hooks
8. Copies `deploy/.env.example` to `deploy/.env` if no `.env` file exists
9. Installs UI npm dependencies (if Node.js is available)
@@ -96,6 +96,7 @@ uv pip install -e ./frontends/aiq_api # Unified API server (includes debug)
# Data sources (pick what you need)
uv pip install -e ./sources/tavily_web_search
uv pip install -e ./sources/exa_web_search
+uv pip install -e ./sources/nimble_web_search
uv pip install -e ./sources/google_scholar_paper_search
uv pip install -e "./sources/knowledge_layer[llamaindex,foundational_rag]"
@@ -132,9 +133,10 @@ Then edit `deploy/.env` and fill in your keys.
|----------|----------|---------|
| `TAVILY_API_KEY` | [Tavily](https://tavily.com/) | Web search (Tavily provider) |
| `EXA_API_KEY` | [Exa](https://exa.ai/) | Web search (Exa provider) |
+| `NIMBLE_API_KEY` | [Nimble](https://nimbleway.com/) | Web search (Nimble provider) |
| `SERPER_API_KEY` | [Serper](https://serper.dev/) | Academic paper search (Google Scholar). To enable, uncomment `paper_search_tool` in your config file |
-At minimum, you need `NVIDIA_API_KEY` for LLM inference and one of `TAVILY_API_KEY` or `EXA_API_KEY` for web search. Paper search (`SERPER_API_KEY`) is disabled by default in the shipped configs -- refer to the comments in your config file to enable it.
+At minimum, you need `NVIDIA_API_KEY` for LLM inference and one of `TAVILY_API_KEY`, `EXA_API_KEY`, or `NIMBLE_API_KEY` for web search. Paper search (`SERPER_API_KEY`) is disabled by default in the shipped configs -- refer to the comments in your config file to enable it.
## Verify Installation
diff --git a/docs/source/get-started/quick-start.md b/docs/source/get-started/quick-start.md
index fbb59bac..4917a13d 100644
--- a/docs/source/get-started/quick-start.md
+++ b/docs/source/get-started/quick-start.md
@@ -22,6 +22,8 @@ NVIDIA_API_KEY=nvapi-...
TAVILY_API_KEY=tvly-...
# Or, to use Exa instead of Tavily for web search:
# EXA_API_KEY=...
+# Or, to use Nimble instead of Tavily for web search:
+# NIMBLE_API_KEY=...
```
## Step 2: Choose a Mode
diff --git a/docs/source/resources/faq.md b/docs/source/resources/faq.md
index 20916c41..a90d57a2 100644
--- a/docs/source/resources/faq.md
+++ b/docs/source/resources/faq.md
@@ -44,6 +44,7 @@ If `enable_escalation: true` in the workflow config, the orchestrator evaluates
- **Tavily Web Search** — General web search (requires `TAVILY_API_KEY`)
- **Exa Web Search** — General web search via Exa (requires `EXA_API_KEY`)
+- **Nimble Web Search** — General web search via Nimble (requires `NIMBLE_API_KEY`)
- **Google Scholar Paper Search** — Academic paper search (requires `SERPER_API_KEY`)
- **Knowledge Layer** — Document retrieval from local or hosted vector stores
diff --git a/docs/source/resources/troubleshooting.md b/docs/source/resources/troubleshooting.md
index c4dbedbd..73ac2c7d 100644
--- a/docs/source/resources/troubleshooting.md
+++ b/docs/source/resources/troubleshooting.md
@@ -25,6 +25,8 @@ Common issues and solutions for the AI-Q blueprint.
| `Gateway timeout (504)` | Model endpoint overloaded or unavailable | Retry, or switch to a different model in config |
| Tavily search returns empty | Invalid `TAVILY_API_KEY` | Verify key at [tavily.com](https://tavily.com) |
| Exa search returns empty or 401 | Invalid or missing `EXA_API_KEY` | Verify key at [exa.ai](https://exa.ai) |
+| Nimble search returns empty or 401 | Invalid or missing `NIMBLE_API_KEY` | Verify key at [nimbleway.com](https://nimbleway.com) |
+| Nimble search returns 403 with "enterprise" | `search_depth: fast` requires an Enterprise plan | Switch to `search_depth: lite` (default) or `deep`, or upgrade your Nimble plan |
| Serper search fails | Missing `SERPER_API_KEY` | Set key or remove `paper_search_tool` from config |
## Runtime Issues
diff --git a/pyproject.toml b/pyproject.toml
index e08479b3..f2873d6d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -190,6 +190,7 @@ dev = [
"google-scholar-paper-search",
"tavily-web-search",
"exa-web-search",
+ "nimble-web-search",
"knowledge-layer[all]",
"aiq-api",
"aiq-research-cli",
@@ -231,6 +232,7 @@ aiq-agent = { workspace = true }
google-scholar-paper-search = { workspace = true }
tavily-web-search = { workspace = true }
exa-web-search = { workspace = true }
+nimble-web-search = { workspace = true }
knowledge-layer = { workspace = true }
aiq-api = { workspace = true }
aiq-research-cli = { workspace = true }
diff --git a/scripts/setup.sh b/scripts/setup.sh
index b0992871..f053af15 100755
--- a/scripts/setup.sh
+++ b/scripts/setup.sh
@@ -66,6 +66,7 @@ echo ""
echo "Installing data sources..."
"${UV_BIN}" pip install -e ./sources/tavily_web_search
"${UV_BIN}" pip install -e ./sources/exa_web_search
+"${UV_BIN}" pip install -e ./sources/nimble_web_search
"${UV_BIN}" pip install -e ./sources/google_scholar_paper_search
"${UV_BIN}" pip install -e "./sources/knowledge_layer[llamaindex,foundational_rag]"
echo "Data Sources installed"
diff --git a/sources/nimble_web_search/README.md b/sources/nimble_web_search/README.md
new file mode 100644
index 00000000..218c548d
--- /dev/null
+++ b/sources/nimble_web_search/README.md
@@ -0,0 +1,139 @@
+# Nimble Web Search
+
+NAT-based [Nimble](https://nimbleway.com/) web search tool for agentic search workflows that need live web context. Requires a `NIMBLE_API_KEY` environment variable or `api_key` config.
+
+## When to use
+
+Use `nimble_web_search` when your AI-Q agent needs fresh web context from Nimble's real-time web intelligence infrastructure. The provider is designed for agentic search workflows that benefit from live web discovery, structured results, and reliable retrieval through Nimble's search layer.
+
+Choose `nimble_web_search` when Tavily or Exa are not the right fit for your workflow, or when you want to standardize web-search retrieval through Nimble. The provider follows the same integration pattern as `exa_web_search` and `tavily_web_search`, making it straightforward to configure as an alternative search backend in AI-Q.
+
+## Install
+
+This package is installed automatically by `scripts/setup.sh` alongside the other data-source plugins. Manual install:
+
+```bash
+uv pip install -e ./sources/nimble_web_search
+```
+
+## Configure
+
+Add a `nimble_web_search` function to your workflow YAML:
+
+```yaml
+functions:
+ web_search_tool:
+ _type: nimble_web_search
+ max_results: 5
+ search_depth: lite
+ country: US
+ locale: en
+```
+
+All filters are optional and default off, so the block above is unchanged from a minimal setup. To use the richer surface (e.g. a recency- and domain-scoped news tool):
+
+```yaml
+functions:
+ recent_news_tool:
+ _type: nimble_web_search
+ focus: news
+ time_range: week
+ include_domains: ["reuters.com", "apnews.com", "bbc.com"]
+ max_results: 5
+```
+
+See [`docs/source/customization/configuration-reference.md`](../../docs/source/customization/configuration-reference.md) for the full parameter table.
+
+## Environment
+
+```bash
+NIMBLE_API_KEY=...
+```
+
+You can alternatively set `api_key` directly in the YAML (as a string). Both paths use the standard `nat.data_models.function.FunctionBaseConfig` `SecretStr` handling — the key is not logged.
+
+## Test
+
+```bash
+# Unit tests (credential-free, mocked)
+uv run pytest sources/nimble_web_search -v
+
+# Lint
+uv run ruff check sources/nimble_web_search
+uv run ruff format --check sources/nimble_web_search
+```
+
+The unit tests cover: config defaults / all fields / invalid `search_depth` rejection, missing-key stub + warn-once, key-from-config env hydration, successful render + description fallback, deep depth passthrough, query/content truncation, empty/error handling, retry-then-success, final-retry failure, 401, 403 enterprise-tier, non-default country/locale passthrough, and renderer behavior on titles containing special characters.
+
+## Verification
+
+Beyond the mocked unit tests above, verify the provider is fully integrated with AI-Q by walking the [adding-a-data-source checklist](../../docs/source/extending/adding-a-data-source.md):
+
+```bash
+# 1. Mocked unit tests pass (CI-safe, no credentials)
+uv run pytest sources/nimble_web_search -q
+# → 21 passed in <1s
+
+# 2. NAT discovers the registered function
+nat info components --types function | grep nimble_web_search
+# → nimble_web_search 1.0.0 function
+
+# 3. Lint, format, and dependency lock all clean
+uv run ruff check sources/nimble_web_search
+uv run ruff format --check sources/nimble_web_search
+uv lock --check
+
+# 4. Live smoke through any workflow that names `_type: nimble_web_search` (requires NIMBLE_API_KEY)
+export NIMBLE_API_KEY=...
+nat run --config_file --input "your test query"
+```
+
+Step 4 satisfies the checklist's "Installed and tested with `nat run`" item. Any of the existing AI-Q web search configs (`configs/config_cli_default.yml`, `configs/config_web_default_llamaindex.yml`, etc.) becomes a Nimble-backed test by swapping `_type: tavily_web_search` → `_type: nimble_web_search` and translating `advanced_search: true` → `search_depth: deep`.
+
+## Native capabilities
+
+The provider exposes the following Nimble-specific surface. Defaults are tuned for the common AI-Q research workflow (lite-mode SERP for a few results, US/English regional bias):
+
+| Capability | Field | Default | Notes |
+|---|---|---|---|
+| Result count | `max_results` | `5` | Range `1-100` (Nimble's documented cap). Soft cap (Nimble may return up to N+2; see Known limitations). |
+| Search depth | `search_depth` | `lite` | See the dedicated [Search depth](#search-depth) section below. |
+| Search focus | `focus` | `general` | Nimble focus mode: `general` (default, broad web/research), `news` (news-publisher sources ordered by recency — not a recency filter; older articles still appear), or domain-specific `location` / `shopping` / `geo` / `social`. Leave `general` for normal research; the LLM never selects focus, so general queries can't drift to `news`. |
+| Localization — country | `country` | `US` | Two-letter country code (e.g. `FR`, `JP`, `UK`). Reaches the SDK constructor verbatim. |
+| Localization — language | `locale` | `en` | ISO 639-1 language code (e.g. `fr`, `ja`). |
+| Per-result content size | `max_content_length` | `10000` chars | Truncates each result's body to N chars (3-char ellipsis included). Minimum `1`; set to `null` to disable truncation; omit to use default. |
+| Domain whitelist | `include_domains` | `null` | List of domains to restrict results to, e.g. `["github.com", "docs.python.org"]`. Verified live (whitelist returns only matching domains). **Neither the Tavily nor Exa AI-Q providers expose domain filtering.** |
+| Domain blacklist | `exclude_domains` | `null` | List of domains to exclude, e.g. `["pinterest.com"]`. |
+| Recency window | `time_range` | `null` | One of `hour` / `day` / `week` / `month` / `year`. A tight window can legitimately return **zero** results for evergreen topics (the tool then returns a clear "no results" message — not an error). |
+| Date range | `start_date` / `end_date` | `null` | `YYYY-MM-DD` or `YYYY`. Restrict results to a published-date window. Verified live (date-filtered result set differs from baseline). |
+| Body format | `output_format` | `markdown` | `plain_text` / `markdown` / `simplified_html`. `markdown` is recommended for LLM context; `simplified_html` adds HTML noise and is not recommended for agents. |
+| Retries | `max_retries` | `3` | Exponential backoff on transient errors. Final failure surfaces a friendly per-status message (401, 403, generic). |
+| Auth | `api_key` / `NIMBLE_API_KEY` | env or config | `pydantic.SecretStr`; never logged. Config-side `api_key` hydrates the env var so the underlying SDK can read it. |
+
+> **Result count is an upper bound.** `max_results` is enforced as a hard cap on our side (the API may soft-cap and return *more* than requested; we slice to `max_results` for Tavily/Exa-consistent breadth). The API may still return *fewer* than requested — a property of any SERP backend — which is surfaced as-is.
+
+Each `` block in the rendered output carries an `entity_type` (`"OrganicResult"` for SERP results). The `include_answer=True` capability — which would produce an `entity_type="answer"` block first — is intentionally **not exposed** in this initial integration. See [Known limitations](#known-limitations).
+
+## Search depth
+
+| Value | Behavior | Account requirement |
+|---|---|---|
+| `lite` (default) | Metadata only — URL, title, description. Token-efficient. | Any |
+| `fast` | Enterprise tier. Lower latency, richer content. | **Enterprise account required.** Returns a 403 ToolException with `"search_depth='fast' is not enabled for this account. Contact sales for access."` on non-enterprise accounts. |
+| `deep` | Higher token cost. May return short page-content snippets in addition to metadata. | Any (in this account; behavior may vary by tier) |
+
+If you do not know your tier, leave `search_depth: lite` and let the description field carry the content.
+
+## Known limitations
+
+- `max_results` is a **soft cap on the Nimble API side** — it may return more than N when asked for N. The provider enforces it as a **hard upper bound** (slices to `max_results`) so result breadth is predictable and consistent with the other web-search providers. The API may still return *fewer* than requested (a property of any SERP backend), which is surfaced as-is.
+- `lite` mode returns `page_content == ""` per result. The provider falls back to `description` (~150 chars per result, organic-result quality).
+- `time_range` filters by recency; a tight window (e.g. `week`) can legitimately return **zero** results for evergreen topics. The tool then returns a clear "no results" message rather than an error.
+- `include_answer` is **not exposed** in this initial integration. It can be added in a follow-up.
+
+## Security
+
+- API key handling follows the existing `EXA_API_KEY` / `TAVILY_API_KEY` pattern: env var or `SecretStr` config; never logged.
+- Untrusted API fields (`url`, `title`, body) are HTML-escaped before being rendered into the `` markup, so a result can't break the block or inject into downstream parsers.
+- Tests are mocked; no live network in CI.
+- The optional live smoke is documented in the PR description and uses a redacted output pattern.
diff --git a/sources/nimble_web_search/pyproject.toml b/sources/nimble_web_search/pyproject.toml
new file mode 100644
index 00000000..a40e0d77
--- /dev/null
+++ b/sources/nimble_web_search/pyproject.toml
@@ -0,0 +1,37 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+[build-system]
+build-backend = "setuptools.build_meta"
+requires = ["setuptools >= 64", "setuptools-scm>=8"]
+
+[tool.setuptools]
+packages = ["nimble_web_search"]
+package-dir = {"nimble_web_search" = "src"}
+
+[project]
+name = "nimble-web-search"
+version = "1.0.0"
+description = "NAT-based Nimble web search tool"
+readme = "README.md"
+requires-python = ">=3.11,<3.14"
+license = {text = "Apache-2.0"}
+dependencies = [
+ "pydantic>=2.0.0",
+ "langchain-nimble>=3.0.0,<4.0.0",
+]
+
+[project.entry-points."nat.plugins"]
+nimble_web_search = "nimble_web_search.register"
diff --git a/sources/nimble_web_search/src/__init__.py b/sources/nimble_web_search/src/__init__.py
new file mode 100644
index 00000000..98e8bbb9
--- /dev/null
+++ b/sources/nimble_web_search/src/__init__.py
@@ -0,0 +1,22 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Nimble web search tool for NAT."""
+
+from .register import nimble_web_search # noqa: F401
+
+__all__ = [
+ "nimble_web_search",
+]
diff --git a/sources/nimble_web_search/src/register.py b/sources/nimble_web_search/src/register.py
new file mode 100644
index 00000000..1431aca5
--- /dev/null
+++ b/sources/nimble_web_search/src/register.py
@@ -0,0 +1,295 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import html
+import logging
+import os
+from collections.abc import AsyncGenerator
+from typing import Literal
+
+from pydantic import Field
+from pydantic import SecretStr
+
+from nat.builder.builder import Builder
+from nat.builder.function_info import FunctionInfo
+from nat.cli.register_workflow import register_function
+from nat.data_models.function import FunctionBaseConfig
+
+logger = logging.getLogger(__name__)
+
+_missing_key_warned = False
+
+
+class NimbleWebSearchToolConfig(FunctionBaseConfig, name="nimble_web_search"):
+ """
+ Tool that retrieves relevant contexts from web search (using Nimble) for the given question.
+ Requires a NIMBLE_API_KEY environment variable or api_key config.
+ """
+
+ max_results: int = Field(
+ default=5,
+ ge=1,
+ le=100,
+ description="Maximum number of search results to return (Nimble accepts 1-100).",
+ )
+ api_key: SecretStr | None = Field(default=None, description="The API key for the Nimble service")
+ max_retries: int = Field(default=3, ge=1, description="Maximum number of retries for the search request")
+ search_depth: Literal["lite", "fast", "deep"] = Field(
+ default="lite",
+ description=(
+ "Nimble search depth. 'lite' returns metadata only and is the safe default. "
+ "'fast' is an Enterprise-tier feature and raises a 403 ToolException on "
+ "non-enterprise accounts. 'deep' returns full page content."
+ ),
+ )
+ # Mirrors langchain-nimble's SearchFocus modes (general, news, location,
+ # shopping, geo, social). Declared locally because the SDK does not export
+ # the enum publicly; swap for a direct import if it becomes public.
+ focus: Literal["general", "news", "location", "shopping", "geo", "social"] = Field(
+ default="general",
+ description=(
+ "Nimble search focus mode. 'general' (default) covers broad web/research "
+ "queries and is the right choice for almost all agent use. 'news' restricts "
+ "results to news-publisher sources ordered by recency (it is not a recency "
+ "filter -- older articles still appear); the rest are domain-specific "
+ "(location, shopping, geo, social). Leave as 'general' unless the tool is "
+ "dedicated to one of those."
+ ),
+ )
+ country: str = Field(
+ default="US",
+ description="ISO country code passed to Nimble (e.g. 'US', 'UK', 'FR').",
+ )
+ locale: str = Field(
+ default="en",
+ description="Language/locale passed to Nimble (e.g. 'en', 'fr', 'es').",
+ )
+ max_content_length: int | None = Field(
+ default=10000,
+ ge=1,
+ description=(
+ "Max characters per result's page content. Truncates each result to reduce "
+ "token usage. Set to None to disable truncation."
+ ),
+ )
+ # --- Optional search filters (all default off; backward-compatible) ---------
+ # These map 1:1 to langchain-nimble's NimbleSearchRetriever fields and are
+ # only sent to the API when set. Domain filtering and recency/date filtering
+ # are capabilities the Tavily and Exa AI-Q providers do not expose.
+ include_domains: list[str] | None = Field(
+ default=None,
+ min_length=1,
+ description=(
+ "Restrict results to these domains (whitelist), e.g. "
+ "['github.com', 'docs.python.org']. Use None (not []) to disable; an "
+ "empty list is rejected to avoid an ambiguous zero-domain filter."
+ ),
+ )
+ exclude_domains: list[str] | None = Field(
+ default=None,
+ min_length=1,
+ description=(
+ "Exclude these domains from results (blacklist), e.g. ['pinterest.com']. "
+ "Use None (not []) to disable; an empty list is rejected."
+ ),
+ )
+ time_range: Literal["hour", "day", "week", "month", "year"] | None = Field(
+ default=None,
+ description=(
+ "Restrict results to a recency window. None = no recency filter. Note: a "
+ "tight window (e.g. 'week') can legitimately return zero results for "
+ "evergreen topics; the tool then returns a clear 'no results' message."
+ ),
+ )
+ start_date: str | None = Field(
+ default=None,
+ description=("Restrict results to those on/after this date (YYYY-MM-DD or YYYY). None = no lower bound."),
+ )
+ end_date: str | None = Field(
+ default=None,
+ description=("Restrict results to those on/before this date (YYYY-MM-DD or YYYY). None = no upper bound."),
+ )
+ output_format: Literal["plain_text", "markdown", "simplified_html"] = Field(
+ default="markdown",
+ description=(
+ "Body content format from Nimble. 'markdown' (default) is recommended for "
+ "LLM context; 'simplified_html' adds HTML noise and is not recommended for "
+ "agent consumption."
+ ),
+ )
+
+
+@register_function(config_type=NimbleWebSearchToolConfig)
+async def nimble_web_search(
+ tool_config: NimbleWebSearchToolConfig,
+ builder: Builder,
+) -> AsyncGenerator[FunctionInfo, None]:
+ """Register the Nimble web search tool with NAT.
+
+ Wraps ``langchain_nimble.NimbleSearchRetriever`` in a NAT function so agents
+ can query the Nimble Search API. If ``NIMBLE_API_KEY`` is not available
+ (via environment or ``tool_config.api_key``), a stub function is registered
+ that returns an informative error instead of failing at import time.
+
+ Args:
+ tool_config: Configuration controlling result count, retries, search
+ depth, geographic filters, and optional content truncation.
+ builder: NAT builder handle (unused; accepted for interface parity).
+
+ Yields:
+ A ``FunctionInfo`` wrapping either the live Nimble search callable or
+ the missing-key stub.
+ """
+ from langchain_nimble import NimbleSearchRetriever
+
+ if not os.environ.get("NIMBLE_API_KEY") and tool_config.api_key:
+ os.environ["NIMBLE_API_KEY"] = tool_config.api_key.get_secret_value()
+
+ if not os.environ.get("NIMBLE_API_KEY"):
+ global _missing_key_warned
+ if not _missing_key_warned:
+ logger.warning(
+ "NIMBLE_API_KEY not found. The web search tool will be registered but will "
+ "return an error when called. To enable: set NIMBLE_API_KEY in your environment, "
+ ".env file, or specify api_key in your workflow config."
+ )
+ _missing_key_warned = True
+
+ async def _nimble_web_search_stub(question: str) -> str:
+ """Web search tool (unavailable - missing NIMBLE_API_KEY)."""
+ return (
+ "Error: Nimble web search is unavailable because NIMBLE_API_KEY is not set.\n"
+ "To enable this tool:\n"
+ "1. Get an API key from https://nimbleway.com/\n"
+ "2. Set the API key in your environment or in your .env file\n"
+ "3. Restart the application"
+ )
+
+ yield FunctionInfo.from_fn(
+ _nimble_web_search_stub,
+ description=_nimble_web_search_stub.__doc__,
+ )
+ return
+
+ # The constructor kwargs below match langchain-nimble>=3.0.0,<4.0.0; this
+ # signature contract is exercised by the live smoke (see PR description),
+ # since the unit tests mock the retriever. `focus` and `output_format` are
+ # passed explicitly so the defaults are provably "general"/"markdown" rather
+ # than relying on the SDK's (unvalidated) field defaults. The optional
+ # filters (include_domains / exclude_domains / time_range / start_date /
+ # end_date) are forwarded as-is; langchain-nimble omits any that are None.
+ # `include_answer` is intentionally not exposed in this initial integration.
+ retriever = NimbleSearchRetriever(
+ max_results=tool_config.max_results,
+ search_depth=tool_config.search_depth,
+ focus=tool_config.focus,
+ country=tool_config.country,
+ locale=tool_config.locale,
+ output_format=tool_config.output_format,
+ include_domains=tool_config.include_domains,
+ exclude_domains=tool_config.exclude_domains,
+ time_range=tool_config.time_range,
+ start_date=tool_config.start_date,
+ end_date=tool_config.end_date,
+ )
+
+ async def _nimble_web_search(question: str) -> str:
+ """Search the web with Nimble and return relevant sources for a question.
+
+ General-purpose web/research search: pass a natural-language question and
+ get back the most relevant pages with their URLs and content. Use it for
+ broad informational and technical research queries.
+
+ Args:
+ question (str): The question to answer. Truncated to 400 characters if longer.
+
+ Returns:
+ str: Relevant documents and their URLs, rendered as XML blocks.
+ """
+ if len(question) > 400:
+ question = question[:397] + "..."
+
+ def _truncate_content(content: str) -> str:
+ limit = tool_config.max_content_length
+ if limit is not None and len(content) > limit:
+ # For very small limits there is no room for the ellipsis; hard-cut
+ # so the result never exceeds the configured budget.
+ if limit <= 3:
+ return content[:limit]
+ return content[: limit - 3] + "..."
+ return content
+
+ for attempt in range(tool_config.max_retries):
+ try:
+ docs = await retriever.ainvoke(question)
+
+ if not docs:
+ raise ValueError("Search returned no results")
+
+ # Nimble's `max_results` is a soft cap (the API may return more
+ # than requested). Enforce it as a hard upper bound so result
+ # breadth is predictable and consistent with the Tavily/Exa
+ # providers. The API may still return *fewer* (a property of any
+ # SERP backend); that is surfaced as-is.
+ if len(docs) > tool_config.max_results:
+ docs = docs[: tool_config.max_results]
+
+ def _render(doc) -> str:
+ metadata = getattr(doc, "metadata", {}) or {}
+ url = metadata.get("url", "") or ""
+ title = metadata.get("title", "") or ""
+ page_content = getattr(doc, "page_content", "") or ""
+ description = metadata.get("description", "") or ""
+ body = _truncate_content(page_content if page_content else description)
+ # Escape untrusted API fields so they can't break the
+ # markup or inject into downstream renderers/parsers.
+ return (
+ f'\n'
+ f"\n{html.escape(title)}\n\n"
+ f"{html.escape(body)}\n"
+ )
+
+ web_search_results = "\n\n---\n\n".join(_render(doc) for doc in docs)
+ return web_search_results if web_search_results else "Search returned no results"
+
+ except Exception as e:
+ error_msg = str(e)
+ # Non-transient errors can't be resolved by retrying, so return
+ # immediately instead of sleeping through the remaining attempts.
+ if isinstance(e, ValueError):
+ return error_msg
+ if "401" in error_msg or "Unauthorized" in error_msg:
+ return (
+ "Error: Web search failed due to invalid API key (401 Unauthorized).\n"
+ "Please check your NIMBLE_API_KEY and ensure it is valid.\n"
+ )
+ if "403" in error_msg:
+ return (
+ "Error: Web search failed due to a Nimble entitlement restriction (403).\n"
+ "The configured `search_depth` may require an enterprise Nimble account. "
+ "Try `search_depth: lite` or contact Nimble.\n"
+ )
+ # Transient error: retry with backoff, or give up on the last attempt.
+ if attempt == tool_config.max_retries - 1:
+ return f"Error: Web search failed - {error_msg}"
+ await asyncio.sleep(2**attempt)
+
+ return "Error: Search failed after all retries"
+
+ yield FunctionInfo.from_fn(
+ _nimble_web_search,
+ description=_nimble_web_search.__doc__,
+ )
diff --git a/sources/nimble_web_search/tests/test_nimble_register.py b/sources/nimble_web_search/tests/test_nimble_register.py
new file mode 100644
index 00000000..680fa326
--- /dev/null
+++ b/sources/nimble_web_search/tests/test_nimble_register.py
@@ -0,0 +1,649 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for the nimble_web_search NAT registration."""
+
+import os
+import sys
+import types
+from unittest.mock import AsyncMock
+from unittest.mock import MagicMock
+
+import pytest
+from nimble_web_search.register import NimbleWebSearchToolConfig
+from nimble_web_search.register import nimble_web_search
+from pydantic import SecretStr
+
+
+class _FakeDoc:
+ """Mimic langchain Document just enough for the renderer."""
+
+ def __init__(self, url="", title="", page_content="", description=""):
+ self.page_content = page_content
+ self.metadata = {
+ "url": url,
+ "title": title,
+ "description": description,
+ "position": 1,
+ "entity_type": "OrganicResult",
+ }
+
+
+@pytest.fixture
+def fake_langchain_nimble(monkeypatch):
+ """Install a fake `langchain_nimble` module so tests never hit the network.
+
+ Returns the shared NimbleSearchRetriever instance the registration will create.
+ """
+
+ module = types.ModuleType("langchain_nimble")
+ instance = MagicMock()
+ instance.ainvoke = AsyncMock()
+
+ module.NimbleSearchRetriever = MagicMock(return_value=instance)
+ monkeypatch.setitem(sys.modules, "langchain_nimble", module)
+ return instance
+
+
+@pytest.fixture(autouse=True)
+def _reset_warn_flag():
+ import nimble_web_search.register as reg
+
+ reg._missing_key_warned = False
+ yield
+ reg._missing_key_warned = False
+
+
+@pytest.fixture(autouse=True)
+def _clear_env(monkeypatch):
+ monkeypatch.delenv("NIMBLE_API_KEY", raising=False)
+
+
+async def _no_sleep(_):
+ return None
+
+
+class TestNimbleWebSearchToolConfig:
+ def test_defaults(self):
+ config = NimbleWebSearchToolConfig()
+ assert config.max_results == 5
+ assert config.api_key is None
+ assert config.max_retries == 3
+ assert config.search_depth == "lite"
+ assert config.focus == "general"
+ assert config.country == "US"
+ assert config.locale == "en"
+ assert config.max_content_length == 10000
+ # Enrichment fields default off so existing configs are unaffected.
+ assert config.include_domains is None
+ assert config.exclude_domains is None
+ assert config.time_range is None
+ assert config.start_date is None
+ assert config.end_date is None
+ assert config.output_format == "markdown"
+
+ def test_all_fields(self):
+ config = NimbleWebSearchToolConfig(
+ max_results=10,
+ api_key=SecretStr("sk-test"),
+ max_retries=1,
+ search_depth="deep",
+ focus="news",
+ country="UK",
+ locale="fr",
+ max_content_length=50,
+ )
+ assert config.max_results == 10
+ assert config.api_key.get_secret_value() == "sk-test"
+ assert config.max_retries == 1
+ assert config.search_depth == "deep"
+ assert config.focus == "news"
+ assert config.country == "UK"
+ assert config.locale == "fr"
+ assert config.max_content_length == 50
+
+ def test_invalid_search_depth_rejected(self):
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ NimbleWebSearchToolConfig(search_depth="ultra")
+
+ def test_invalid_focus_rejected(self):
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ NimbleWebSearchToolConfig(focus="newsy")
+
+ def test_no_include_answer_field(self):
+ # include_answer is intentionally not exposed as AI-Q-facing config in this
+ # initial integration (see register.py / README).
+ assert "include_answer" not in NimbleWebSearchToolConfig.model_fields
+ assert "include_answers" not in NimbleWebSearchToolConfig.model_fields
+
+ @pytest.mark.parametrize(
+ "field,value",
+ [
+ ("max_results", 0), # below ge=1
+ ("max_results", 101), # above le=100 (Nimble's documented cap)
+ ("max_retries", 0), # below ge=1
+ ("max_content_length", 0), # below ge=1 (use None to disable truncation)
+ ],
+ )
+ def test_out_of_range_numeric_fields_rejected(self, field, value):
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ NimbleWebSearchToolConfig(**{field: value})
+
+ def test_max_content_length_none_allowed(self):
+ config = NimbleWebSearchToolConfig(max_content_length=None)
+ assert config.max_content_length is None
+
+ def test_inherits_from_function_base_config(self):
+ from nat.data_models.function import FunctionBaseConfig
+
+ assert issubclass(NimbleWebSearchToolConfig, FunctionBaseConfig)
+
+
+class TestNimbleWebSearchStub:
+ async def test_stub_when_no_api_key(self):
+ config = NimbleWebSearchToolConfig()
+ builder = MagicMock()
+
+ async with nimble_web_search(config, builder) as info:
+ result = await info.single_fn("anything")
+
+ assert "NIMBLE_API_KEY" in result
+ assert "unavailable" in result.lower()
+
+ async def test_warn_once_when_key_missing(self, caplog):
+ import logging
+
+ config = NimbleWebSearchToolConfig()
+ builder = MagicMock()
+ with caplog.at_level(logging.WARNING, logger="nimble_web_search.register"):
+ async with nimble_web_search(config, builder):
+ pass
+ async with nimble_web_search(config, builder):
+ pass
+
+ warnings = [r for r in caplog.records if "NIMBLE_API_KEY not found" in r.message]
+ assert len(warnings) == 1
+
+
+class TestNimbleWebSearchLive:
+ async def test_api_key_from_config_sets_env(self, fake_langchain_nimble):
+ fake_langchain_nimble.ainvoke.return_value = [
+ _FakeDoc(url="https://a.example", title="A", page_content="body a")
+ ]
+ config = NimbleWebSearchToolConfig(api_key=SecretStr("sk-from-config"))
+ builder = MagicMock()
+
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("question")
+
+ assert os.environ.get("NIMBLE_API_KEY") == "sk-from-config"
+ assert "https://a.example" in out
+ assert "body a" in out
+
+ async def test_successful_search_formats_documents(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [
+ _FakeDoc(url="https://a.example", title="Title A", page_content="Body A"),
+ _FakeDoc(url="https://b.example", title="Title B", page_content="Body B"),
+ ]
+ config = NimbleWebSearchToolConfig(max_results=2)
+ builder = MagicMock()
+
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("query")
+
+ assert "Title A" in out
+ assert "Title B" in out
+ assert "Body A" in out
+ assert "Body B" in out
+ assert "---" in out
+ assert "https://a.example" in out
+
+ async def test_description_used_when_page_content_empty(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [
+ _FakeDoc(url="https://a.example", title="A", page_content="", description="metadata-only description"),
+ ]
+ config = NimbleWebSearchToolConfig()
+ builder = MagicMock()
+
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ assert "metadata-only description" in out
+
+ async def test_search_depth_deep_passes_through(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="body")]
+
+ config = NimbleWebSearchToolConfig(search_depth="deep")
+ builder = MagicMock()
+ async with nimble_web_search(config, builder):
+ pass
+
+ # Verify the constructor received the expected kwargs via the module mock
+ ctor = sys.modules["langchain_nimble"].NimbleSearchRetriever
+ ctor.assert_called()
+ kwargs = ctor.call_args.kwargs
+ assert kwargs["search_depth"] == "deep"
+ assert kwargs["max_results"] == 5
+ assert kwargs["focus"] == "general" # default is general, passed explicitly
+ assert kwargs["country"] == "US"
+ assert kwargs["locale"] == "en"
+ # include_answer is intentionally omitted in v1 — the upstream retriever
+ # surfaces it as a 403 enterprise gate for non-enterprise accounts.
+ assert "include_answer" not in kwargs
+
+ async def test_focus_defaults_to_general(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: with no focus configured, the retriever is built with focus='general' —
+ so general research queries never silently use a news/other focus.
+ """
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig()
+ builder = MagicMock()
+ async with nimble_web_search(config, builder):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["focus"] == "general"
+
+ async def test_focus_news_passes_through_when_configured(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: an operator can deliberately configure focus='news'; it reaches the SDK.
+ (The LLM never chooses focus — it's not a tool parameter — so general queries can't
+ drift to news on their own.)
+ """
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(focus="news")
+ builder = MagicMock()
+ async with nimble_web_search(config, builder):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["focus"] == "news"
+
+ async def test_truncates_long_query(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="body")]
+
+ config = NimbleWebSearchToolConfig()
+ builder = MagicMock()
+ long_q = "x" * 500
+ async with nimble_web_search(config, builder) as info:
+ await info.single_fn(long_q)
+
+ (passed_q,), _ = fake_langchain_nimble.ainvoke.call_args
+ assert len(passed_q) == 400
+ assert passed_q.endswith("...")
+
+ async def test_truncates_content(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="abcdefghijklmnop")]
+
+ config = NimbleWebSearchToolConfig(max_content_length=8)
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ assert "abcde..." in out
+ assert "abcdefghi" not in out
+
+ async def test_truncates_content_small_limit_no_negative_slice(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: a max_content_length below 4 hard-cuts without a negative slice, and the
+ result never exceeds the configured budget (the ellipsis needs 3 chars of headroom).
+ """
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="abcdefghij")]
+
+ config = NimbleWebSearchToolConfig(max_content_length=2)
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ # body hard-cut to exactly 2 chars ("ab"), no "..." appended, no over-run
+ assert "ab\n" in out
+ assert "abc" not in out
+
+ async def test_empty_results_returns_error(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = []
+
+ config = NimbleWebSearchToolConfig(max_retries=1)
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ assert "no results" in out.lower()
+
+ async def test_retries_then_succeeds(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ monkeypatch.setattr("nimble_web_search.register.asyncio.sleep", _no_sleep)
+
+ fake_langchain_nimble.ainvoke.side_effect = [
+ RuntimeError("transient"),
+ [_FakeDoc(url="u", title="t", page_content="ok")],
+ ]
+
+ config = NimbleWebSearchToolConfig(max_retries=3)
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ assert "ok" in out
+ assert fake_langchain_nimble.ainvoke.call_count == 2
+
+ async def test_final_retry_failure_returns_error(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ monkeypatch.setattr("nimble_web_search.register.asyncio.sleep", _no_sleep)
+ fake_langchain_nimble.ainvoke.side_effect = RuntimeError("upstream broken")
+
+ config = NimbleWebSearchToolConfig(max_retries=2)
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ assert "Web search failed" in out
+ assert "upstream broken" in out
+
+ async def test_401_returns_friendly_message(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ monkeypatch.setattr("nimble_web_search.register.asyncio.sleep", _no_sleep)
+ fake_langchain_nimble.ainvoke.side_effect = RuntimeError("401 Unauthorized")
+
+ config = NimbleWebSearchToolConfig(max_retries=2)
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ assert "401" in out
+ assert "NIMBLE_API_KEY" in out
+
+ async def test_403_returns_friendly_entitlement_message(self, fake_langchain_nimble, monkeypatch):
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ monkeypatch.setattr("nimble_web_search.register.asyncio.sleep", _no_sleep)
+ fake_langchain_nimble.ainvoke.side_effect = RuntimeError(
+ "403 - {'detail': 'This feature is only available for enterprise accounts.'}"
+ )
+
+ config = NimbleWebSearchToolConfig(max_retries=1, search_depth="fast")
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ assert "403" in out
+ assert "enterprise" in out.lower()
+
+ async def test_non_transient_errors_short_circuit_without_retry(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: 401/403 (and other non-transient) errors return immediately after a single
+ attempt — no retry, no backoff sleep — so the caller isn't made to wait through retries
+ for an error that can't be resolved by retrying.
+ """
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ slept = []
+ monkeypatch.setattr(
+ "nimble_web_search.register.asyncio.sleep",
+ lambda d: slept.append(d) or _no_sleep(d),
+ )
+ fake_langchain_nimble.ainvoke.side_effect = RuntimeError("401 Unauthorized")
+
+ config = NimbleWebSearchToolConfig(max_retries=3)
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ assert "401" in out
+ # short-circuited: exactly one upstream call, and no backoff sleep happened
+ assert fake_langchain_nimble.ainvoke.call_count == 1
+ assert slept == []
+
+ # --- Field-passthrough tests (defaults are covered by test_search_depth_deep_passes_through;
+ # the four tests below verify that *non-default* values flow into the SDK constructor and
+ # that the rendered output stays well-formed for unusual title content) -------------------
+
+ async def test_non_default_country_passthrough(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: NimbleWebSearchToolConfig(country="FR") forwards country='FR' to the SDK."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(country="FR")
+ builder = MagicMock()
+ async with nimble_web_search(config, builder):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["country"] == "FR"
+
+ async def test_non_default_locale_passthrough(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: NimbleWebSearchToolConfig(locale="fr") forwards locale='fr' to the SDK."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(locale="fr")
+ builder = MagicMock()
+ async with nimble_web_search(config, builder):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["locale"] == "fr"
+
+ async def test_country_locale_combined_passthrough(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: Both country and locale non-defaults are forwarded together to the SDK."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(country="JP", locale="ja")
+ builder = MagicMock()
+ async with nimble_web_search(config, builder):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["country"] == "JP"
+ assert kwargs["locale"] == "ja"
+
+ async def test_renderer_escapes_special_characters(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: untrusted API fields containing <, >, &, " are HTML-escaped in the
+ rendered block, so they can't break the markup or inject into downstream
+ renderers/parsers.
+ """
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ weird_title = "Apple Q4 < Microsoft Q4 > Forecast & Analysis"
+ fake_langchain_nimble.ainvoke.return_value = [
+ _FakeDoc(
+ url="https://example.com/q4?a=1&b=2",
+ title=weird_title,
+ page_content='snippet with and "quotes"',
+ )
+ ]
+
+ config = NimbleWebSearchToolConfig()
+ builder = MagicMock()
+ async with nimble_web_search(config, builder) as info:
+ out = await info.single_fn("q")
+
+ # Title special chars are escaped (raw < > & no longer present in the title text)
+ assert "Apple Q4 < Microsoft Q4 > Forecast & Analysis" in out
+ assert weird_title not in out
+ # The href attribute is escaped (& → &) so it can't break the attribute
+ assert 'href="https://example.com/q4?a=1&b=2"' in out
+ # A body that contains a literal can't terminate the block early
+ assert "</Document>" in out
+ # The block is still well-formed and terminates correctly
+ assert out.endswith("")
+
+
+class TestEnrichedFilters:
+ """Passthrough + behavior tests for the optional search filters
+ (include_domains / exclude_domains / time_range / start_date / end_date /
+ output_format) and the upper-bound result cap. Each filter is verified
+ against live Nimble behavior in the integration workspace; here we pin that
+ a configured value reaches the SDK constructor and that defaults stay off.
+ """
+
+ async def test_filters_default_to_none_and_markdown(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: with no filters configured, the domain/time/date filters are None
+ and output_format defaults to 'markdown' (so existing configs are unaffected)."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ async with nimble_web_search(NimbleWebSearchToolConfig(), MagicMock()):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["include_domains"] is None
+ assert kwargs["exclude_domains"] is None
+ assert kwargs["time_range"] is None
+ assert kwargs["start_date"] is None
+ assert kwargs["end_date"] is None
+ assert kwargs["output_format"] == "markdown"
+
+ async def test_include_domains_passthrough(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: include_domains (a capability Tavily/Exa don't expose in AI-Q) reaches the SDK."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(include_domains=["github.com", "docs.python.org"])
+ async with nimble_web_search(config, MagicMock()):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["include_domains"] == ["github.com", "docs.python.org"]
+
+ async def test_exclude_domains_passthrough(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: exclude_domains reaches the SDK."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(exclude_domains=["pinterest.com"])
+ async with nimble_web_search(config, MagicMock()):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["exclude_domains"] == ["pinterest.com"]
+
+ async def test_time_range_passthrough(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: time_range recency filter reaches the SDK."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(time_range="week")
+ async with nimble_web_search(config, MagicMock()):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["time_range"] == "week"
+
+ async def test_date_range_passthrough(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: start_date + end_date reach the SDK together."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(start_date="2026-01-01", end_date="2026-12-31")
+ async with nimble_web_search(config, MagicMock()):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["start_date"] == "2026-01-01"
+ assert kwargs["end_date"] == "2026-12-31"
+
+ async def test_output_format_passthrough(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: a non-default output_format reaches the SDK."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ fake_langchain_nimble.ainvoke.return_value = [_FakeDoc(url="u", title="t", page_content="b")]
+
+ config = NimbleWebSearchToolConfig(output_format="plain_text")
+ async with nimble_web_search(config, MagicMock()):
+ pass
+
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["output_format"] == "plain_text"
+
+ def test_invalid_time_range_rejected(self):
+ """VERIFIES: an out-of-enum time_range is rejected by Pydantic before any network call."""
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ NimbleWebSearchToolConfig(time_range="fortnight")
+
+ def test_invalid_output_format_rejected(self):
+ """VERIFIES: an out-of-enum output_format is rejected by Pydantic."""
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ NimbleWebSearchToolConfig(output_format="xml")
+
+ def test_empty_domain_lists_rejected(self):
+ """VERIFIES: an empty include/exclude_domains list is rejected (min_length=1), so the
+ ambiguous 'zero-domain filter' never reaches the API. Use None to disable instead."""
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ NimbleWebSearchToolConfig(include_domains=[])
+ with pytest.raises(ValidationError):
+ NimbleWebSearchToolConfig(exclude_domains=[])
+
+ async def test_filter_active_with_empty_results_returns_clear_message(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: end-to-end, a filter is configured (e.g. a tight time_range or a domain
+ whitelist) and the API returns zero results — the provider returns the friendly
+ 'no results' message rather than crashing or returning an empty render. Closes the
+ full-render-path gap the passthrough tests stop short of."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ monkeypatch.setattr("nimble_web_search.register.asyncio.sleep", _no_sleep)
+ fake_langchain_nimble.ainvoke.return_value = [] # filter yielded nothing
+
+ config = NimbleWebSearchToolConfig(time_range="hour", include_domains=["example.com"], max_retries=1)
+ async with nimble_web_search(config, MagicMock()) as info:
+ out = await info.single_fn("evergreen topic that won't be in the last hour")
+
+ assert "no results" in out.lower()
+ # The filters did reach the SDK on the way to the empty result.
+ kwargs = sys.modules["langchain_nimble"].NimbleSearchRetriever.call_args.kwargs
+ assert kwargs["time_range"] == "hour"
+ assert kwargs["include_domains"] == ["example.com"]
+
+ async def test_upper_bound_result_cap_enforced(self, fake_langchain_nimble, monkeypatch):
+ """VERIFIES: when the API soft-cap returns MORE than max_results (NF-003), the provider
+ hard-caps to max_results for predictable, Tavily/Exa-consistent breadth."""
+ monkeypatch.setenv("NIMBLE_API_KEY", "sk-env")
+ # API returns 5 docs though max_results=3 (the soft-cap "too many" case)
+ fake_langchain_nimble.ainvoke.return_value = [
+ _FakeDoc(url=f"https://r{i}.example", title=f"T{i}", page_content=f"B{i}") for i in range(5)
+ ]
+
+ config = NimbleWebSearchToolConfig(max_results=3)
+ async with nimble_web_search(config, MagicMock()) as info:
+ out = await info.single_fn("q")
+
+ assert out.count("