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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

67 changes: 67 additions & 0 deletions docs/exa-plugin.md
Original file line number Diff line number Diff line change
@@ -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"]
```
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
207 changes: 207 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()) == ""
1 change: 1 addition & 0 deletions tinyloom.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading