feat: excerpt-based web_search, retry seam, domain filters, spec support

Audit follow-up on the ExaSearch capability:

- web_search now requests Exa highlights instead of full page text, per
  Exa's search guide for agents: excerpts make the survey step cheap and
  leave full-text reading to get_page, matching the capability's own
  survey-then-dig instructions.
- Transient Exa failures (non-2xx ValueError, httpx errors) convert to
  ModelRetry via a _recoverable seam mirroring Shell/FileSystem; 401/403
  auth failures propagate as configuration errors.
- get_page requests one char of headroom above max_text_chars so the
  local truncation marker can actually fire (impossible at the 10,000
  API ceiling, documented); deep_search answers are no longer capped.
- Bounds follow the Exa API reference (num_results 1-100, max_text_chars
  1-10,000) and validate in __post_init__ so the object the user wrote
  fails; include_domains/exclude_domains added as mutually exclusive
  filters; guidance field follows the Planning/Memory contract.
- from_spec override (Memory precedent) keeps the protocol-typed client
  out of the spec schema, enabling Agent.from_file and schema generation.
- Docs: MCP tool catalog corrected to the current live set with the
  create-then-poll vs single-call contrast, new core-WebSearch comparison
  (including the Anthropic web_search name duplication caveat), and an
  agent-spec section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bill Easton
2026-07-16 16:00:09 -05:00
co-authored by Claude Fable 5
parent e72e11353d
commit 86cc4795b1
7 changed files with 533 additions and 150 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ We studied leading coding agents, agent frameworks, and Claw-style assistants to
| | **Shell** | Execute commands with allowlists, denylists, and timeouts | :white_check_mark: [Docs](pydantic_ai_harness/shell/) | [pydantic-ai-backend](https://github.com/vstorm-co/pydantic-ai-backend) (vstorm&#8209;co) |
| | **Repo context injection** | Auto-load CLAUDE.md/AGENTS.md and repo structure | :white_check_mark: [Docs](pydantic_ai_harness/context/) | [pydantic-deep](https://github.com/vstorm-co/pydantic-deepagents) (vstorm&#8209;co) |
| | **Docs lookup** | On-demand `read_pyai_docs` tool for Pydantic AI docs | :white_check_mark: [Docs](pydantic_ai_harness/docs/) | |
| | **Web research** | Web search returning full page text, single-page retrieval, and opt-in deep search with cited answers, backed by [Exa](https://exa.ai) | :white_check_mark: [Docs](pydantic_ai_harness/exa/) | |
| | **Web research** | Web search returning relevant page excerpts, full single-page retrieval, and opt-in deep search with cited answers, backed by [Exa](https://exa.ai) | :white_check_mark: [Docs](pydantic_ai_harness/exa/) | |
| | **Verification loop** | Run tests after edits, auto-fix failures | :construction: [PR&nbsp;#169](https://github.com/pydantic/pydantic-ai-harness/pull/169) | |
| **Editor integration** | **ACP** | Serve an agent to editors (Zed, etc.) over the [Agent Client Protocol](https://agentclientprotocol.com) -- streamed text, diff-rendered edits, tool approval | :white_check_mark: [Docs](pydantic_ai_harness/experimental/acp/) (experimental) | |
| **Prompt management** | **Managed prompt** | Back an agent's instructions with a [Logfire](https://pydantic.dev/logfire)-managed prompt, editable without shipping code | :white_check_mark: [Docs](pydantic_ai_harness/logfire/) | |
+98 -35
View File
@@ -1,29 +1,29 @@
---
title: Exa Search
description: Give a Pydantic AI agent web research tools backed by the Exa search API -- search with page text, full-page retrieval, and opt-in deep search, with page text capped per result to bound tool output.
description: Give a Pydantic AI agent web research tools backed by the Exa search API -- search with relevant excerpts, full-page retrieval, and opt-in deep search, with output budgeted per tool.
---
# Exa Search
`ExaSearch` gives an agent web research tools backed by the
[Exa](https://exa.ai) search API: search that returns page text alongside each
hit, full-page retrieval for digging into a specific URL, and opt-in deep
search that synthesizes a cited answer in one call. Page text is capped per
result to bound tool output.
[Exa](https://exa.ai) search API: search that returns the most relevant
excerpts from each hit, full-page retrieval for digging into a specific URL,
and opt-in deep search that synthesizes a cited answer in one call.
[Source](https://github.com/pydantic/pydantic-ai-harness/tree/main/pydantic_ai_harness/exa/)
## The problem
Search tools that return only titles and snippets force a second round of
fetching before the agent can judge a source. Wiring a search API together with
a page fetcher, capping page text so tool output doesn't overwhelm the model's
context, and prompting the agent to research methodically is boilerplate every
research agent reinvents.
fetching before the agent can judge a source, while search tools that return
full page text flood the context with pages the agent will discard. Wiring a
search API together with a page fetcher, budgeting what each tool returns, and
prompting the agent to research methodically is boilerplate every research
agent reinvents.
`ExaSearch` bundles that plumbing into a single
[capability](/ai/core-concepts/capabilities/): the research tools, a per-result
page-text cap, and short research guidance in the system prompt.
[capability](/ai/core-concepts/capabilities/): the research tools, per-tool
output budgets, and short research guidance in the system prompt.
## Usage
@@ -52,20 +52,29 @@ print(result.output)
| Tool | Purpose |
|---|---|
| `web_search` | Search the web and return the top `num_results` pages, each with title, URL, and page text. |
| `get_page` | Retrieve the text contents of one specific URL. |
| `web_search` | Search the web and return the top `num_results` pages, each with title, URL, and its most relevant excerpts. |
| `get_page` | Retrieve the full text of one specific URL -- a promising `web_search` hit, or a URL the user provided. |
| `deep_search` | Run Exa's multi-step deep search and return a synthesized, cited answer. Opt-in via `include_deep_search=True`. |
Page text is capped at `max_text_chars` characters per result: the cap is sent
to Exa as the contents limit and re-enforced when tool output is formatted, so
text stays bounded even with a custom client. The result count is bounded the
same way (`num_results` is requested from Exa and re-applied to the response).
When text is cut, the **head** is kept (a page's lead carries the substance)
and a `[... page text truncated at N characters]` marker is appended.
`web_search` returns short excerpts (Exa highlights) rather than full page
text, following [Exa's own guidance for agents](https://exa.ai/docs/reference/search-api-guide-for-coding-agents),
so surveying several sources stays cheap; the agent reads a chosen page with
`get_page`.
A URL or question that returns no content surfaces to the model as a
`get_page` text is capped at `max_text_chars` characters, keeping the **head**
(a page's lead carries the substance). One character of headroom above the cap
is requested from Exa, so when a page exceeds the cap the output ends with a
`[... page text truncated at N characters]` marker; at the API ceiling of
10,000 characters no headroom exists, so the marker cannot appear there. The
result count is bounded the same way: `num_results` is requested from Exa and
re-applied to the response.
A URL or question that returns no content, a rate limit, or a transient API or
network failure surfaces to the model as a
[`ModelRetry`](/ai/tools-toolsets/tools-advanced/#tool-retries) rather than a
hard error: the run continues and the model can correct the URL or rephrase.
hard error: the run continues and the model can correct the URL, rephrase, or
try again. Authentication failures (401/403) are configuration errors and
propagate.
## Deep search
@@ -83,7 +92,9 @@ ExaSearch(include_deep_search=True)
```
When enabled, the capability's instructions tell the model to treat it as an
escalation from `web_search`, not a replacement.
escalation from `web_search`, not a replacement. The synthesized answer is
returned in full (it is Exa-generated and inherently bounded); `max_text_chars`
only applies to `get_page`.
## Instructions
@@ -91,7 +102,8 @@ escalation from `web_search`, not a replacement.
wide with `web_search` first, read the most promising pages in full with
`get_page` before drawing conclusions, prefer primary sources, and cite the
URLs relied on. With `include_deep_search=True`, the guidance also covers when
to escalate to `deep_search`.
to escalate to `deep_search`. Set `guidance` to replace the default text, or
to `''` to contribute no instructions at all.
## Configuration
@@ -101,13 +113,20 @@ Every field of `ExaSearch` with its default:
from pydantic_ai_harness.exa import ExaSearch
ExaSearch(
num_results=5, # results per web_search call
max_text_chars=10_000, # page-text cap per result, in characters
num_results=5, # results per web_search call (1 to 100)
max_text_chars=10_000, # get_page text cap, in characters (1 to 10,000)
include_deep_search=False, # also expose the deep_search tool
include_domains=[], # only search these domains (allowlist)
exclude_domains=[], # never search these domains (denylist)
guidance=None, # None = default instructions, '' = none, str = custom
client=None, # ExaClient -- None builds exa_py.AsyncExa from EXA_API_KEY
)
```
`include_domains` and `exclude_domains` apply to `web_search` and
`deep_search`, and are mutually exclusive -- set one, not both. Out-of-range
limits and setting both domain lists raise a `ValueError` at construction.
## Custom client
The default client is `exa_py.AsyncExa`, configured from the `EXA_API_KEY`
@@ -127,23 +146,41 @@ ExaSearch(client=AsyncExa(api_key='...'))
The API may change between releases while the capability settles; breaking
changes ship deprecation warnings where practical.
## ExaSearch vs core WebSearch
Pydantic AI core ships a provider-adaptive
[`WebSearch`](/ai/core-concepts/capabilities/#provider-adaptive-tools)
capability: on models with a native search tool it uses the provider's own
search, executed server-side; elsewhere it falls back to a local DuckDuckGo
tool. Reach for it when you want search that follows the model.
Reach for `ExaSearch` when you want the same search behavior on every model:
one vendor, excerpts with every hit, explicit page retrieval, domain filters,
and opt-in deep search.
One caveat when combining them: on Anthropic models the provider-native search
tool is also named `web_search` on the wire, so
`capabilities=[WebSearch(), ExaSearch()]` puts two tools with the same name in
the request. Use one search capability per agent on native-search models, or
force the local fallback with `WebSearch(native=False)` (its DuckDuckGo tool is
named `duckduckgo_search`, which does not collide).
## ExaSearch vs Exa's MCP server
Exa also ships an official hosted MCP server at `https://mcp.exa.ai/mcp`
([exa-labs/exa-mcp-server](https://github.com/exa-labs/exa-mcp-server)) with
their full tool catalog: `web_search_exa`, `get_code_context_exa`,
`crawling_exa`, `company_research_exa`, `linkedin_search_exa`, and a
`deep_researcher_start` / `deep_researcher_check` pair.
([exa-labs/exa-mcp-server](https://github.com/exa-labs/exa-mcp-server)). By
default it exposes `web_search_exa` and `web_fetch_exa`; the full catalog adds
`web_search_advanced_exa` and an agent-run set (`agent_create_run`,
`agent_wait_for_run`, `agent_get_run_output`, `agent_cancel_run`).
`ExaSearch` is the curated, typed path: bounded output, a retry-on-empty
contract, bundled research instructions, and a client seam that makes it
testable offline. The MCP server is how you get Exa's full catalog with zero
wrapper code, via Pydantic AI core's
[MCP capability](/ai/core-concepts/capabilities/). Where the MCP deep
researcher is a start/check tool pair the model must poll across calls,
`deep_search` returns the answer in a single call. The two compose in one
`capabilities` list, and the tool names don't collide (the MCP tools are
`*_exa`-suffixed):
wrapper code, via Pydantic AI core's MCP capability. Their agent runs are
create-then-poll (`agent_create_run` returns an ID immediately;
`agent_wait_for_run` polls it), where `deep_search` returns the answer in a
single call. The two compose in one `capabilities` list, and none of the MCP
tool names collide with `web_search`, `get_page`, or `deep_search`:
```python
from pydantic_ai import Agent
@@ -153,6 +190,32 @@ from pydantic_ai_harness.exa import ExaSearch
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaSearch(), MCP('https://mcp.exa.ai/mcp')])
```
## Agent spec (YAML/JSON)
`ExaSearch` works with Pydantic AI's
[agent spec](/ai/core-concepts/agent-spec/), so you can declare it in a config
file instead of Python:
```yaml
# agent.yaml
model: anthropic:claude-sonnet-4-6
capabilities:
- ExaSearch:
num_results: 3
include_deep_search: true
```
```python
from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaSearch
agent = Agent.from_file('agent.yaml', custom_capability_types=[ExaSearch])
```
Pass `custom_capability_types` so the spec loader knows how to instantiate
`ExaSearch`. The `client` field is not spec-serializable; spec-loaded instances
always build the default client from `EXA_API_KEY`.
## Further reading
- [Pydantic AI capabilities](/ai/core-concepts/capabilities/)
+1 -1
View File
@@ -117,7 +117,7 @@ Each capability is a self-contained battery you drop into an agent's `capabiliti
| [Shell](shell.md) | Command execution in a subprocess rooted at a working directory, gated by allowlists, denylists, timeouts, and optional environment-variable stripping (including a preset for common LLM provider credentials). | -- |
| [Context](context.md) | Auto-loads repo context -- `CLAUDE.md`/`AGENTS.md` and repository structure -- so the agent starts a run already oriented in the project. | -- |
| [Pydantic AI Docs](pydantic-ai-docs.md) | An on-demand `read_pyai_docs` tool that pulls Pydantic AI documentation into the run when the agent needs it, instead of preloading it. | -- |
| [Exa Search](exa-search.md) | Web research backed by the [Exa](https://exa.ai) search API: `web_search` returns results with page text, `get_page` reads a specific URL, and opt-in `deep_search` synthesizes a cited answer in one call. Page text is capped per result to bound tool output. | `exa` |
| [Exa Search](exa-search.md) | Web research backed by the [Exa](https://exa.ai) search API: `web_search` returns results with their most relevant excerpts, `get_page` reads a specific URL in full, and opt-in `deep_search` synthesizes a cited answer in one call. Output is budgeted per tool. | `exa` |
| [Compaction](compaction.md) | Keeps a run within token limits: sliding-window trimming, LLM-powered summarization of older messages, and warnings before the context or iteration ceiling is hit. | -- |
| [Overflowing Tool Output](overflowing-tool-output.md) | Reduces an oversized tool return when it is produced -- truncate, spill to a queryable file, or summarize -- so a large payload does not persist in history and get re-sent every request. | -- |
| [Step Persistence](step-persistence.md) | Saves and restores full conversation state; snapshot, resume (`continue_run`), and fork (`fork_run`) a run. | -- |
+96 -33
View File
@@ -1,9 +1,9 @@
# Exa Search
Give an agent web research tools backed by the [Exa](https://exa.ai) search
API: search that returns page text alongside each hit, full-page retrieval for
digging into a specific URL, and opt-in deep search that synthesizes a cited
answer in one call.
API: search that returns the most relevant excerpts from each hit, full-page
retrieval for digging into a specific URL, and opt-in deep search that
synthesizes a cited answer in one call.
[Source](https://github.com/pydantic/pydantic-ai-harness/tree/main/pydantic_ai_harness/exa/)
@@ -20,15 +20,17 @@ Set the `EXA_API_KEY` environment variable (create a key at
## The problem
Search tools that return only titles and snippets force a second round of
fetching before the agent can judge a source. Wiring a search API together with
a page fetcher, capping page text so tool output doesn't overwhelm the model's
context, and prompting the agent to research methodically is boilerplate every
research agent reinvents.
fetching before the agent can judge a source, while search tools that return
full page text flood the context with pages the agent will discard. Wiring a
search API together with a page fetcher, budgeting what each tool returns, and
prompting the agent to research methodically is boilerplate every research
agent reinvents.
## The solution
`ExaSearch` bundles the tools with output capping and short research guidance
in the system prompt:
`ExaSearch` bundles the tools with output budgeting and short research
guidance in the system prompt: survey cheaply with excerpts, then read the
pages that matter in full.
```python
from pydantic_ai import Agent
@@ -44,20 +46,27 @@ print(result.output)
| Tool | Purpose |
|---|---|
| `web_search` | Search the web and return the top `num_results` pages, each with title, URL, and page text. |
| `get_page` | Retrieve the text contents of one specific URL. |
| `web_search` | Search the web and return the top `num_results` pages, each with title, URL, and its most relevant excerpts. |
| `get_page` | Retrieve the full text of one specific URL -- a promising `web_search` hit, or a URL the user provided. |
| `deep_search` | Run Exa's multi-step deep search and return a synthesized, cited answer. Opt-in via `include_deep_search=True`. |
Page text is capped at `max_text_chars` characters per result: the cap is sent
to Exa as the contents limit and re-enforced when tool output is formatted, so
text stays bounded even with a custom client. The result count is bounded the
same way (`num_results` is requested from Exa and re-applied to the response).
When text is cut, the **head** is kept (a page's lead carries the substance)
and a `[... page text truncated at N characters]` marker is appended.
`web_search` returns short excerpts (Exa highlights) rather than full page
text, following [Exa's own guidance for agents](https://exa.ai/docs/reference/search-api-guide-for-coding-agents),
so surveying several sources stays cheap; the agent reads a chosen page with
`get_page`.
A URL or question that returns no content surfaces to the model as a
`ModelRetry` (the model can correct the URL or rephrase) rather than aborting
the run.
`get_page` text is capped at `max_text_chars` characters, keeping the **head**
(a page's lead carries the substance). One character of headroom above the cap
is requested from Exa, so when a page exceeds the cap the output ends with a
`[... page text truncated at N characters]` marker; at the API ceiling of
10,000 characters no headroom exists, so the marker cannot appear there. The
result count is bounded the same way: `num_results` is requested from Exa and
re-applied to the response.
A URL or question that returns no content, a rate limit, or a transient API or
network failure surfaces to the model as a `ModelRetry` (the model can correct
the URL, rephrase, or try again) rather than aborting the run. Authentication
failures (401/403) are configuration errors and propagate.
## Deep search
@@ -75,7 +84,9 @@ ExaSearch(include_deep_search=True)
```
When enabled, the capability's instructions tell the model to treat it as an
escalation from `web_search`, not a replacement.
escalation from `web_search`, not a replacement. The synthesized answer is
returned in full (it is Exa-generated and inherently bounded); `max_text_chars`
only applies to `get_page`.
## Instructions
@@ -83,7 +94,8 @@ escalation from `web_search`, not a replacement.
wide with `web_search` first, read the most promising pages in full with
`get_page` before drawing conclusions, prefer primary sources, and cite the
URLs relied on. With `include_deep_search=True`, the guidance also covers when
to escalate to `deep_search`.
to escalate to `deep_search`. Set `guidance` to replace the default text, or
to `''` to contribute no instructions at all.
## Configuration
@@ -93,13 +105,20 @@ Every field of `ExaSearch` with its default:
from pydantic_ai_harness.exa import ExaSearch
ExaSearch(
num_results=5, # results per web_search call
max_text_chars=10_000, # page-text cap per result, in characters
num_results=5, # results per web_search call (1 to 100)
max_text_chars=10_000, # get_page text cap, in characters (1 to 10,000)
include_deep_search=False, # also expose the deep_search tool
include_domains=[], # only search these domains (allowlist)
exclude_domains=[], # never search these domains (denylist)
guidance=None, # None = default instructions, '' = none, str = custom
client=None, # ExaClient -- None builds exa_py.AsyncExa from EXA_API_KEY
)
```
`include_domains` and `exclude_domains` apply to `web_search` and
`deep_search`, and are mutually exclusive -- set one, not both. Out-of-range
limits and setting both domain lists raise a `ValueError` at construction.
## Custom client
The default client is `exa_py.AsyncExa`, configured from the `EXA_API_KEY`
@@ -119,22 +138,41 @@ ExaSearch(client=AsyncExa(api_key='...'))
The API may change between releases while the capability settles; breaking
changes ship deprecation warnings where practical.
## ExaSearch vs core `WebSearch`
Pydantic AI core ships a provider-adaptive
[`WebSearch`](https://ai.pydantic.dev/capabilities/#provider-adaptive-tools)
capability: on models with a native search tool it uses the provider's own
search, executed server-side; elsewhere it falls back to a local DuckDuckGo
tool. Reach for it when you want search that follows the model.
Reach for `ExaSearch` when you want the same search behavior on every model:
one vendor, excerpts with every hit, explicit page retrieval, domain filters,
and opt-in deep search.
One caveat when combining them: on Anthropic models the provider-native search
tool is also named `web_search` on the wire, so
`capabilities=[WebSearch(), ExaSearch()]` puts two tools with the same name in
the request. Use one search capability per agent on native-search models, or
force the local fallback with `WebSearch(native=False)` (its DuckDuckGo tool is
named `duckduckgo_search`, which does not collide).
## ExaSearch vs Exa's MCP server
Exa also ships an official hosted MCP server at `https://mcp.exa.ai/mcp`
([exa-labs/exa-mcp-server](https://github.com/exa-labs/exa-mcp-server)) with
their full tool catalog: `web_search_exa`, `get_code_context_exa`,
`crawling_exa`, `company_research_exa`, `linkedin_search_exa`, and a
`deep_researcher_start` / `deep_researcher_check` pair.
([exa-labs/exa-mcp-server](https://github.com/exa-labs/exa-mcp-server)). By
default it exposes `web_search_exa` and `web_fetch_exa`; the full catalog adds
`web_search_advanced_exa` and an agent-run set (`agent_create_run`,
`agent_wait_for_run`, `agent_get_run_output`, `agent_cancel_run`).
`ExaSearch` is the curated, typed path: bounded output, a retry-on-empty
contract, bundled research instructions, and a client seam that makes it
testable offline. The MCP server is how you get Exa's full catalog with zero
wrapper code, via Pydantic AI core's MCP capability. Where the MCP deep
researcher is a start/check tool pair the model must poll across calls,
`deep_search` returns the answer in a single call. The two compose in one
`capabilities` list, and the tool names don't collide (the MCP tools are
`*_exa`-suffixed):
wrapper code, via Pydantic AI core's MCP capability. Their agent runs are
create-then-poll (`agent_create_run` returns an ID immediately;
`agent_wait_for_run` polls it), where `deep_search` returns the answer in a
single call. The two compose in one `capabilities` list, and none of the MCP
tool names collide with `web_search`, `get_page`, or `deep_search`:
```python
from pydantic_ai import Agent
@@ -144,6 +182,31 @@ from pydantic_ai_harness.exa import ExaSearch
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaSearch(), MCP('https://mcp.exa.ai/mcp')])
```
## Agent spec (YAML/JSON)
`ExaSearch` works with Pydantic AI's
[agent spec](https://ai.pydantic.dev/agent-spec/):
```yaml
# agent.yaml
model: anthropic:claude-sonnet-4-6
capabilities:
- ExaSearch:
num_results: 3
include_deep_search: true
```
```python
from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaSearch
agent = Agent.from_file('agent.yaml', custom_capability_types=[ExaSearch])
```
Pass `custom_capability_types` so the spec loader knows how to instantiate
`ExaSearch`. The `client` field is not spec-serializable; spec-loaded instances
always build the default client from `EXA_API_KEY`.
## Further reading
- [Pydantic AI capabilities](https://ai.pydantic.dev/capabilities/)
+87 -14
View File
@@ -2,12 +2,22 @@
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.tools import AgentDepsT
from pydantic_ai_harness.exa._toolset import ExaClient, ExaSearchToolset
from pydantic_ai_harness.exa._toolset import (
EXA_MAX_NUM_RESULTS,
EXA_MAX_PAGE_TEXT_CHARS,
ExaClient,
ExaSearchToolset,
)
if TYPE_CHECKING:
from pydantic_ai._instructions import AgentInstructions
_INSTRUCTIONS = (
'You have web research tools backed by the Exa search API. Start broad: use `web_search` '
@@ -26,11 +36,11 @@ _DEEP_INSTRUCTIONS = _INSTRUCTIONS + (
class ExaSearch(AbstractCapability[AgentDepsT]):
"""Web research for agents, backed by the [Exa](https://exa.ai) search API.
Adds two tools: `web_search`, which returns search results together with
page text, and `get_page`, which retrieves the text of a specific URL.
Set `include_deep_search=True` to also expose `deep_search`, which runs
Exa's multi-step deep search and returns a synthesized, cited answer in
one tool call.
Adds two tools: `web_search`, which returns search results with their most
relevant excerpts, and `get_page`, which retrieves the full text of a
specific URL. Set `include_deep_search=True` to also expose `deep_search`,
which runs Exa's multi-step deep search and returns a synthesized, cited
answer in one tool call.
```python
from pydantic_ai import Agent
@@ -44,13 +54,14 @@ class ExaSearch(AbstractCapability[AgentDepsT]):
"""
num_results: int = 5
"""Number of results `web_search` returns per query."""
"""Number of results `web_search` returns per query (1 to 100, the Exa API range)."""
max_text_chars: int = 10_000
"""Maximum characters of page text returned per result.
"""Maximum characters of page text `get_page` returns (1 to 10,000, the Exa API range).
Sent to Exa as the contents cap and re-enforced when tool output is
formatted, so page text stays bounded even with a custom `client`.
One character of headroom above the cap is requested from Exa so local
truncation can detect a longer page and append a truncation marker. At the
API ceiling of 10,000 no headroom exists, so the marker cannot fire there.
"""
include_deep_search: bool = False
@@ -63,6 +74,27 @@ class ExaSearch(AbstractCapability[AgentDepsT]):
than the default.
"""
include_domains: Sequence[str] = field(default_factory=list[str])
"""If non-empty, search results only come from these domains (allowlist).
Applies to `web_search` and `deep_search`. Mutually exclusive with
`exclude_domains`.
"""
exclude_domains: Sequence[str] = field(default_factory=list[str])
"""Search results never come from these domains (denylist).
Applies to `web_search` and `deep_search`. Mutually exclusive with
`include_domains`.
"""
guidance: str | None = None
"""Custom research guidance for the system prompt.
Leave as `None` for the default guidance (which adapts to
`include_deep_search`), or set `''` to contribute no instructions at all.
"""
client: ExaClient | None = None
"""Exa client to use; when `None`, an `exa_py.AsyncExa` is built from `EXA_API_KEY`.
@@ -70,12 +102,26 @@ class ExaSearch(AbstractCapability[AgentDepsT]):
key explicitly, point at a different base URL, or substitute a fake in tests.
"""
def get_instructions(self) -> str:
def __post_init__(self) -> None:
"""Validate configuration against the Exa API's documented bounds."""
if not 1 <= self.num_results <= EXA_MAX_NUM_RESULTS:
raise ValueError(f'num_results must be between 1 and {EXA_MAX_NUM_RESULTS}, got {self.num_results}')
if not 1 <= self.max_text_chars <= EXA_MAX_PAGE_TEXT_CHARS:
raise ValueError(
f'max_text_chars must be between 1 and {EXA_MAX_PAGE_TEXT_CHARS}, got {self.max_text_chars}'
)
if self.include_domains and self.exclude_domains:
raise ValueError('Specify include_domains or exclude_domains, not both.')
def get_instructions(self) -> AgentInstructions[AgentDepsT] | None:
"""Static research guidance: search wide, read the promising pages in full, cite URLs.
When `include_deep_search` is set, the guidance also covers when to
escalate to `deep_search`.
When `include_deep_search` is set, the default guidance also covers
when to escalate to `deep_search`. A non-`None` `guidance` replaces the
default; `''` disables instructions entirely.
"""
if self.guidance is not None:
return self.guidance or None
return _DEEP_INSTRUCTIONS if self.include_deep_search else _INSTRUCTIONS
def get_toolset(self) -> ExaSearchToolset[AgentDepsT]:
@@ -85,4 +131,31 @@ class ExaSearch(AbstractCapability[AgentDepsT]):
num_results=self.num_results,
max_text_chars=self.max_text_chars,
include_deep_search=self.include_deep_search,
include_domains=self.include_domains,
exclude_domains=self.exclude_domains,
)
@classmethod
def from_spec(
cls,
*,
num_results: int = 5,
max_text_chars: int = 10_000,
include_deep_search: bool = False,
include_domains: Sequence[str] = (),
exclude_domains: Sequence[str] = (),
guidance: str | None = None,
) -> ExaSearch[AgentDepsT]:
"""Construct the capability from serializable spec options.
The `client` field is not spec-serializable, so spec-loaded instances
always build the default `exa_py.AsyncExa` from `EXA_API_KEY`.
"""
return cls(
num_results=num_results,
max_text_chars=max_text_chars,
include_deep_search=include_deep_search,
include_domains=list(include_domains),
exclude_domains=list(exclude_domains),
guidance=guidance,
)
+99 -26
View File
@@ -2,9 +2,13 @@
from __future__ import annotations
import functools
import json
from typing import Literal, Protocol
import re
from collections.abc import Awaitable, Callable, Sequence
from typing import Concatenate, Literal, ParamSpec, Protocol
import httpx
from pydantic_ai.exceptions import ModelRetry, UserError
from pydantic_ai.tools import AgentDepsT
from pydantic_ai.toolsets import FunctionToolset
@@ -24,6 +28,19 @@ except ImportError as _import_error: # pragma: no cover
'exa-py is required for ExaSearch. Install it with: pip install "pydantic-ai-harness[exa]"'
) from _import_error
EXA_MAX_NUM_RESULTS = 100
"""Largest `num_results` the Exa search API accepts."""
EXA_MAX_PAGE_TEXT_CHARS = 10_000
"""Largest per-page text budget the Exa contents API accepts."""
_P = ParamSpec('_P')
# exa-py raises a bare ValueError for any non-2xx response, embedding the HTTP
# status in the message. 401/403 mean a bad or missing API key -- configuration
# the model cannot correct, so those propagate instead of retrying.
_AUTH_STATUS_RE = re.compile(r'status code (401|403)\b')
class ExaClient(Protocol):
"""The subset of the `exa_py.AsyncExa` API that `ExaSearchToolset` calls.
@@ -45,6 +62,8 @@ class ExaClient(Protocol):
num_results: int | None = None,
type: SearchType | None = None,
output_schema: DeepOutputSchema | None = None,
include_domains: list[str] | None = None,
exclude_domains: list[str] | None = None,
) -> SearchResponse[Result]:
"""Search the web and return results, optionally with page contents or a synthesized output."""
... # pragma: no cover
@@ -65,62 +84,104 @@ def _default_client() -> ExaClient:
) from error
def _recoverable(
fn: Callable[Concatenate[ExaSearchToolset, _P], Awaitable[str]],
) -> Callable[Concatenate[ExaSearchToolset, _P], Awaitable[str]]:
"""Convert transient Exa API failures into `ModelRetry`.
pyai only feeds `ModelRetry` back to the model as a retry prompt; any other
exception propagates and aborts the whole run. exa-py surfaces every non-2xx
response as a bare `ValueError` (message embeds the status code) and network
failures as `httpx.HTTPError`. Rate limits, transient 5xx, and rejected
parameters are things a model can recover from (wait, rephrase, adjust), so
they become retries; 401/403 auth failures are configuration errors and
propagate.
"""
@functools.wraps(fn)
async def wrapper(self: ExaSearchToolset, *args: _P.args, **kwargs: _P.kwargs) -> str:
try:
return await fn(self, *args, **kwargs)
except httpx.HTTPError as error:
raise ModelRetry(f'Exa request failed: {error}') from error
except ValueError as error:
if _AUTH_STATUS_RE.search(str(error)):
raise
raise ModelRetry(f'Exa request failed: {error}') from error
return wrapper
class ExaSearchToolset(FunctionToolset[AgentDepsT]):
"""Gives an agent web research tools backed by the Exa search API.
`web_search` surveys the web and returns results together with page text,
and `get_page` retrieves the text of one specific URL. With
`web_search` surveys the web and returns results with their most relevant
excerpts, and `get_page` retrieves the text of one specific URL. With
`include_deep_search=True`, `deep_search` runs Exa's multi-step deep search
and returns a synthesized, cited answer in one call.
Page text is capped at `max_text_chars` characters per result: the cap is
sent to Exa as the contents limit and re-enforced when output is formatted,
so text stays bounded even with a custom `client`. The result count is
bounded the same way: `num_results` is requested from Exa and re-applied to
the response.
`get_page` text is capped at `max_text_chars` characters; one character of
headroom above the cap is requested from Exa so local truncation can detect
a longer page and append a marker (at the API ceiling of
`EXA_MAX_PAGE_TEXT_CHARS` no headroom exists, so the marker cannot fire).
The `web_search` result count is bounded the same way: `num_results` is
requested from Exa and re-applied to the response. Bounds are validated by
`ExaSearch` at construction.
"""
def __init__(
self, *, client: ExaClient | None, num_results: int, max_text_chars: int, include_deep_search: bool
self,
*,
client: ExaClient | None,
num_results: int,
max_text_chars: int,
include_deep_search: bool,
include_domains: Sequence[str] = (),
exclude_domains: Sequence[str] = (),
) -> None:
if num_results < 1:
raise ValueError(f'num_results must be at least 1, got {num_results}')
if max_text_chars < 1:
raise ValueError(f'max_text_chars must be at least 1, got {max_text_chars}')
super().__init__()
self._client = client if client is not None else _default_client()
self._num_results = num_results
self._max_text_chars = max_text_chars
self._include_domains = list(include_domains) if include_domains else None
self._exclude_domains = list(exclude_domains) if exclude_domains else None
self.add_function(self.web_search, name='web_search')
self.add_function(self.get_page, name='get_page')
if include_deep_search:
self.add_function(self.deep_search, name='deep_search')
@_recoverable
async def web_search(self, query: str) -> str:
"""Search the web and return matching pages, each with its text content.
"""Search the web and return matching pages, each with its most relevant excerpts.
Args:
query: The search query. Natural-language questions and keyword
queries both work.
Returns:
The matching pages, each with title, URL, and page text.
The matching pages, each with title, URL, and excerpts.
"""
response = await self._client.search(
query,
contents={'text': {'max_characters': self._max_text_chars}},
contents={'highlights': True},
num_results=self._num_results,
include_domains=self._include_domains,
exclude_domains=self._exclude_domains,
)
if not response.results:
return f'No results found for {query!r}.'
results = response.results[: self._num_results]
sections = [_format_result(result, self._max_text_chars) for result in results]
sections = [_format_result(result, _highlights_body(result)) for result in results]
plural = 's' if len(sections) != 1 else ''
joined = '\n\n---\n\n'.join(sections)
return f'Found {len(sections)} result{plural} for {query!r}:\n\n{joined}'
@_recoverable
async def get_page(self, url: str) -> str:
"""Retrieve the text contents of a specific URL.
"""Retrieve the full text of a specific URL.
Use it to read a promising URL from `web_search` results in full, or a
URL the user provided.
Args:
url: The URL of the page to read.
@@ -128,11 +189,14 @@ class ExaSearchToolset(FunctionToolset[AgentDepsT]):
Returns:
The page's title, URL, and text content.
"""
response = await self._client.get_contents(url, text={'max_characters': self._max_text_chars})
if not response.results or not response.results[0].text:
requested = min(self._max_text_chars + 1, EXA_MAX_PAGE_TEXT_CHARS)
response = await self._client.get_contents(url, text={'max_characters': requested})
first = response.results[0] if response.results else None
if first is None or not first.text:
raise ModelRetry(f'No content could be retrieved for {url!r}. Check the URL or try another page.')
return _format_result(response.results[0], self._max_text_chars)
return _format_result(first, _truncate(first.text, self._max_text_chars))
@_recoverable
async def deep_search(self, question: str) -> str:
"""Run Exa's multi-step deep search and return a synthesized answer with its sources.
@@ -150,6 +214,8 @@ class ExaSearchToolset(FunctionToolset[AgentDepsT]):
contents=False,
type='deep',
output_schema={'type': 'text'},
include_domains=self._include_domains,
exclude_domains=self._exclude_domains,
)
output = response.output
if output is None or not output.content:
@@ -161,25 +227,32 @@ class ExaSearchToolset(FunctionToolset[AgentDepsT]):
sources = {citation.url: citation.title for row in output.grounding for citation in row.citations}
if not sources:
sources = {result.url: result.title or '' for result in response.results}
lines = [_truncate(answer, self._max_text_chars)]
lines = [answer]
if sources:
lines.extend(['', 'Sources:'])
lines.extend(f'- {title or "(untitled)"}: {url}' for url, title in sources.items())
return '\n'.join(lines)
def _format_result(result: Result, max_text_chars: int) -> str:
"""Render one result as labelled metadata lines followed by the page text."""
def _format_result(result: Result, body: str | None) -> str:
"""Render one result as labelled metadata lines followed by its body text."""
lines = [f'Title: {result.title or "(untitled)"}', f'URL: {result.url}']
if result.published_date:
lines.append(f'Published: {result.published_date}')
if result.author:
lines.append(f'Author: {result.author}')
if result.text:
lines.extend(['', _truncate(result.text, max_text_chars)])
if body:
lines.extend(['', body])
return '\n'.join(lines)
def _highlights_body(result: Result) -> str | None:
"""Render a result's excerpts as a bullet list, or `None` when there are none."""
if not result.highlights:
return None
return '\n'.join(f'- {highlight}' for highlight in result.highlights)
def _truncate(text: str, max_chars: int) -> str:
"""Cap page text at `max_chars`, keeping the head.
+151 -40
View File
@@ -2,10 +2,13 @@
from __future__ import annotations
import json
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
import httpx
import pytest
from exa_py.api import (
ContentsOptions,
@@ -19,6 +22,7 @@ from exa_py.api import (
TextContentsOptions,
)
from pydantic_ai import Agent
from pydantic_ai.agent.spec import AgentSpec
from pydantic_ai.exceptions import ModelRetry, UserError
from pydantic_ai.messages import ModelRequest, ModelResponse, ToolCallPart, ToolReturnPart
from pydantic_ai.models.test import TestModel
@@ -36,12 +40,21 @@ def _result(
url: str = 'https://example.dev/page',
*,
title: str | None = 'Example page',
text: str | None = 'Example page text.',
text: str | None = None,
highlights: list[str] | None = None,
author: str | None = None,
published_date: str | None = None,
) -> Result:
"""A real `exa_py` result with the fields the toolset reads."""
return Result(url=url, id=url, title=title, text=text, author=author, published_date=published_date)
return Result(
url=url,
id=url,
title=title,
text=text,
highlights=highlights,
author=author,
published_date=published_date,
)
def _response(*results: Result) -> SearchResponse[Result]:
@@ -82,6 +95,7 @@ class _FakeExaClient:
search_response: SearchResponse[Result] = field(default_factory=_response)
contents_response: SearchResponse[Result] = field(default_factory=_response)
deep_response: SearchResponse[Result] = field(default_factory=_response)
error: Exception | None = None
search_calls: list[dict[str, object]] = field(default_factory=list[dict[str, object]])
get_contents_calls: list[tuple[str, TextContentsOptions]] = field(
default_factory=list[tuple[str, TextContentsOptions]]
@@ -95,7 +109,11 @@ class _FakeExaClient:
num_results: int | None = None,
type: SearchType | None = None,
output_schema: DeepOutputSchema | None = None,
include_domains: list[str] | None = None,
exclude_domains: list[str] | None = None,
) -> SearchResponse[Result]:
if self.error is not None:
raise self.error
self.search_calls.append(
{
'query': query,
@@ -103,11 +121,15 @@ class _FakeExaClient:
'num_results': num_results,
'type': type,
'output_schema': output_schema,
'include_domains': include_domains,
'exclude_domains': exclude_domains,
}
)
return self.deep_response if type is not None else self.search_response
async def get_contents(self, urls: str, *, text: TextContentsOptions) -> SearchResponse[Result]:
if self.error is not None:
raise self.error
self.get_contents_calls.append((urls, text))
return self.contents_response
@@ -118,21 +140,31 @@ def _toolset(
num_results: int = 5,
max_text_chars: int = 10_000,
include_deep_search: bool = False,
include_domains: Sequence[str] = (),
exclude_domains: Sequence[str] = (),
) -> ExaSearchToolset[None]:
return ExaSearch[None](
num_results=num_results,
max_text_chars=max_text_chars,
include_deep_search=include_deep_search,
include_domains=include_domains,
exclude_domains=exclude_domains,
client=client,
).get_toolset()
class TestWebSearch:
async def test_formats_results_with_metadata(self) -> None:
async def test_formats_results_with_excerpts_and_metadata(self) -> None:
client = _FakeExaClient(
search_response=_response(
_result('https://a.dev', title='A', text='alpha text', author='Ada', published_date='2026-07-01'),
_result('https://b.dev', title=None, text=None),
_result(
'https://a.dev',
title='A',
highlights=['alpha excerpt', 'another excerpt'],
author='Ada',
published_date='2026-07-01',
),
_result('https://b.dev', title=None),
)
)
output = await _toolset(client).web_search('rust web frameworks')
@@ -143,25 +175,35 @@ class TestWebSearch:
'Published: 2026-07-01\n'
'Author: Ada\n'
'\n'
'alpha text'
'- alpha excerpt\n'
'- another excerpt'
'\n\n---\n\n'
'Title: (untitled)\n'
'URL: https://b.dev'
)
async def test_passes_num_results_and_contents_cap_to_client(self) -> None:
async def test_requests_highlights_num_results_and_domains(self) -> None:
client = _FakeExaClient(search_response=_response(_result()))
await _toolset(client, num_results=3, max_text_chars=42).web_search('q')
toolset = _toolset(client, num_results=3, include_domains=['a.dev', 'b.dev'])
await toolset.web_search('q')
assert client.search_calls == [
{
'query': 'q',
'contents': {'text': {'max_characters': 42}},
'contents': {'highlights': True},
'num_results': 3,
'type': None,
'output_schema': None,
'include_domains': ['a.dev', 'b.dev'],
'exclude_domains': None,
}
]
async def test_exclude_domains_plumbed(self) -> None:
client = _FakeExaClient(search_response=_response(_result()))
await _toolset(client, exclude_domains=['spam.dev']).web_search('q')
assert client.search_calls[0]['include_domains'] is None
assert client.search_calls[0]['exclude_domains'] == ['spam.dev']
async def test_no_results(self) -> None:
client = _FakeExaClient()
output = await _toolset(client).web_search('nothing to see')
@@ -170,9 +212,9 @@ class TestWebSearch:
async def test_num_results_enforced_on_oversized_response(self) -> None:
client = _FakeExaClient(
search_response=_response(
_result('https://a.dev', title='A', text=None),
_result('https://b.dev', title='B', text=None),
_result('https://c.dev', title='C', text=None),
_result('https://a.dev', title='A'),
_result('https://b.dev', title='B'),
_result('https://c.dev', title='C'),
)
)
output = await _toolset(client, num_results=2).web_search('q')
@@ -180,27 +222,31 @@ class TestWebSearch:
"Found 2 results for 'q':\n\nTitle: A\nURL: https://a.dev\n\n---\n\nTitle: B\nURL: https://b.dev"
)
async def test_text_at_cap_is_not_truncated(self) -> None:
client = _FakeExaClient(search_response=_response(_result(title='T', text='x' * 10)))
output = await _toolset(client, max_text_chars=10).web_search('q')
assert output == f"Found 1 result for 'q':\n\nTitle: T\nURL: https://example.dev/page\n\n{'x' * 10}"
async def test_text_over_cap_is_truncated_keeping_the_head(self) -> None:
client = _FakeExaClient(search_response=_response(_result(title='T', text='x' * 10 + 'TAIL')))
output = await _toolset(client, max_text_chars=10).web_search('q')
assert output == (
f"Found 1 result for 'q':\n\nTitle: T\nURL: https://example.dev/page\n\n{'x' * 10}\n"
'[... page text truncated at 10 characters]'
)
class TestGetPage:
async def test_returns_page_text(self) -> None:
async def test_returns_page_text_with_headroom_request(self) -> None:
client = _FakeExaClient(contents_response=_response(_result('https://a.dev', title='A', text='alpha text')))
output = await _toolset(client).get_page('https://a.dev')
assert client.get_contents_calls == [('https://a.dev', {'max_characters': 10_000})]
output = await _toolset(client, max_text_chars=100).get_page('https://a.dev')
assert client.get_contents_calls == [('https://a.dev', {'max_characters': 101})]
assert output == 'Title: A\nURL: https://a.dev\n\nalpha text'
async def test_headroom_request_stops_at_api_ceiling(self) -> None:
client = _FakeExaClient(contents_response=_response(_result(text='alpha')))
await _toolset(client, max_text_chars=10_000).get_page('https://a.dev')
assert client.get_contents_calls == [('https://a.dev', {'max_characters': 10_000})]
async def test_text_at_cap_is_not_truncated(self) -> None:
client = _FakeExaClient(contents_response=_response(_result(title='T', text='x' * 10)))
output = await _toolset(client, max_text_chars=10).get_page('https://example.dev/page')
assert output == f'Title: T\nURL: https://example.dev/page\n\n{"x" * 10}'
async def test_text_over_cap_is_truncated_keeping_the_head(self) -> None:
client = _FakeExaClient(contents_response=_response(_result(title='T', text='x' * 10 + 'T')))
output = await _toolset(client, max_text_chars=10).get_page('https://example.dev/page')
assert output == (
f'Title: T\nURL: https://example.dev/page\n\n{"x" * 10}\n[... page text truncated at 10 characters]'
)
async def test_no_results_raises_model_retry(self) -> None:
client = _FakeExaClient()
with pytest.raises(ModelRetry, match='No content could be retrieved'):
@@ -212,6 +258,23 @@ class TestGetPage:
await _toolset(client).get_page('https://a.dev')
class TestRecoverableErrors:
async def test_non_2xx_becomes_model_retry(self) -> None:
client = _FakeExaClient(error=ValueError('Request failed with status code 429: rate limited'))
with pytest.raises(ModelRetry, match='Exa request failed: .*429'):
await _toolset(client).web_search('q')
async def test_auth_failure_propagates(self) -> None:
client = _FakeExaClient(error=ValueError('Request failed with status code 401: invalid API key'))
with pytest.raises(ValueError, match='status code 401'):
await _toolset(client).web_search('q')
async def test_network_failure_becomes_model_retry(self) -> None:
client = _FakeExaClient(error=httpx.ConnectError('connection refused'))
with pytest.raises(ModelRetry, match='Exa request failed: connection refused'):
await _toolset(client).get_page('https://a.dev')
class TestDeepSearch:
async def test_returns_answer_with_deduplicated_sources(self) -> None:
client = _FakeExaClient(
@@ -228,19 +291,25 @@ class TestDeepSearch:
'num_results': None,
'type': 'deep',
'output_schema': {'type': 'text'},
'include_domains': None,
'exclude_domains': None,
}
]
assert output == 'Deep answer.\n\nSources:\n- A: https://a.dev\n- (untitled): https://b.dev'
async def test_domains_plumbed(self) -> None:
client = _FakeExaClient(deep_response=_deep_response('Answer.'))
toolset = _toolset(client, include_deep_search=True, include_domains=['a.dev'])
await toolset.deep_search('q')
assert client.search_calls[0]['include_domains'] == ['a.dev']
async def test_structured_content_is_rendered_as_json(self) -> None:
client = _FakeExaClient(deep_response=_deep_response({'finding': 'x'}, citations=[('https://a.dev', 'A')]))
output = await _toolset(client, include_deep_search=True).deep_search('q')
assert output == '{"finding": "x"}\n\nSources:\n- A: https://a.dev'
async def test_without_grounding_falls_back_to_result_sources(self) -> None:
client = _FakeExaClient(
deep_response=_deep_response('Answer.', results=[_result('https://a.dev', title='A', text=None)])
)
client = _FakeExaClient(deep_response=_deep_response('Answer.', results=[_result('https://a.dev', title='A')]))
output = await _toolset(client, include_deep_search=True).deep_search('q')
assert output == 'Answer.\n\nSources:\n- A: https://a.dev'
@@ -249,6 +318,12 @@ class TestDeepSearch:
output = await _toolset(client, include_deep_search=True).deep_search('q')
assert output == 'Answer.'
async def test_answer_is_not_capped_by_max_text_chars(self) -> None:
long_answer = 'a' * 50
client = _FakeExaClient(deep_response=_deep_response(long_answer))
output = await _toolset(client, include_deep_search=True, max_text_chars=10).deep_search('q')
assert output == long_answer
async def test_missing_output_raises_model_retry(self) -> None:
client = _FakeExaClient(deep_response=_response())
with pytest.raises(ModelRetry, match='Deep search returned no answer'):
@@ -276,29 +351,45 @@ class TestExaSearch:
toolset = ExaSearch[None]().get_toolset()
assert isinstance(toolset, ExaSearchToolset)
def test_non_positive_num_results_rejected(self) -> None:
with pytest.raises(ValueError, match='num_results must be at least 1, got 0'):
ExaSearch[None](num_results=0, client=_FakeExaClient()).get_toolset()
@pytest.mark.parametrize('num_results', [0, 101])
def test_num_results_out_of_bounds_rejected(self, num_results: int) -> None:
with pytest.raises(ValueError, match=f'num_results must be between 1 and 100, got {num_results}'):
ExaSearch[None](num_results=num_results)
def test_non_positive_max_text_chars_rejected(self) -> None:
with pytest.raises(ValueError, match='max_text_chars must be at least 1, got -1'):
ExaSearch[None](max_text_chars=-1, client=_FakeExaClient()).get_toolset()
@pytest.mark.parametrize('max_text_chars', [0, 10_001])
def test_max_text_chars_out_of_bounds_rejected(self, max_text_chars: int) -> None:
with pytest.raises(ValueError, match=f'max_text_chars must be between 1 and 10000, got {max_text_chars}'):
ExaSearch[None](max_text_chars=max_text_chars)
def test_include_and_exclude_domains_are_mutually_exclusive(self) -> None:
with pytest.raises(ValueError, match='include_domains or exclude_domains, not both'):
ExaSearch[None](include_domains=['a.dev'], exclude_domains=['b.dev'])
def test_instructions_reference_the_tools(self) -> None:
instructions = ExaSearch[None]().get_instructions()
assert isinstance(instructions, str)
assert 'web_search' in instructions
assert 'get_page' in instructions
assert 'cite' in instructions
assert 'deep_search' not in instructions
def test_instructions_cover_deep_search_when_enabled(self) -> None:
base = ExaSearch[None]().get_instructions()
instructions = ExaSearch[None](include_deep_search=True).get_instructions()
assert instructions.startswith(ExaSearch[None]().get_instructions())
assert isinstance(base, str) and isinstance(instructions, str)
assert instructions.startswith(base)
assert 'escalate to `deep_search`' in instructions
def test_custom_guidance_replaces_default(self) -> None:
capability = ExaSearch[None](include_deep_search=True, guidance='Research with the Exa tools.')
assert capability.get_instructions() == 'Research with the Exa tools.'
def test_empty_guidance_disables_instructions(self) -> None:
assert ExaSearch[None](guidance='').get_instructions() is None
async def test_agent_run_uses_all_tools_and_instructions(self) -> None:
client = _FakeExaClient(
search_response=_response(_result('https://a.dev', title='A', text='alpha')),
search_response=_response(_result('https://a.dev', title='A', highlights=['alpha'])),
contents_response=_response(_result('https://b.dev', title='B', text='beta')),
deep_response=_deep_response('Deep answer.', citations=[('https://c.dev', 'C')]),
)
@@ -329,6 +420,26 @@ class TestExaSearch:
}
assert set(returns) == {'web_search', 'get_page', 'deep_search'}
query = calls['web_search']['query']
assert returns['web_search'] == f'Found 1 result for {query!r}:\n\nTitle: A\nURL: https://a.dev\n\nalpha'
assert returns['web_search'] == f'Found 1 result for {query!r}:\n\nTitle: A\nURL: https://a.dev\n\n- alpha'
assert returns['get_page'] == 'Title: B\nURL: https://b.dev\n\nbeta'
assert returns['deep_search'] == 'Deep answer.\n\nSources:\n- C: https://c.dev'
class TestAgentSpec:
def test_spec_schema_includes_exa_search(self) -> None:
schema = AgentSpec.model_json_schema_with_capabilities([ExaSearch])
assert 'ExaSearch' in json.dumps(schema)
def test_from_spec_builds_capability(self) -> None:
capability = ExaSearch[None].from_spec(num_results=3, include_deep_search=True, include_domains=['a.dev'])
assert capability.num_results == 3
assert capability.include_deep_search is True
assert capability.include_domains == ['a.dev']
assert capability.client is None
def test_agent_loads_from_spec_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv('EXA_API_KEY', 'test-key')
spec = tmp_path / 'agent.yaml'
spec.write_text('model: test\ncapabilities:\n - ExaSearch:\n num_results: 3\n')
agent = Agent.from_file(spec, custom_capability_types=[ExaSearch])
assert isinstance(agent, Agent)