From ac6dfa8515306555ef21c5360c847f1dc78fdd60 Mon Sep 17 00:00:00 2001 From: Teo Gonzalez Date: Sun, 26 Apr 2026 12:20:29 -0700 Subject: [PATCH] feat: add Exa AI-powered search plugin --- .env.example | 4 + docs/exa-plugin.md | 67 +++++++++++ pyproject.toml | 1 + tests/test_plugins.py | 207 +++++++++++++++++++++++++++++++++ tinyloom.example.yaml | 1 + tinyloom/plugins/exa_search.py | 147 +++++++++++++++++++++++ uv.lock | 24 +++- 7 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 docs/exa-plugin.md create mode 100644 tinyloom/plugins/exa_search.py diff --git a/.env.example b/.env.example index 16174df..822932b 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ ANTHROPIC_API_KEY=sk-ant-... # OpenAI (when provider=openai) OPENAI_API_KEY=sk-... + +# Exa (when using tinyloom.plugins.exa_search) +EXA_API_KEY= + diff --git a/docs/exa-plugin.md b/docs/exa-plugin.md new file mode 100644 index 0000000..4fcdf00 --- /dev/null +++ b/docs/exa-plugin.md @@ -0,0 +1,67 @@ +# Exa Search Plugin + +The Exa plugin registers a `web_search` tool backed by [Exa](https://exa.ai)'s web search API. It gives the agent access to live web results with optional highlights, summaries, and full page text. + +## Setup + +1. Install the optional dependency: + +```bash +uv add 'tinyloom[exa]' +``` + +2. Set your API key (get one at [dashboard.exa.ai](https://dashboard.exa.ai)): + +```bash +export EXA_API_KEY=... +``` + +Or add it to a `.env` file at the project root. + +3. Enable the plugin in `tinyloom.yaml`: + +```yaml +plugins: + - tinyloom.plugins.exa_search +``` + +If `EXA_API_KEY` is not set when the plugin activates, it logs a warning and skips registering the tool. Tinyloom keeps running. + +## What the agent sees + +The plugin registers a single tool named `web_search`. The agent provides a `query` and optional filters; the tool returns a markdown-formatted list of hits with title, URL, publish date, author, and content (highlights, summary, or text — whichever was requested). + +## Parameters + +| Parameter | Type | Notes | +|---|---|---| +| `query` | string (required) | Search query | +| `num_results` | integer | Default 5, max 100 | +| `type` | string | `auto` (default), `neural`, `fast`, `deep`, `deep-lite`, `deep-reasoning`, `instant` | +| `include_domains` / `exclude_domains` | array of strings | Domain allow/deny lists | +| `include_text` / `exclude_text` | array of strings | Phrase must/must not appear in result | +| `category` | string | e.g. `company`, `research paper`, `news`, `personal site`, `financial report`, `people` | +| `start_published_date` / `end_published_date` | ISO 8601 | Date range filter | +| `user_location` | string | Two-letter ISO country code | +| `text` | bool or object | Return page text (default `true`) | +| `highlights` | bool or object | Return relevance-ranked snippets (default `true`) | +| `summary` | bool or object | Return an LLM-generated summary (default `false`) | + +`text`, `highlights`, and `summary` can be combined in a single request — Exa supports all three simultaneously. + +## Content fallback + +For each result, the plugin renders the first non-empty content field in this order: `highlights` → `summary` → `text` (truncated to 1500 chars). This keeps output compact when the agent only needs a snippet. + +## Example + +```yaml +plugins: + - tinyloom.plugins.exa_search +``` + +``` +> find recent papers on test-time compute scaling + +[agent calls web_search with query="test-time compute scaling", category="research paper", start_published_date="2024-01-01"] +``` diff --git a/pyproject.toml b/pyproject.toml index 579f3f1..744936e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ [project.optional-dependencies] mcp = ["mcp>=1.0,<2"] +exa = ["exa-py>=2.0.0"] dev = ["pytest", "pytest-asyncio", "ruff"] [project.scripts] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 6f14773..0982d43 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -308,3 +308,210 @@ def test_hook_scripts_string_command_format(): }) hook_scripts_activate(agent) assert len(agent.hooks._hooks.get("tool_call", [])) == 1 + + +# --------------------------------------------------------------------------- +# Exa Search Plugin tests +# --------------------------------------------------------------------------- + +class _FakeResult: + def __init__(self, title="", url="", text=None, highlights=None, summary=None, published_date=None, author=None): + self.title = title + self.url = url + self.text = text + self.highlights = highlights + self.summary = summary + self.published_date = published_date + self.author = author + +class _FakeResponse: + def __init__(self, results): + self.results = results + +class _FakeExaClient: + last_call: dict | None = None + + def __init__(self, api_key=None): + self.api_key = api_key + self.headers: dict = {} + + def search_and_contents(self, query, **kwargs): + _FakeExaClient.last_call = {"query": query, "kwargs": kwargs, "headers": dict(self.headers)} + return _FakeResponse([ + _FakeResult(title="Hit One", url="https://example.com/1", highlights=["snippet a", "snippet b"], published_date="2026-04-01"), + _FakeResult(title="Hit Two", url="https://example.com/2", summary="A summary of the page."), + _FakeResult(title="Hit Three", url="https://example.com/3", text="Long text content."), + ]) + + +def _install_fake_exa(monkeypatch): + fake_module = types.ModuleType("exa_py") + fake_module.Exa = _FakeExaClient + monkeypatch.setitem(sys.modules, "exa_py", fake_module) + _FakeExaClient.last_call = None + + +def test_exa_activate_skips_without_api_key(monkeypatch, capsys): + from tinyloom.plugins.exa_search import activate + monkeypatch.delenv("EXA_API_KEY", raising=False) + agent = _make_agent() + activate(agent) + assert agent.tools.get("web_search") is None + assert "EXA_API_KEY not set" in capsys.readouterr().err + + +def test_exa_activate_registers_tool(monkeypatch): + from tinyloom.plugins.exa_search import activate + monkeypatch.setenv("EXA_API_KEY", "test-key") + agent = _make_agent() + activate(agent) + tool = agent.tools.get("web_search") + assert tool is not None + assert "Exa" in tool.description + assert tool.input_schema["required"] == ["query"] + + +async def test_exa_search_executes_and_renders_hits(monkeypatch): + from tinyloom.plugins.exa_search import activate + monkeypatch.setenv("EXA_API_KEY", "test-key") + _install_fake_exa(monkeypatch) + agent = _make_agent() + activate(agent) + out = await agent.tools.execute("web_search", {"query": "claude opus"}) + assert "Hit One" in out + assert "https://example.com/1" in out + assert "snippet a" in out + assert "A summary of the page." in out + assert "Long text content." in out + + +async def test_exa_search_sets_integration_header(monkeypatch): + from tinyloom.plugins.exa_search import activate, INTEGRATION_HEADER + monkeypatch.setenv("EXA_API_KEY", "test-key") + _install_fake_exa(monkeypatch) + agent = _make_agent() + activate(agent) + await agent.tools.execute("web_search", {"query": "anything"}) + assert _FakeExaClient.last_call is not None + assert _FakeExaClient.last_call["headers"]["x-exa-integration"] == INTEGRATION_HEADER + assert INTEGRATION_HEADER == "tinyloom" + + +async def test_exa_search_passes_filters_through(monkeypatch): + from tinyloom.plugins.exa_search import activate + monkeypatch.setenv("EXA_API_KEY", "test-key") + _install_fake_exa(monkeypatch) + agent = _make_agent() + activate(agent) + await agent.tools.execute("web_search", { + "query": "ai search", + "num_results": 3, + "type": "neural", + "include_domains": ["exa.ai"], + "category": "company", + "start_published_date": "2025-01-01", + "summary": True, + }) + call = _FakeExaClient.last_call + assert call["query"] == "ai search" + assert call["kwargs"]["num_results"] == 3 + assert call["kwargs"]["type"] == "neural" + assert call["kwargs"]["include_domains"] == ["exa.ai"] + assert call["kwargs"]["category"] == "company" + assert call["kwargs"]["start_published_date"] == "2025-01-01" + assert call["kwargs"]["summary"] is True + + +async def test_exa_search_missing_query(monkeypatch): + from tinyloom.plugins.exa_search import activate + monkeypatch.setenv("EXA_API_KEY", "test-key") + _install_fake_exa(monkeypatch) + agent = _make_agent() + activate(agent) + out = await agent.tools.execute("web_search", {}) + assert "Error" in out + assert "query" in out + + +async def test_exa_search_no_results(monkeypatch): + from tinyloom.plugins.exa_search import activate + monkeypatch.setenv("EXA_API_KEY", "test-key") + _install_fake_exa(monkeypatch) + + class _EmptyClient(_FakeExaClient): + def search_and_contents(self, query, **kwargs): + return _FakeResponse([]) + + sys.modules["exa_py"].Exa = _EmptyClient + agent = _make_agent() + activate(agent) + out = await agent.tools.execute("web_search", {"query": "nothing"}) + assert "No results" in out + + +async def test_exa_search_handles_sdk_exception(monkeypatch): + from tinyloom.plugins.exa_search import activate + monkeypatch.setenv("EXA_API_KEY", "test-key") + _install_fake_exa(monkeypatch) + + class _BoomClient(_FakeExaClient): + def search_and_contents(self, query, **kwargs): + raise RuntimeError("boom") + + sys.modules["exa_py"].Exa = _BoomClient + agent = _make_agent() + activate(agent) + out = await agent.tools.execute("web_search", {"query": "anything"}) + assert "Error" in out + assert "boom" in out + + +async def test_exa_search_handles_missing_sdk(monkeypatch): + from tinyloom.plugins.exa_search import activate + monkeypatch.setenv("EXA_API_KEY", "test-key") + monkeypatch.delitem(sys.modules, "exa_py", raising=False) + # Block real import too, in case exa-py is installed in the test env. + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "exa_py": raise ImportError("not installed") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + agent = _make_agent() + activate(agent) + out = await agent.tools.execute("web_search", {"query": "anything"}) + assert "exa-py" in out + assert "uv add" in out + + +def test_exa_extract_content_prefers_highlights(): + from tinyloom.plugins.exa_search import _extract_content + r = _FakeResult(highlights=["a", "b"], summary="ignore", text="ignore") + assert _extract_content(r) == "- a\n- b" + + +def test_exa_extract_content_falls_back_to_summary(): + from tinyloom.plugins.exa_search import _extract_content + r = _FakeResult(highlights=None, summary=" hello ", text="ignore") + assert _extract_content(r) == "hello" + + +def test_exa_extract_content_falls_back_to_text(): + from tinyloom.plugins.exa_search import _extract_content + r = _FakeResult(highlights=None, summary=None, text="just text") + assert _extract_content(r) == "just text" + + +def test_exa_extract_content_truncates_long_text(): + from tinyloom.plugins.exa_search import _extract_content + r = _FakeResult(text="x" * 5000) + out = _extract_content(r) + assert out.endswith("…") + assert len(out) <= 1502 + + +def test_exa_extract_content_empty(): + from tinyloom.plugins.exa_search import _extract_content + assert _extract_content(_FakeResult()) == "" diff --git a/tinyloom.example.yaml b/tinyloom.example.yaml index 6cc5adc..c1201a4 100644 --- a/tinyloom.example.yaml +++ b/tinyloom.example.yaml @@ -29,6 +29,7 @@ compaction: # - tinyloom.plugins.subagent # - tinyloom.plugins.todo # - tinyloom.plugins.mcp +# - tinyloom.plugins.exa_search # - tinyloom.plugins.hook_scripts # - tinyloom.plugins.mask diff --git a/tinyloom/plugins/exa_search.py b/tinyloom/plugins/exa_search.py new file mode 100644 index 0000000..e07262f --- /dev/null +++ b/tinyloom/plugins/exa_search.py @@ -0,0 +1,147 @@ +"""Exa search plugin — registers a `web_search` tool backed by Exa's web search API. + +Config (tinyloom.yaml): + plugins: + - tinyloom.plugins.exa_search + +Env: + EXA_API_KEY=... + +Install: + uv add 'tinyloom[exa]' +""" +from __future__ import annotations +import os, sys +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any +from tinyloom.core.tools import Tool + +if TYPE_CHECKING: + from tinyloom.core.agent import Agent + +INTEGRATION_HEADER = "tinyloom" + +@dataclass +class SearchHit: + title: str + url: str + published_date: str | None + author: str | None + content: str + + def render(self) -> str: + head = f"## {self.title or '(untitled)'}\n{self.url}" + meta = " · ".join(x for x in (self.published_date, self.author) if x) + if meta: head += f"\n{meta}" + if self.content: head += f"\n\n{self.content}" + return head + +def _extract_content(result: Any) -> str: + # Cascade through whatever the API returned: highlights → summary → text. + highlights = getattr(result, "highlights", None) or [] + if highlights: return "\n".join(f"- {h}" for h in highlights) + summary = getattr(result, "summary", None) + if summary: return str(summary).strip() + text = getattr(result, "text", None) + if text: + text = str(text).strip() + return text if len(text) <= 1500 else text[:1500] + "…" + return "" + +def _hit_from_result(result: Any) -> SearchHit: + return SearchHit( + title=getattr(result, "title", "") or "", + url=getattr(result, "url", "") or "", + published_date=getattr(result, "published_date", None), + author=getattr(result, "author", None), + content=_extract_content(result), + ) + +def _build_search_kwargs(inp: dict) -> dict: + kwargs: dict = {"num_results": int(inp.get("num_results", 5))} + type_ = inp.get("type") + if type_: kwargs["type"] = type_ + for src, dst in ( + ("include_domains", "include_domains"), + ("exclude_domains", "exclude_domains"), + ("include_text", "include_text"), + ("exclude_text", "exclude_text"), + ): + v = inp.get(src) + if v: kwargs[dst] = v + category = inp.get("category") + if category: kwargs["category"] = category + start = inp.get("start_published_date") + if start: kwargs["start_published_date"] = start + end = inp.get("end_published_date") + if end: kwargs["end_published_date"] = end + user_location = inp.get("user_location") + if user_location: kwargs["user_location"] = user_location + + text = inp.get("text", True) + highlights = inp.get("highlights", True) + summary = inp.get("summary", False) + if text: kwargs["text"] = text if isinstance(text, dict) else True + if highlights: kwargs["highlights"] = highlights if isinstance(highlights, dict) else True + if summary: kwargs["summary"] = summary if isinstance(summary, dict) else True + return kwargs + +def _make_search(api_key: str): + def search(inp: dict) -> str: + try: + from exa_py import Exa + except ImportError: + return "Error: 'exa-py' not installed. Install with: uv add 'tinyloom[exa]'" + query = (inp.get("query") or "").strip() + if not query: return "Error: 'query' is required" + client = Exa(api_key=api_key) + client.headers["x-exa-integration"] = INTEGRATION_HEADER + kwargs = _build_search_kwargs(inp) + try: + response = client.search_and_contents(query, **kwargs) + except Exception as e: + return f"Error: Exa search failed: {type(e).__name__}: {e}" + results = getattr(response, "results", None) or [] + if not results: return f"No results for: {query}" + hits = [_hit_from_result(r) for r in results] + return "\n\n".join(h.render() for h in hits) + return search + +INPUT_SCHEMA = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "num_results": {"type": "integer", "description": "Max results (default 5, max 100)"}, + "type": {"type": "string", "enum": ["auto", "neural", "fast", "deep", "deep-lite", "deep-reasoning", "instant"], "description": "Search type (default auto)"}, + "include_domains": {"type": "array", "items": {"type": "string"}, "description": "Only return results from these domains"}, + "exclude_domains": {"type": "array", "items": {"type": "string"}, "description": "Exclude results from these domains"}, + "include_text": {"type": "array", "items": {"type": "string"}, "description": "Result must include this text (1 phrase, ≤5 words)"}, + "exclude_text": {"type": "array", "items": {"type": "string"}, "description": "Result must not include this text"}, + "category": {"type": "string", "description": "Filter by category (e.g. company, research paper, news, personal site, financial report, people)"}, + "start_published_date": {"type": "string", "description": "ISO 8601, e.g. 2024-01-01"}, + "end_published_date": {"type": "string", "description": "ISO 8601, e.g. 2024-12-31"}, + "user_location": {"type": "string", "description": "Two-letter ISO country code"}, + "text": {"description": "Return page text. Boolean or {maxCharacters, includeHtmlTags}"}, + "highlights": {"description": "Return highlights. Boolean or {numSentences, highlightsPerUrl, query}"}, + "summary": {"description": "Return a summary. Boolean or {query, schema}"}, + }, + "required": ["query"], +} + +DESCRIPTION = ( + "Search the web with Exa AI. Returns ranked results with titles, URLs, publish dates, and content " + "(highlights/summary/text). Supports neural and keyword search, domain and date filters, and category " + "filters (company, research paper, news, etc.). Use this when you need fresh or external information." +) + +def activate(agent: Agent): + api_key = os.environ.get("EXA_API_KEY", "").strip() + if not api_key: + print("Exa plugin: EXA_API_KEY not set; skipping web_search tool.", file=sys.stderr) + return + agent.tools.register(Tool( + name="web_search", + description=DESCRIPTION, + input_schema=INPUT_SCHEMA, + function=_make_search(api_key), + )) diff --git a/uv.lock b/uv.lock index 57cfe56..de73853 100644 --- a/uv.lock +++ b/uv.lock @@ -318,6 +318,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "exa-py" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore" }, + { name = "httpx" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/e4/11bbbc076ae420b9e00537945d48a03cb42cc6da63edc65bf50d23e4778e/exa_py-2.12.1.tar.gz", hash = "sha256:9ff1924fbfbcae822b20c0ddef0650fabc04ac75906b9153623eadc18135b7ce", size = 55792, upload-time = "2026-04-22T20:00:38.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/19/0a504b6ce7c468595cd0551f65e5c464832a1d3af8dc8acd681e21696a5f/exa_py-2.12.1-py3-none-any.whl", hash = "sha256:9e735802161482a7d5b231376257883cb4e34dbd6f75ded04ab1a5a171b69d9f", size = 74512, upload-time = "2026-04-22T20:00:34.326Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1308,6 +1326,9 @@ dev = [ { name = "pytest-asyncio" }, { name = "ruff" }, ] +exa = [ + { name = "exa-py" }, +] mcp = [ { name = "mcp" }, ] @@ -1315,6 +1336,7 @@ mcp = [ [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.40" }, + { name = "exa-py", marker = "extra == 'exa'", specifier = ">=2.0.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0,<2" }, { name = "openai", specifier = ">=1.50" }, { name = "pytest", marker = "extra == 'dev'" }, @@ -1325,7 +1347,7 @@ requires-dist = [ { name = "textual", specifier = ">=1.0" }, { name = "tiktoken", specifier = ">=0.7" }, ] -provides-extras = ["mcp", "dev"] +provides-extras = ["mcp", "exa", "dev"] [[package]] name = "tqdm"