Skip to content
Merged
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
86 changes: 86 additions & 0 deletions src/runtime/adk_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,90 @@ async def fetch_url(url: str) -> str:
return f"error fetching {url}: {exc}"


def exa_search(
query: str,
num_results: int = 5,
include_domains: Optional[list[str]] = None,
category: Optional[str] = None,
) -> str:
"""Semantic web search via the Exa API. Returns ranked candidate URLs
with snippet highlights — use it to DISCOVER a URL, then `fetch_url`
the best result to read the page. Costs real money (~$0.005-0.01 per
call): one query per task; if results miss, refine the query rather
than re-running variations. See src/skills/exa-search/skill.md.

query: the search string — be specific (names, versions, domains).
num_results: how many results (default 5, capped at 10 here).
include_domains: optional, e.g. ["docs.anthropic.com"] to anchor on
known-authoritative sources.
category: optional index narrowing — one of: company, research paper,
news, personal site, financial report, people.
"""
# Exists because the skill catalog advertises `exa-search` in every
# session's system prompt, and with no matching tool registered Flash
# pattern-matched the name to a native tool and emitted `exa_search(...)`
# calls the runner had no handler for — an unhandled exception that
# killed the whole session (3 crashes, 2026-07-09..13). Name and `query`
# arg deliberately match what Flash already tried to call.
try:
import httpx

api_key = os.environ.get("EXA_API_KEY")
if not api_key:
return "error: EXA_API_KEY not set in environment"
body: dict = {
"query": query,
"numResults": max(1, min(num_results, 10)),
"contents": {"highlights": True},
}
if include_domains:
body["includeDomains"] = include_domains
if category:
body["category"] = category
resp = httpx.post(
"https://api.exa.ai/search",
headers={"x-api-key": api_key},
json=body,
timeout=60.0,
)
try:
data = resp.json()
except Exception:
return (
f"error: Exa returned non-JSON (HTTP {resp.status_code}): "
f"{resp.text[:300]}"
)
if resp.status_code != 200:
# Error body shape: {"requestId", "error", "tag"} — surface the
# tag so Sam can branch (429 = back off; 400 INVALID_REQUEST_BODY
# = fix params, e.g. category+excludeDomains conflict; 402 =
# out of credits).
return (
f"error: Exa HTTP {resp.status_code}"
f" [{data.get('tag', 'no-tag')}]: {data.get('error', '(no message)')}"
)
results = data.get("results") or []
if not results:
return (
f"no results for {query!r} — this is a valid empty response, "
"not an error. Refine the query (add a name/version/domain)."
)
lines: list[str] = []
for i, r in enumerate(results, 1):
highlight = ((r.get("highlights") or [""])[0]).replace("\n", " ").strip()
lines.append(
f"{i}. {r.get('title')}\n"
f" {r.get('url')}\n"
f" published: {r.get('publishedDate') or 'unknown'}\n"
f" {highlight[:400]}"
)
cost = (data.get("costDollars") or {}).get("total")
lines.append(f"(exa cost this query: ${cost})")
return "\n".join(lines)
except Exception as exc:
return f"error: exa_search failed: {type(exc).__name__}: {exc}"


# ─── Worker agent instruction ──────────────────────────────────────────────────


Expand Down Expand Up @@ -736,6 +820,7 @@ async def run_one(task: str, idx: int) -> str:
FunctionTool(func=grep),
FunctionTool(func=glob_files),
FunctionTool(func=fetch_url),
FunctionTool(func=exa_search),
],
)
svc = InMemorySessionService()
Expand Down Expand Up @@ -1526,6 +1611,7 @@ async def run(self, request: AgentRunRequest) -> AgentRunResult:
FunctionTool(func=grep),
FunctionTool(func=glob_files),
FunctionTool(func=fetch_url),
FunctionTool(func=exa_search),
]

worker_instruction = _load_worker_instruction()
Expand Down
93 changes: 57 additions & 36 deletions src/skills/exa-search/skill.md
Original file line number Diff line number Diff line change
@@ -1,75 +1,96 @@
---
name: exa-search
description: How to find candidate URLs about a topic via semantic search before fetching them. Uses the Exa AI API. Reach for this when Sam doesn't already have a URL.
when_to_use: When Sam needs to find URLs about a topic (a paper, a doc, a vendor's API, a library) and doesn't already have the specific URL. NOT when Sam already has a URL — use `fetch_url` for that. NOT when the info lives in our own repo / Linear / Slack — use `read_file`, `grep`, or the Linear MCP.
description: When and how to use the `exa_search` runtime tool — semantic web search that finds candidate URLs about a topic before fetching them. Reach for this when Sam doesn't already have a URL.
when_to_use: When Sam needs to find URLs about a topic (a paper, a doc, a vendor's API, a library) and doesn't already have the specific URL — call the native `exa_search` tool (it IS registered in the runtime). NOT when Sam already has a URL — use `fetch_url`. NOT when the info lives in our own repos, Linear, or Slack — use `read_file`/`grep`, the Linear API via curl, or the Slack API.
---

# Skill: exa-search

Exa is a semantic search API — it returns ranked candidate URLs for a query, with snippets/highlights from each page. Use it to *discover* URLs; use `fetch_url` to actually read one.
Exa is a semantic search API. **`exa_search` is a native runtime tool** — call it
directly like `fetch_url`; do not curl the API for ordinary searches. It returns
ranked candidate URLs with snippet highlights. Use it to *discover* URLs; use
`fetch_url` to actually read one.

## When to use this skill
## When to use

The trigger is **you don't know the URL yet**. Examples:
The trigger is **you don't know the URL yet**:

- "find the latest Vertex AI Gemini 3.5 quota docs"
- "look up Anthropic's pricing page"
- "is there a paper on inverted-worker LLM architectures?"
- "find the OpenCode docs on `apply_patch`"

## When NOT to use this skill
## When NOT to use

| Situation | Use instead |
|---|---|
| You already have the URL | `fetch_url` |
| The info is in `Dembrane/sam` | `read_file` / `grep` / `glob_files` |
| The info is in another Dembrane repo | clone + `read_file` / `grep` |
| The info is in Linear (issues, comments, projects) | Linear MCP tools |
| The info is in Linear (issues, comments, projects) | Linear API via bash + curl (see `linear-issue-workflow`) |
| The info is in Slack (a thread, a canvas, a file) | Slack API via `bash` + curl |

Mixing this skill with `fetch_url` for the *same* task is the standard flow: Exa finds the URL, `fetch_url` gets the content.
The standard flow: `exa_search` finds the URL`fetch_url` reads it.

## How to use it

The API key lives in env as `EXA_API_KEY`. Endpoint and auth:
```
exa_search(query="Vertex AI Gemini 3.5 Flash EU multi-region quota docs",
num_results=5,
include_domains=["cloud.google.com"]) # optional
```

- `query` (required) — be specific: names, versions, error strings, domains.
- `num_results` — default 5; the tool caps at 10. Refine, don't widen.
- `include_domains` — anchor on known-authoritative sources early; better
signal-to-cost than a generic search.
- `category` — one of `company`, `research paper`, `news`, `personal site`,
`financial report`, `people`. Caution: `company`/`people` combined with
date filters or `excludeDomains` is rejected by Exa (400).

The tool returns numbered results (title, URL, publishedDate, highlight
snippet) plus the actual dollar cost of the query. An empty result set comes
back as a "no results" message — that's a valid answer, not an error; sharpen
the query.

## Curl fallback (advanced params only)

Only when you need something the tool doesn't expose (`type: deep`,
`startPublishedDate`, `contents.text`, the `/contents` or `/answer`
endpoints). The key is in env as `EXA_API_KEY`.
**`jq` is NOT installed in this container — never pipe to it.**
Use `python3 -m json.tool`, or write the response to a file and parse
with `python3`.

```bash
curl -s -X POST 'https://api.exa.ai/search' \
-H "x-api-key: $EXA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"query": "Vertex AI Gemini 3.5 Flash EU multi-region quota docs",
"numResults": 5,
"contents": {"highlights": true}
}' | jq '.results[] | {title, url, snippet: .highlights[0]}'
-d '{"query": "...", "numResults": 5, "type": "deep", "contents": {"highlights": true}}' \
| python3 -m json.tool
```

Key params:

- `query` (required) — your search string. Make it specific.
- `numResults` (default 10, range 1-100) — keep it ≤5 unless you genuinely need a wider net.
- `contents.highlights: true` — returns relevant snippets per result so you can pick without fetching every URL.
- `type` — `auto` (default), `fast`, `deep`, `instant`. `auto` is right for most cases. Use `deep` only when the first `auto` pass missed.
- `includeDomains` / `excludeDomains` — `["docs.cloud.google.com"]` shape. Use to anchor on known-authoritative sources.
- `category` — `research paper`, `news`, `company`, `people`, etc. Narrows the search index.

Response shape (the parts that matter):

- `results[]` — each has `title`, `url`, `publishedDate`, `author`, `text`, `highlights[]`, `summary`
- `costDollars` — actual cost of THIS query. Log it in the journal if it surprises you.
Current `type` values: `instant`, `fast`, `auto` (default), `deep-lite`,
`deep`, `deep-reasoning` ($$$, 12–50s). Response fields `text` / `summary`
only appear if you requested them under `contents`. Errors come back as
`{"requestId", "error", "tag"}` — branch on `tag`. Rate limit: 10 QPS on
/search; back off exponentially on 429.

## Cost discipline

Exa charges per query. Cheap (cents) but not free — and the budget compounds if you fan out.
Exa charges per query (~$0.005–0.01 for a normal search). Cheap but not free.

- **One query per task.** Don't loop. Don't run 5 variations.
- **One target URL per query result set.** Pick the most relevant from `highlights`, then `fetch_url`. Don't fetch every result.
- **Refine, don't paginate.** If `numResults=5` doesn't surface the right page, your query is too vague — make it more specific. Asking for more results rarely fixes a vague query.
- **Use `includeDomains` early.** When you know the source family (Anthropic docs, Google Cloud docs, GitHub), include them. Better signal-to-cost ratio than a generic search.
- **Journal the cost** if a query was unusual (e.g., `deep` type). Pattern detection lives in daily-maintenance §1 — if Exa cost shows up as friction across multiple sessions, that's a signal to add a rule.
- **One target URL per result set.** Pick the best from the highlights, then
`fetch_url` it. Don't fetch every result.
- **Refine, don't paginate.** If 5 results don't surface the right page, the
query is too vague.
- **Journal the cost** if a query was unusual (e.g. a `deep` type via curl).
Pattern detection lives in daily-maintenance §1 — if Exa cost shows up as
friction across multiple sessions, that's a signal to add a rule.

## Then what

`exa-search` returns URLs + snippets. Pick the most relevant. Then `fetch_url` it to get the actual page content. Don't try to synthesize from the snippets alone — they're a clue, not the content.

If none of the results look right, the query was wrong. Re-query with a sharper specific (a function name, a version number, a domain).
Pick the most relevant result and `fetch_url` it. Don't synthesize from
snippets alone — they're a clue, not the content. If nothing looks right,
the query was wrong: re-query with a sharper specific (function name,
version number, domain).
74 changes: 74 additions & 0 deletions tests/runtime/test_exa_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tests for the `exa_search` runtime tool.

Regression coverage for the 2026-07-09..13 crash mode: the skill catalog
advertised `exa-search` while no `exa_search` FunctionTool existed, so the
model emitted calls the runner had no handler for and the whole session
died. These tests pin the tool's existence, its registration, and its
never-raise error contract. No network calls — the live happy path is
covered by the pre-merge verification plan in the PR.
"""
from __future__ import annotations

import importlib
import inspect


def _adk_runner():
from src.runtime import adk_runner
importlib.reload(adk_runner)
return adk_runner


def test_exa_search_exists_with_expected_signature(tmp_sam_home):
adk_runner = _adk_runner()
params = inspect.signature(adk_runner.exa_search).parameters
# `query` first and required — it's the arg shape the model already
# emitted during the crash incidents.
assert list(params)[0] == "query"
assert params["query"].default is inspect.Parameter.empty
assert params["num_results"].default == 5


def test_exa_search_missing_key_returns_error_string(tmp_sam_home, monkeypatch):
adk_runner = _adk_runner()
monkeypatch.delenv("EXA_API_KEY", raising=False)

result = adk_runner.exa_search("anything")

assert result == "error: EXA_API_KEY not set in environment"


def test_exa_search_never_raises_on_connection_failure(tmp_sam_home, monkeypatch):
# Tool-error contract: failures come back as strings the model can read,
# never exceptions (an exception here is exactly the old crash mode).
adk_runner = _adk_runner()
monkeypatch.setenv("EXA_API_KEY", "test-key")
import httpx

def boom(*args, **kwargs):
raise httpx.ConnectError("no route to host")

monkeypatch.setattr(httpx, "post", boom)

result = adk_runner.exa_search("anything")

assert result.startswith("error: exa_search failed: ConnectError")


def test_exa_search_wraps_as_function_tool(tmp_sam_home):
from google.adk.tools import FunctionTool

adk_runner = _adk_runner()
tool = FunctionTool(func=adk_runner.exa_search)
assert tool.name == "exa_search"


def test_exa_search_registered_in_worker_tools_and_fanout(tmp_sam_home):
# Guard both registration sites: the shared worker_tools list (flows to
# main, worker, pro_executor) and the parallel_workers fanout branch.
from pathlib import Path

source = Path("src/runtime/adk_runner.py").read_text()
assert source.count("FunctionTool(func=exa_search)") == 2
worker_tools_block = source.split("worker_tools = [")[1].split("]")[0]
assert "exa_search" in worker_tools_block
Loading