Compare commits

...

7 Commits

Author SHA1 Message Date
Kobi Kadosh 6478551cbd Merge branch 'main' into feat/nimble_web_search
E2E UI Tests / gate (push) Failing after 1s
E2E UI Tests / setup (push) Has been skipped
E2E UI Tests / E2E UI Tests (shard ${{ matrix.shard_id }}/${{ matrix.num_shards }}) (push) Has been skipped
2026-06-17 00:36:16 -07:00
Kobi Kadosh 0b977d68b8 fix(web_search): restore web_search dispatch handler in tool_dispatch
The _execute_web_search_tool handler and the _WEB_SEARCH_TOOLS entry in
_ALL_LOCAL_TOOLS were missing from this branch, so web_search fell through
to _execute_spec_callable_tool and returned a dispatch error. Re-add them
so web_search resolves to its configured backend via WebSearchTool.invoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
2026-06-14 05:50:19 -07:00
Kobi Kadosh 45d57f6a41 Merge remote-tracking branch 'origin/main' into feat/nimble_web_search
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
2026-06-14 05:45:19 -07:00
Kobi Kadosh 5738a2723f fix(web_search_nimble): guard search_depth, keep answer on empty results
Address review findings: reject an unsupported search_depth (e.g. the
enterprise-only 'fast') with a clear error instead of an opaque HTTP 403; and
stop discarding a non-null 'answer' when the results list is empty. Add tests
for the HTTP-error path, answer-on-empty-results, search_depth rejection, and
max_results coercion/clamping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
2026-06-14 05:00:31 -07:00
Kobi Kadosh e0b403fb20 docs(web_search): document the nimble search_provider
Add Nimble to the tools.builtins web_search docs in AGENTSPEC.md, mirroring the
google/perplexity entries: a config example (search_provider: nimble, api_key,
optional max_results and search_depth) plus a backend-selection note describing
what it returns and that it works with any non-OpenAI model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
2026-06-14 04:07:39 -07:00
Kobi Kadosh eec492a2d0 feat: add nimble web_search provider
Add a Nimble backend for the unified web_search builtin, selectable via
search_provider: nimble. Mirrors the existing google/perplexity backends:
a raw httpx call, api_key read from spec config (no env fallback), results
returned as a formatted string, and errors returned as strings.

The backend calls Nimble's AI search endpoint (POST /v1/search) and formats
the result list (title, url, snippet) like the Google backend. It defaults
to the standard lite tier and reads an optional max_results from config. A
non-null answer field, when present, is shown first.

Wires a nimble dispatch branch and a _run_nimble helper into web_search.py
and documents the backend in the module docstring and help text. Includes
unit tests for the result list, the answer-first case, the missing-key
error, and spec-config passthrough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
2026-06-14 04:07:39 -07:00
Kobi Kadosh f8548ca8b9 fix(web_search): dispatch web_search to its backend on non-OpenAI models
web_search had no handler in the runner's execute_tool dispatch table, so a
non-OpenAI model's web_search function call fell through to the spec-callable
branch and errored as unavailable — no backend (google/perplexity/nimble) ran.
The async DBOS dispatch was removed and never rewired to a synchronous path.

Add _execute_web_search_tool, mirroring _execute_web_fetch_tool, and register
web_search as a runner-local tool so dispatch routes there. The handler infers
llm_provider exactly as ToolManager._create_web_search does, so the dispatch
path keeps the same invariants as session setup: OpenAI models keep the native
web_search_preview passthrough (invoke() raises its fence; the backend is never
run), and databricks-* models skip passthrough and run in function-tool mode.

Tests assert web_search is runner-local, not relayed to native harnesses, the
OpenAI passthrough fence holds, and databricks-* uses function mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kobi Kadosh <kobi.kadosh@gmail.com>
2026-06-14 04:07:28 -07:00
6 changed files with 516 additions and 8 deletions
+95
View File
@@ -213,6 +213,15 @@ _SESSION_QUERY_TOOLS = frozenset(
# ``_execute_subagent_tool``.
_WEB_FETCH_TOOLS = frozenset({"web_fetch"})
# Priority 5f.1b: web_search — the first-party search builtin. Runner-local
# so a non-OpenAI model's web_search function call resolves to the spec's
# configured backend (google / perplexity / nimble) via WebSearchTool.invoke.
# (OpenAI models use the native web_search_preview passthrough and never reach
# this path.) Without this entry the call fell through to the spec-callable
# branch and errored "tool unavailable" — the gap behind the non-OpenAI
# web_search known-failure.
_WEB_SEARCH_TOOLS = frozenset({"web_search"})
# Priority 5f.2: sys_list_models — runner-local because provider resolution
# reads the runner host's config/credentials, same as the spawn paths.
_LIST_MODELS_TOOLS = frozenset({"sys_list_models"})
@@ -309,6 +318,7 @@ _ALL_LOCAL_TOOLS = (
| _SESSION_CREATE_TOOLS
| _SESSION_QUERY_TOOLS
| _WEB_FETCH_TOOLS
| _WEB_SEARCH_TOOLS
| _TIMER_TOOLS
| _TASK_LIFECYCLE_TOOLS
| _SKILL_TOOLS
@@ -1853,6 +1863,83 @@ async def _execute_web_fetch_tool(
)
def _web_search_config_from_spec(agent_spec: Any | None) -> dict[str, str]:
"""
Return the ``web_search`` builtin's config dict from the parent spec.
Mirrors ``ToolManager._register_builtin_tools``: scans
``spec.tools.builtins`` for the entry named ``"web_search"`` and returns
its ``config`` (``search_provider`` + credentials). Empty dict when the
builtin is declared as a bare string or absent.
:param agent_spec: Parent agent's spec, or ``None``.
:returns: The web_search config dict, e.g.
``{"search_provider": "nimble", "api_key": "..."}``.
"""
if agent_spec is None:
return {}
tools = getattr(agent_spec, "tools", None)
builtins = getattr(tools, "builtins", None) or []
for entry in builtins:
if getattr(entry, "name", None) == "web_search":
return getattr(entry, "config", None) or {}
return {}
async def _execute_web_search_tool(
args: dict[str, Any],
*,
agent_spec: Any | None,
conversation_id: str | None = None,
task_id: str | None = None,
agent_id: str | None = None,
) -> str:
"""
Dispatch a ``web_search`` tool call to the spec's configured backend.
Builds ``WebSearchTool`` from the spec's ``web_search`` builtin config and
runs its synchronous ``invoke`` off the event loop (the backend makes a
blocking HTTP call).
``llm_provider`` is inferred exactly as ``ToolManager._create_web_search``
does, so the dispatch path preserves the same invariants as session setup:
- **OpenAI models** keep the native ``web_search_preview`` passthrough; if a
``web_search`` function call ever reached this path, ``invoke()`` raises
(its built-in fence) and the third-party backend is never run. In normal
operation OpenAI models never emit a ``web_search`` function call, so this
is defensive — but it keeps the promise rather than silently weakening it.
- **``databricks-*`` models** skip provider inference (they don't support
``web_search_preview``) and run in function-tool mode.
:param args: Parsed LLM arguments — ``query`` (required).
:param agent_spec: Parent agent's spec; carries the web_search config + model.
:param conversation_id: Parent session id, threaded into the context.
:param task_id: Calling task id, threaded into the context.
:param agent_id: Calling agent id, threaded into the context.
:returns: The formatted search results, or an error string.
"""
from omnigent.tools.base import ToolContext
from omnigent.tools.builtins.web_search import WebSearchTool
config = _web_search_config_from_spec(agent_spec)
# Mirror ToolManager._create_web_search's provider inference (same skip for
# databricks-*, same OpenAI passthrough fence) so dispatch honors session-setup invariants.
llm_provider: str | None = None
model = getattr(getattr(agent_spec, "executor", None), "model", None)
if model and not model.startswith("databricks-"):
from omnigent.llms.routing import parse_model_string
llm_provider = parse_model_string(model).provider
tool = WebSearchTool(config=config, llm_provider=llm_provider)
ctx = ToolContext(
task_id=task_id or "web_search",
agent_id=agent_id or "web_search",
conversation_id=conversation_id,
)
return await asyncio.to_thread(tool.invoke, json.dumps(args), ctx)
def _has_subagent(
sub_agent_name: str,
agent_spec: Any | None,
@@ -3505,6 +3592,14 @@ async def execute_tool(
publish_event=publish_event,
session_inbox=session_inbox,
)
elif tool_name in _WEB_SEARCH_TOOLS:
output = await _execute_web_search_tool(
args,
agent_spec=agent_spec,
conversation_id=conversation_id,
task_id=task_id,
agent_id=agent_id,
)
elif tool_name in _TIMER_TOOLS:
if tool_name == "sys_timer_set":
output = await _execute_timer_set(
+11 -3
View File
@@ -133,6 +133,10 @@ tools:
- name: web_search # dict — explicit Perplexity
search_provider: perplexity
api_key: ${PERPLEXITY_API_KEY}
- name: web_search # dict — explicit Nimble
search_provider: nimble
api_key: ${NIMBLE_API_KEY}
# optional: max_results (1-100, default 5); search_depth (lite | deep)
```
Keys can be hardcoded or use `${ENV_VAR}` references (resolved at deploy time
@@ -143,9 +147,13 @@ by the client, not at runtime by the server — the spec is self-contained).
- **OpenAI models:** `web_search` works automatically with no config —
it uses OpenAI's native `web_search_preview` (server-side). Just add
`- web_search` to builtins.
- **Other models:** `search_provider` must be set to `"google"` or
`"perplexity"` with credentials. All config comes from the spec (no
environment variable fallbacks).
- **Other models:** `search_provider` must be set to `"google"`,
`"perplexity"`, or `"nimble"` with credentials. All config comes from the
spec (no environment variable fallbacks).
- **Nimble** (`search_provider: nimble`): returns a ranked list of titles,
URLs, and snippets from Nimble's AI search API. Requires `api_key`; optional
`max_results` (1-100, default 5) and `search_depth` (`lite` default, or
`deep`). Works with any non-OpenAI model.
**`web_fetch` — zero-config web research:** Spawns an internal sub-agent with
`terminal_run` to search the web and fetch pages using plain HTTP. No API keys
+29 -5
View File
@@ -5,8 +5,9 @@ Backend selection is fully determined by the agent spec:
- **OpenAI model** → passthrough to OpenAI's native
``web_search_preview`` (server-side, uses the LLM API key).
- **Other models** → requires ``search_provider`` in config
(``"google"`` or ``"perplexity"``) with the appropriate
credentials. No env var fallbacks — the spec is self-contained.
(``"google"``, ``"perplexity"``, or ``"nimble"``) with the
appropriate credentials. No env var fallbacks — the spec is
self-contained.
Usage in config.yaml::
@@ -176,7 +177,7 @@ def _search(query: str, config: dict[str, str]) -> str:
:param query: The search query string.
:param config: Spec-level config. Required keys:
- ``search_provider``: ``"google"`` or ``"perplexity"``
- ``search_provider``: ``"google"``, ``"perplexity"``, or ``"nimble"``
- ``api_key``: API key for the chosen backend
- ``engine_id``: Required for Google only
@@ -190,6 +191,9 @@ def _search(query: str, config: dict[str, str]) -> str:
if backend == "perplexity":
return _run_perplexity(query, config)
if backend == "nimble":
return _run_nimble(query, config)
return (
"web_search requires configuration for non-OpenAI models. "
"(For OpenAI models, web_search works automatically with no "
@@ -198,11 +202,12 @@ def _search(query: str, config: dict[str, str]) -> str:
" tools:\n"
" builtins:\n"
" - name: web_search\n"
" search_provider: perplexity # or google\n"
" search_provider: perplexity # or google, nimble\n"
" api_key: ${PERPLEXITY_API_KEY}\n\n"
"Supported backends:\n"
" - google (requires api_key + engine_id)\n"
" - perplexity (requires api_key)"
" - perplexity (requires api_key)\n"
" - nimble (requires api_key)"
)
@@ -243,3 +248,22 @@ def _run_perplexity(query: str, config: dict[str, str]) -> str:
return "Perplexity web search requires api_key in the web_search config."
return _search_perplexity(query, config)
def _run_nimble(query: str, config: dict[str, str]) -> str:
"""
Run a Nimble web search query using spec config credentials.
:param query: The search query.
:param config: Must contain ``api_key``.
:returns: Formatted results or an error message.
"""
from omnigent.tools.builtins.web_search_nimble import (
_search_nimble,
)
api_key = config.get("api_key")
if not api_key:
return "Nimble web search requires api_key in the web_search config."
return _search_nimble(query, config)
@@ -0,0 +1,142 @@
"""Built-in tool: Nimble web search.
Uses Nimble's AI web search endpoint (``POST /v1/search``) to return a
list of grounded results (title, URL, snippet). Good for non-OpenAI
models (Anthropic, Llama, Databricks-hosted, etc.) that cannot use
OpenAI's native ``web_search_preview``.
Configured in the agent spec::
tools:
builtins:
- name: web_search
search_provider: nimble
api_key: ${NIMBLE_API_KEY}
# optional:
# max_results: 5 # 1-100 (default 5)
# search_depth: lite # lite (default) or deep
See https://docs.nimbleway.com/api-reference/search/search
"""
from __future__ import annotations
import logging
import os
# Any: Nimble's JSON response is a heterogeneous dict with string keys
# and mixed value types (str, int, list, dict, None).
from typing import Any
import httpx
_logger = logging.getLogger(__name__)
_DEFAULT_NIMBLE_URL = "https://sdk.nimbleway.com/v1/search"
# Default number of results when the spec does not set ``max_results``.
# Nimble accepts 1-100; the API's own default is small (3).
_DEFAULT_MAX_RESULTS: int = 5
# Supported search tiers. Non-default values are validated against this allowlist
# so a misconfigured spec gets a clear error rather than an opaque API failure.
_DEFAULT_SEARCH_DEPTH = "lite"
_VALID_SEARCH_DEPTHS = frozenset({"lite", "deep"})
def _nimble_url() -> str:
"""Resolve the Nimble Search URL; ``OMNIGENT_NIMBLE_BASE_URL`` overrides for tests."""
return os.environ.get("OMNIGENT_NIMBLE_BASE_URL", _DEFAULT_NIMBLE_URL)
def _resolve_max_results(config: dict[str, str]) -> int:
"""
Read ``max_results`` from spec config, clamped to Nimble's 1-100 range.
:param config: Spec-level config; ``max_results`` may be a str or int.
:returns: A valid result count, or the default on missing/invalid input.
"""
raw = config.get("max_results")
if raw is None:
return _DEFAULT_MAX_RESULTS
try:
value = int(raw)
except (TypeError, ValueError):
return _DEFAULT_MAX_RESULTS
return max(1, min(value, 100))
def _search_nimble(
query: str,
config: dict[str, str],
) -> str:
"""
Call the Nimble AI web search API and format the results.
:param query: The search query string.
:param config: Spec-level config; checked for ``api_key`` (required),
``max_results`` and ``search_depth`` (optional).
:returns: Formatted results or an error message.
"""
api_key = config.get("api_key")
if not api_key:
return "Error: api_key must be provided in the web_search config in config.yaml."
search_depth = config.get("search_depth", _DEFAULT_SEARCH_DEPTH)
if search_depth not in _VALID_SEARCH_DEPTHS:
return (
f"Error: unsupported search_depth {search_depth!r}. "
f"Use one of: {', '.join(sorted(_VALID_SEARCH_DEPTHS))}."
)
try:
resp = httpx.post(
_nimble_url(),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"query": query,
"max_results": _resolve_max_results(config),
"search_depth": search_depth,
},
timeout=30.0,
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
return f"Nimble search error: HTTP {exc.response.status_code}"
except (httpx.ConnectError, httpx.TimeoutException) as exc:
return f"Nimble search error: {exc}"
return _format_results(resp.json())
def _format_results(data: dict[str, Any]) -> str:
"""
Format Nimble's ``/v1/search`` JSON response into readable text.
Nimble returns ``{"results": [{"title", "url", "description",
"content", ...}], "answer": str | None, ...}``. In the ``lite``
tier ``content`` is absent and ``description`` carries the snippet,
so we prefer ``content`` and fall back to ``description``. If the
response includes a non-null ``answer``, it is shown first.
:param data: The parsed JSON response from Nimble.
:returns: An optional answer followed by numbered results.
"""
results = data.get("results", [])
answer = data.get("answer")
if not results:
# Don't discard an answer just because the result list is empty.
return answer or "No results found."
formatted: list[str] = []
for i, item in enumerate(results):
title = item.get("title", "")
url = item.get("url", "")
snippet = item.get("content") or item.get("description") or ""
formatted.append(f"{i + 1}. {title}\n {url}\n {snippet}")
body = "\n\n".join(formatted)
if answer:
return f"{answer}\n\n{body}"
return body
@@ -0,0 +1,79 @@
"""Blast-radius tests for routing ``web_search`` through runner-local dispatch.
These lock in the expectations behind the ``web_search`` dispatch fix: the tool
must be runner-local (so a non-OpenAI model's call resolves to its backend), and
it must NOT be advertised to native harnesses (which use their own web search).
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from omnigent.runner.tool_dispatch import (
_ALL_LOCAL_TOOLS,
_NATIVE_RELAY_BUILTIN_TOOLS,
_execute_web_search_tool,
should_dispatch_locally,
)
def _spec_with_model(model: str) -> SimpleNamespace:
"""Minimal agent_spec stub: an executor model + a web_search builtin config.
Uses the in-tree ``perplexity`` backend so this test stands alone (the
dispatch fix is provider-agnostic; the invariants under test are too).
"""
return SimpleNamespace(
executor=SimpleNamespace(model=model),
tools=SimpleNamespace(
builtins=[
SimpleNamespace(
name="web_search",
config={"search_provider": "perplexity", "api_key": "k"},
)
]
),
)
def test_web_search_is_runner_local() -> None:
"""``web_search`` dispatches locally, like ``web_fetch``."""
assert "web_search" in _ALL_LOCAL_TOOLS
assert should_dispatch_locally("web_search") is True
def test_web_search_not_relayed_to_native_harnesses() -> None:
"""Native harnesses (claude-native / codex-native) use their own web search."""
assert "web_search" not in _NATIVE_RELAY_BUILTIN_TOOLS
def test_dispatch_preserves_openai_passthrough_fence() -> None:
"""
For an OpenAI model the handler builds the tool in passthrough mode, so
``invoke()`` raises its fence — the third-party backend is NEVER called.
"""
spec = _spec_with_model("gpt-5.4-mini") # provider → openai
with patch("omnigent.tools.builtins.web_search_perplexity.httpx.post") as mock_post:
with pytest.raises(RuntimeError, match="passthrough"):
asyncio.run(
_execute_web_search_tool({"query": "x"}, agent_spec=spec, conversation_id="c")
)
assert mock_post.call_count == 0, "OpenAI passthrough must never hit a search backend."
def test_dispatch_databricks_model_uses_function_mode() -> None:
"""A ``databricks-*`` model skips passthrough and runs the configured backend."""
spec = _spec_with_model("databricks-claude-sonnet-4-6")
fake_response = MagicMock()
fake_response.json.return_value = {"choices": [{"message": {"content": "answer"}}]}
with patch("omnigent.tools.builtins.web_search_perplexity.httpx.post") as mock_post:
mock_post.return_value = fake_response
result = asyncio.run(
_execute_web_search_tool({"query": "x"}, agent_spec=spec, conversation_id="c")
)
assert mock_post.call_count == 1, "databricks-* must run the backend (function mode)."
assert "answer" in result
+160
View File
@@ -5,11 +5,13 @@ from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import httpx
import pytest
from omnigent.tools.base import ToolContext
from omnigent.tools.builtins import get_builtin_tool
from omnigent.tools.builtins.web_search import WebSearchTool
from omnigent.tools.builtins.web_search_nimble import _resolve_max_results
# ── Registry ─────────────────────────────────────────
@@ -184,6 +186,163 @@ def test_perplexity_missing_key_returns_error(tool_ctx: ToolContext) -> None:
assert "api_key" in result
# ── search_provider: nimble ──────────────────────────
def test_nimble_backend_via_spec_config(tool_ctx: ToolContext) -> None:
"""
With search_provider=nimble and api_key in spec config,
the tool delegates to Nimble AI web search.
"""
fake_response = MagicMock()
fake_response.json.return_value = {
"results": [
{
"title": "Nimble Docs",
"url": "https://docs.nimbleway.com",
"description": "Web data platform.",
},
],
"answer": None,
"total_results": 1,
}
tool = WebSearchTool(
config={
"search_provider": "nimble",
"api_key": "spec-nimble-key",
},
llm_provider="anthropic",
)
with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post:
mock_post.return_value = fake_response
result = tool.invoke(json.dumps({"query": "nimble"}), tool_ctx)
# Nimble result list made it through the unified tool pipeline.
assert "1. Nimble Docs" in result
assert "https://docs.nimbleway.com" in result
assert "Web data platform." in result
def test_nimble_answer_shown_first_when_present(tool_ctx: ToolContext) -> None:
"""
A non-null ``answer`` is shown before the result list.
"""
fake_response = MagicMock()
fake_response.json.return_value = {
"answer": "Nimble is a web data platform.",
"results": [
{"title": "Home", "url": "https://nimbleway.com", "description": "..."},
],
}
tool = WebSearchTool(
config={"search_provider": "nimble", "api_key": "k"},
llm_provider="anthropic",
)
with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post:
mock_post.return_value = fake_response
result = tool.invoke(json.dumps({"query": "nimble"}), tool_ctx)
assert result.startswith("Nimble is a web data platform.")
assert "1. Home" in result
def test_nimble_missing_key_returns_error(tool_ctx: ToolContext) -> None:
"""
With search_provider=nimble but no api_key, returns error.
"""
tool = WebSearchTool(
config={"search_provider": "nimble"},
llm_provider="anthropic",
)
result = tool.invoke(json.dumps({"query": "test"}), tool_ctx)
assert "api_key" in result
def test_nimble_spec_config_used_in_http_call(tool_ctx: ToolContext) -> None:
"""
api_key from spec config is sent as a Bearer header, and the
request body carries query / max_results / search_depth.
"""
fake_response = MagicMock()
fake_response.json.return_value = {"results": []}
tool = WebSearchTool(
config={
"search_provider": "nimble",
"api_key": "spec-nimble",
"max_results": "7",
},
llm_provider="anthropic",
)
with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post:
mock_post.return_value = fake_response
tool.invoke(json.dumps({"query": "test"}), tool_ctx)
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer spec-nimble", (
f"Expected spec config api_key in header, got {headers['Authorization']!r}"
)
body = mock_post.call_args.kwargs["json"]
assert body["query"] == "test"
# max_results comes from config as a str ("7") and must be coerced to int.
assert body["max_results"] == 7, f"Expected int 7, got {body['max_results']!r}"
# Default tier is 'lite'.
assert body["search_depth"] == "lite"
def test_nimble_http_error_returns_error_string(tool_ctx: ToolContext) -> None:
"""An HTTP error (e.g. 401) is returned as a string, never raised."""
fake_response = MagicMock()
fake_response.status_code = 401
tool = WebSearchTool(
config={"search_provider": "nimble", "api_key": "k"},
llm_provider="anthropic",
)
with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post:
mock_post.side_effect = httpx.HTTPStatusError(
"401", request=MagicMock(), response=fake_response
)
result = tool.invoke(json.dumps({"query": "test"}), tool_ctx)
assert "Nimble search error" in result
assert "401" in result
def test_nimble_answer_kept_when_no_results(tool_ctx: ToolContext) -> None:
"""A non-null ``answer`` is returned even when ``results`` is empty."""
fake_response = MagicMock()
fake_response.json.return_value = {"answer": "Direct answer.", "results": []}
tool = WebSearchTool(
config={"search_provider": "nimble", "api_key": "k"},
llm_provider="anthropic",
)
with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post:
mock_post.return_value = fake_response
result = tool.invoke(json.dumps({"query": "test"}), tool_ctx)
assert result == "Direct answer.", f"Answer must not be dropped, got {result!r}"
def test_nimble_rejects_unsupported_search_depth(tool_ctx: ToolContext) -> None:
"""An unsupported ``search_depth`` is rejected with a clear error, no HTTP call."""
tool = WebSearchTool(
config={"search_provider": "nimble", "api_key": "k", "search_depth": "fast"},
llm_provider="anthropic",
)
with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post:
result = tool.invoke(json.dumps({"query": "test"}), tool_ctx)
assert "search_depth" in result
assert mock_post.call_count == 0, "Must not call the API for an invalid search_depth."
def test_nimble_max_results_clamped() -> None:
"""``max_results`` is coerced + clamped to Nimble's 1-100 range; junk → default."""
assert _resolve_max_results({}) == 5 # missing → default
assert _resolve_max_results({"max_results": "0"}) == 1 # below min → clamped up
assert _resolve_max_results({"max_results": "500"}) == 100 # above max → clamped down
assert _resolve_max_results({"max_results": "abc"}) == 5 # non-numeric → default
# ── No search_provider set ───────────────────────────
@@ -198,6 +357,7 @@ def test_no_search_provider_returns_help_message(tool_ctx: ToolContext) -> None:
assert "search_provider" in result, f"Should tell user to set search_provider. Got: {result}"
assert "google" in result.lower()
assert "perplexity" in result.lower()
assert "nimble" in result.lower()
# ── Spec config passed through ───────────────────────