diff --git a/docs/tools.md b/docs/tools.md index 6fcf8d3..2ac089a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -3,7 +3,7 @@ | Category | Tools | Description | |----------|-------|-------------| | **File I/O** | `bash`, `read_file`, `write_file`, `edit_file`, `glob`, `grep` | Core file operations with permission checks | -| **Web** | `web_fetch`, `web_search` | HTTP content retrieval and Brave search | +| **Web** | `web_fetch`, `web_search` | HTTP content retrieval and configurable web search (Brave or you.com) | | **Interaction** | `ask_user_question`, `skill` | User input and on-demand knowledge loading | | **Tasks** | `task_create`, `task_list`, `task_get`, `task_stop` | Background task lifecycle management | | **Scheduling** | `cron_create`, `cron_list`, `cron_delete`, `cron_toggle` | Cron job management | @@ -13,6 +13,26 @@ Every tool has **Pydantic input validation**, **self-describing JSON Schema**, **permission integration**, and **hook support**. +## Web Search Provider Setup + +`web_search` supports two providers via environment variables: + +```bash +# Default provider (backward compatible) +export WEB_SEARCH_PROVIDER=brave +export BRAVE_SEARCH_API_KEY=your_brave_key + +# Optional provider: you.com Search API +export WEB_SEARCH_PROVIDER=you +export YDC_API_KEY=your_you_api_key +``` + +Usage in prompts/workflows remains unchanged: + +```text +Use web_search with query: "latest model context protocol updates" +``` + ## Custom Tool ```python diff --git a/src/leeway/tools/web_search_tool.py b/src/leeway/tools/web_search_tool.py index d87d93f..e52da3d 100644 --- a/src/leeway/tools/web_search_tool.py +++ b/src/leeway/tools/web_search_tool.py @@ -1,7 +1,9 @@ -"""Web search tool — search the web via Brave Search API.""" +"""Web search tool — search the web via Brave or you.com Search API.""" from __future__ import annotations +import os + from pydantic import BaseModel, Field from leeway.tools.base import BaseTool, ToolExecutionContext, ToolResult @@ -17,8 +19,10 @@ class WebSearchInput(BaseModel): class WebSearchTool(BaseTool): """Search the web and return results. - Requires a Brave Search API key configured via - ``web_search_api_key`` in settings or ``BRAVE_SEARCH_API_KEY`` env var. + Provider selection: + - ``WEB_SEARCH_PROVIDER=brave`` (default) requires ``BRAVE_SEARCH_API_KEY`` + - ``WEB_SEARCH_PROVIDER=you`` supports free-tier unauthenticated search, + or set ``YDC_API_KEY`` for authenticated usage """ name = "web_search" @@ -39,42 +43,69 @@ async def execute(self, arguments: BaseModel, context: ToolExecutionContext) -> is_error=True, ) - import os - - api_key = os.environ.get("BRAVE_SEARCH_API_KEY", "") - if not api_key: + provider = os.environ.get("WEB_SEARCH_PROVIDER", "brave").strip().lower() + if provider not in {"brave", "you"}: return ToolResult( - output=( - "No search API key found. Set BRAVE_SEARCH_API_KEY environment " - "variable or configure web_search_api_key in settings." - ), + output="Unsupported WEB_SEARCH_PROVIDER. Use 'brave' or 'you'.", is_error=True, ) - try: - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.get( - "https://api.search.brave.com/res/v1/web/search", - params={"q": args.query, "count": args.num_results}, - headers={ - "Accept": "application/json", - "X-Subscription-Token": api_key, - }, + if provider == "you": + api_key = os.environ.get("YDC_API_KEY", "") + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + params = {"query": args.query, "count": args.num_results} + headers = {"X-API-Key": api_key} if api_key else {} + resp = await client.get( + "https://api.ydc-index.io/v1/search", + params=params, + headers=headers, + ) + resp.raise_for_status() + except Exception as exc: + return ToolResult(output=f"you.com search failed: {exc}", is_error=True) + + data = resp.json() + results = data.get("results", {}).get("web", []) + else: + api_key = os.environ.get("BRAVE_SEARCH_API_KEY", "") + if not api_key: + return ToolResult( + output=( + "No API key found for Brave search. Set BRAVE_SEARCH_API_KEY " + "or switch WEB_SEARCH_PROVIDER=you (optionally set YDC_API_KEY)." + ), + is_error=True, ) - resp.raise_for_status() - except Exception as exc: - return ToolResult(output=f"Search failed: {exc}", is_error=True) - data = resp.json() - results = data.get("web", {}).get("results", []) + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + "https://api.search.brave.com/res/v1/web/search", + params={"q": args.query, "count": args.num_results}, + headers={ + "Accept": "application/json", + "X-Subscription-Token": api_key, + }, + ) + resp.raise_for_status() + except Exception as exc: + return ToolResult(output=f"Brave search failed: {exc}", is_error=True) + + data = resp.json() + results = data.get("web", {}).get("results", []) + if not results: return ToolResult(output="No results found.") lines: list[str] = [] for i, r in enumerate(results[: args.num_results], 1): - title = r.get("title", "") + title = r.get("title") or r.get("name", "") url = r.get("url", "") - desc = r.get("description", "") + snippets = r.get("snippets") or [] + first_snippet = snippets[0] if snippets else "" + desc = first_snippet or r.get("description") or r.get("snippet") or "" lines.append(f"{i}. {title}\n {url}\n {desc}") return ToolResult(output="\n\n".join(lines)) diff --git a/tests/test_tools/test_web_search_tool.py b/tests/test_tools/test_web_search_tool.py new file mode 100644 index 0000000..7059dc0 --- /dev/null +++ b/tests/test_tools/test_web_search_tool.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from pathlib import Path + +import httpx +import pytest + +from leeway.tools.base import ToolExecutionContext +from leeway.tools.web_search_tool import WebSearchInput, WebSearchTool + + +class _MockResponse: + def __init__(self, payload: dict, status_code: int = 200): + self._payload = payload + self.status_code = status_code + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self._payload + + +class _MockAsyncClient: + def __init__(self, payload: dict | None = None, responses: list[_MockResponse] | None = None): + self.payload = payload or {} + self.responses = responses or [] + self.called = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url: str, params: dict, headers: dict): + self.called.append({"url": url, "params": params, "headers": headers}) + if self.responses: + return self.responses.pop(0) + return _MockResponse(self.payload) + + +@pytest.mark.asyncio +async def test_web_search_you_provider_success(monkeypatch): + tool = WebSearchTool() + context = ToolExecutionContext(cwd=Path(".")) + + monkeypatch.setenv("WEB_SEARCH_PROVIDER", "you") + monkeypatch.setenv("YDC_API_KEY", "test-key") + + mock_client = _MockAsyncClient( + { + "results": { + "web": [ + { + "title": "Result A", + "url": "https://example.com/a", + "snippets": ["Snippet A"], + "description": "Fallback description", + } + ] + } + } + ) + + monkeypatch.setattr(httpx, "AsyncClient", lambda timeout=15.0: mock_client) + + result = await tool.execute(WebSearchInput(query="test", num_results=1), context) + + assert not result.is_error + assert "Result A" in result.output + assert "https://api.ydc-index.io/v1/search" in mock_client.called[0]["url"] + assert mock_client.called[0]["params"]["query"] == "test" + assert mock_client.called[0]["params"]["count"] == 1 + + +@pytest.mark.asyncio +async def test_web_search_you_provider_without_key(monkeypatch): + tool = WebSearchTool() + context = ToolExecutionContext(cwd=Path(".")) + + monkeypatch.setenv("WEB_SEARCH_PROVIDER", "you") + monkeypatch.delenv("YDC_API_KEY", raising=False) + + mock_client = _MockAsyncClient( + { + "results": { + "web": [ + { + "title": "Result A", + "url": "https://example.com/a", + "description": "Fallback description", + } + ] + } + } + ) + monkeypatch.setattr(httpx, "AsyncClient", lambda timeout=15.0: mock_client) + + result = await tool.execute(WebSearchInput(query="test"), context) + + assert not result.is_error + assert "Result A" in result.output + assert "X-API-Key" not in mock_client.called[0]["headers"] + + +@pytest.mark.asyncio +async def test_web_search_invalid_provider(monkeypatch): + tool = WebSearchTool() + context = ToolExecutionContext(cwd=Path(".")) + + monkeypatch.setenv("WEB_SEARCH_PROVIDER", "invalid") + + result = await tool.execute(WebSearchInput(query="test"), context) + + assert result.is_error + assert "Unsupported WEB_SEARCH_PROVIDER" in result.output