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("