Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6478551cbd | |||
| 0b977d68b8 | |||
| 45d57f6a41 | |||
| 5738a2723f | |||
| e0b403fb20 | |||
| eec492a2d0 | |||
| f8548ca8b9 |
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 ───────────────────────
|
||||
|
||||
Reference in New Issue
Block a user