Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
85 changes: 58 additions & 27 deletions src/leeway/tools/web_search_tool.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand All @@ -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))
117 changes: 117 additions & 0 deletions tests/test_tools/test_web_search_tool.py
Original file line number Diff line number Diff line change
@@ -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