Deprecate the Exa search common tools in favor of the ExaSearch capability in Pydantic AI Harness (#6644)

This commit is contained in:
David SF
2026-07-21 22:41:44 -05:00
committed by GitHub
parent 4c59b6053b
commit 24d105d21f
6 changed files with 117 additions and 90 deletions
+2
View File
@@ -44,3 +44,5 @@
- Avoid line numbers in comments/docstrings — use function/class names instead — Line numbers become stale immediately when code changes, breaking the reference and misleading readers
<!-- rule:656 -->
- Document new user-facing features in dedicated sections where users naturally encounter them, not just in docstrings — Users discover features through conceptual docs and guides, not API references — ensures feature discoverability and proper context
<!-- rule:-4 -->
- Link to a Pydantic AI Harness capability via its docs page at `https://pydantic.dev/docs/ai/harness/<slug>/` (e.g. `https://pydantic.dev/docs/ai/harness/exa-search/`, `https://pydantic.dev/docs/ai/harness/compaction/`), not its GitHub repo/README — the harness has published docs; only fall back to a GitHub link when a capability has no docs page yet — Gives readers rendered, canonical docs instead of raw source, and matches how the rest of the repo references the harness (`https://pydantic.dev/docs/ai/harness/`)
+1 -1
View File
@@ -4,7 +4,7 @@ The [`WebSearch`][pydantic_ai.capabilities.WebSearch] [capability](overview.md)
[`WebSearch`][pydantic_ai.capabilities.WebSearch] defaults to native-only. Backed by [`WebSearchTool`][pydantic_ai.native_tools.WebSearchTool] on the native side (see [Web Search Tool](../native-tools.md#web-search-tool) for provider support and configuration) — pass `native=WebSearchTool(...)` directly when you need full control over the native instance.
For the local side, pass `local='duckduckgo'` (or `local=True`) for a [DuckDuckGo](../common-tools.md#duckduckgo-search-tool) fallback (requires the `duckduckgo` optional group); for other search providers, use a [Tavily][pydantic_ai.common_tools.tavily.tavily_search_tool] or [Exa][pydantic_ai.common_tools.exa.ExaSearchTool] wrapper from [`common_tools`](../common-tools.md), or any callable, [`Tool`][pydantic_ai.tools.Tool], or [`AbstractToolset`][pydantic_ai.toolsets.AbstractToolset].
For the local side, pass `local='duckduckgo'` (or `local=True`) for a [DuckDuckGo](../common-tools.md#duckduckgo-search-tool) fallback (requires the `duckduckgo` optional group); for other search providers, use a [Tavily][pydantic_ai.common_tools.tavily.tavily_search_tool] wrapper from [`common_tools`](../common-tools.md), the [`ExaSearchToolset`](https://pydantic.dev/docs/ai/harness/exa-search/) from the Pydantic AI Harness, or any callable, [`Tool`][pydantic_ai.tools.Tool], or [`AbstractToolset`][pydantic_ai.toolsets.AbstractToolset].
Native constraint fields: `search_context_size`, `user_location`, `blocked_domains`, `allowed_domains`, `max_uses`. The domain and `max_uses` constraints require native support (the shipped DuckDuckGo fallback doesn't enforce them).
+13 -73
View File
@@ -211,82 +211,22 @@ Here are some recent papers about transformer architectures from arxiv.org:
## Exa Search Tool
!!! info
Exa is a paid service with free credits to explore their product.
!!! warning "Deprecated"
The Exa common tools (`exa_search_tool`, `exa_find_similar_tool`, `exa_get_contents_tool`, `exa_answer_tool`, and `ExaToolset`) are deprecated and will be removed in v3.
You need to [sign up for an account](https://dashboard.exa.ai) and get an API key to use the Exa tools.
Use the [`ExaSearch`](https://pydantic.dev/docs/ai/harness/exa-search/) capability from the Pydantic AI Harness instead, which bundles web search, full-page retrieval, deep search, and an `ExaAgent` capability for long-running research:
Exa is a neural search engine that finds high-quality, relevant results across billions of web pages.
It provides several tools including web search, finding similar pages, content retrieval, and AI-powered answers.
```bash
pip/uv-add "pydantic-ai-harness[exa]"
```
### Installation
```py {title="exa_search.py" test="skip"}
from pydantic_ai_harness.exa import ExaSearch
To use Exa tools, you need to install [`pydantic-ai-slim`](install.md#slim-install) with the `exa` optional group:
from pydantic_ai import Agent
```bash
pip/uv-add "pydantic-ai-slim[exa]"
```
agent = Agent('openai:gpt-5.2', capabilities=[ExaSearch()])
### Usage
You can use Exa tools individually or as a toolset. The following tools are available:
- [`exa_search_tool`][pydantic_ai.common_tools.exa.exa_search_tool]: Search the web with various search types (auto, keyword, neural, fast, deep)
- [`exa_find_similar_tool`][pydantic_ai.common_tools.exa.exa_find_similar_tool]: Find pages similar to a given URL
- [`exa_get_contents_tool`][pydantic_ai.common_tools.exa.exa_get_contents_tool]: Get full text content from URLs
- [`exa_answer_tool`][pydantic_ai.common_tools.exa.exa_answer_tool]: Get AI-powered answers with citations
#### Using Individual Tools
```py {title="exa_search.py" test="skip"}
import os
from pydantic_ai import Agent
from pydantic_ai.common_tools.exa import exa_search_tool
api_key = os.getenv('EXA_API_KEY')
assert api_key is not None
agent = Agent(
'openai:gpt-5.2',
tools=[exa_search_tool(api_key, num_results=5, max_characters=1000)],
system_prompt='Search the web for information using Exa.',
)
result = agent.run_sync('What are the latest developments in quantum computing?')
print(result.output)
```
#### Using ExaToolset
For better efficiency when using multiple Exa tools, use [`ExaToolset`][pydantic_ai.common_tools.exa.ExaToolset]
which shares a single API client across all tools. You can configure which tools to include:
```py {title="exa_toolset.py" test="skip"}
import os
from pydantic_ai import Agent
from pydantic_ai.common_tools.exa import ExaToolset
api_key = os.getenv('EXA_API_KEY')
assert api_key is not None
toolset = ExaToolset(
api_key,
num_results=5,
max_characters=1000, # Limit text content to control token usage
include_search=True, # Include the search tool (default: True)
include_find_similar=True, # Include the find_similar tool (default: True)
include_get_contents=False, # Exclude the get_contents tool
include_answer=True, # Include the answer tool (default: True)
)
agent = Agent(
'openai:gpt-5.2',
toolsets=[toolset],
system_prompt='You have access to Exa search tools to find information on the web.',
)
result = agent.run_sync('Find recent AI research papers and summarize the key findings.')
print(result.output)
```
result = agent.run_sync('What are the latest developments in quantum computing?')
print(result.output)
```
@@ -3,14 +3,21 @@
Provides web search, content retrieval, and AI-powered answer capabilities
using the Exa API, a neural search engine that finds high-quality, relevant
results across billions of web pages.
These tools are deprecated and will be removed in v3. Use the `ExaSearch` capability from the
[Pydantic AI Harness](https://pydantic.dev/docs/ai/harness/exa-search/) instead.
"""
# TODO(v3): remove this module in favor of `pydantic_ai_harness.exa`.
import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, overload
from typing_extensions import Any, TypedDict
from typing_extensions import Any, TypedDict, deprecated
from pydantic_ai import FunctionToolset
from pydantic_ai._warnings import PydanticAIDeprecationWarning
from pydantic_ai.tools import Tool
try:
@@ -270,6 +277,12 @@ def exa_search_tool(
) -> Tool[Any]: ...
@deprecated(
'`exa_search_tool` is deprecated and will be removed in v3. Use the `ExaSearch` capability from the Pydantic AI Harness '
'(`pip install "pydantic-ai-harness[exa]"`, then `from pydantic_ai_harness.exa import ExaSearch`) instead. '
'See https://pydantic.dev/docs/ai/harness/exa-search/.',
category=PydanticAIDeprecationWarning,
)
def exa_search_tool(
api_key: str | None = None,
*,
@@ -320,6 +333,12 @@ def exa_find_similar_tool(
) -> Tool[Any]: ...
@deprecated(
'`exa_find_similar_tool` is deprecated and will be removed in v3. Use the `ExaSearch` capability from the Pydantic AI Harness '
'(`pip install "pydantic-ai-harness[exa]"`, then `from pydantic_ai_harness.exa import ExaSearch`) instead. '
'See https://pydantic.dev/docs/ai/harness/exa-search/.',
category=PydanticAIDeprecationWarning,
)
def exa_find_similar_tool(
api_key: str | None = None,
*,
@@ -355,6 +374,12 @@ def exa_get_contents_tool(api_key: str) -> Tool[Any]: ...
def exa_get_contents_tool(*, client: AsyncExa) -> Tool[Any]: ...
@deprecated(
'`exa_get_contents_tool` is deprecated and will be removed in v3. Use the `ExaSearch` capability from the Pydantic AI Harness '
'(`pip install "pydantic-ai-harness[exa]"`, then `from pydantic_ai_harness.exa import ExaSearch`) instead. '
'See https://pydantic.dev/docs/ai/harness/exa-search/.',
category=PydanticAIDeprecationWarning,
)
def exa_get_contents_tool(
api_key: str | None = None,
*,
@@ -388,6 +413,12 @@ def exa_answer_tool(api_key: str) -> Tool[Any]: ...
def exa_answer_tool(*, client: AsyncExa) -> Tool[Any]: ...
@deprecated(
'`exa_answer_tool` is deprecated and will be removed in v3. Use the `ExaSearch` capability from the Pydantic AI Harness '
'(`pip install "pydantic-ai-harness[exa]"`, then `from pydantic_ai_harness.exa import ExaSearch`) instead. '
'See https://pydantic.dev/docs/ai/harness/exa-search/.',
category=PydanticAIDeprecationWarning,
)
def exa_answer_tool(
api_key: str | None = None,
*,
@@ -413,19 +444,24 @@ def exa_answer_tool(
)
@deprecated(
'`ExaToolset` is deprecated and will be removed in v3. Use the `ExaSearch` capability from the Pydantic AI Harness '
'(`pip install "pydantic-ai-harness[exa]"`, then `from pydantic_ai_harness.exa import ExaSearch`) instead. '
'See https://pydantic.dev/docs/ai/harness/exa-search/.',
category=PydanticAIDeprecationWarning,
)
class ExaToolset(FunctionToolset):
"""A toolset that provides Exa search tools with a shared client.
This is more efficient than creating individual tools when using multiple
Exa tools, as it shares a single API client across all tools.
Deprecated in favor of the [`ExaSearch`](https://pydantic.dev/docs/ai/harness/exa-search/)
capability in the Pydantic AI Harness:
```python {test="skip"}
from pydantic_ai_harness.exa import ExaSearch
Example:
```python
from pydantic_ai import Agent
from pydantic_ai.common_tools.exa import ExaToolset
toolset = ExaToolset(api_key='your-api-key')
agent = Agent('openai:gpt-5.2', toolsets=[toolset])
agent = Agent('openai:gpt-5.2', capabilities=[ExaSearch()])
```
"""
@@ -459,16 +495,21 @@ class ExaToolset(FunctionToolset):
client = AsyncExa(api_key=api_key)
tools: list[Tool[Any]] = []
if include_search:
tools.append(exa_search_tool(client=client, num_results=num_results, max_characters=max_characters))
# The per-tool factories are deprecated alongside `ExaToolset`; constructing the toolset already
# warned, so suppress their redundant warnings here.
with warnings.catch_warnings():
warnings.simplefilter('ignore', PydanticAIDeprecationWarning)
if include_find_similar:
tools.append(exa_find_similar_tool(client=client, num_results=num_results))
if include_search:
tools.append(exa_search_tool(client=client, num_results=num_results, max_characters=max_characters)) # pyright: ignore[reportDeprecated]
if include_get_contents:
tools.append(exa_get_contents_tool(client=client))
if include_find_similar:
tools.append(exa_find_similar_tool(client=client, num_results=num_results)) # pyright: ignore[reportDeprecated]
if include_answer:
tools.append(exa_answer_tool(client=client))
if include_get_contents:
tools.append(exa_get_contents_tool(client=client)) # pyright: ignore[reportDeprecated]
if include_answer:
tools.append(exa_answer_tool(client=client)) # pyright: ignore[reportDeprecated]
super().__init__(tools, id=id)
+2
View File
@@ -204,6 +204,8 @@ async def test_something(model: Model):
- Prefer `instructions=` over `system_prompt=` when the test doesn't specifically need the system-prompt code path — `instructions=` is the canonical entry point for non-system-prompt-specific behavior (cacheable prefix, persona priming, format guidance), and reserving `system_prompt=` for tests that exercise the system-prompt machinery keeps intent legible
- Never reference line numbers in test docstrings or comments (`lines 872-873`, `L42`, `line 100`) — they go stale on the next edit to the referenced file. Describe the condition or behavior instead
- When testing prompt caching, assert prefix stability, not just a cache hit — `cache_read_tokens > 0` only proves that some prefix was reused, not that the prefix you intended stayed stable as history grows; pin it by asserting the cacheable region (serialized leading blocks up to the breakpoint) is byte-identical across consecutive requests and/or that the cache read covers the full prior prefix, since a per-request injection or serialization that moves with history length silently busts the cache with no error
- When you deprecate a public symbol (add `@deprecated`), add a test asserting the warning fires — a `pytest.warns(PydanticAIDeprecationWarning, match=...)` block whose `match` pins both the symbol name and the migration guidance — so the deprecation is protected against accidental removal or a message rewrite, and any executable docstring/doc example that constructs the symbol is marked `{test="skip"}` (it would otherwise fail under `filterwarnings=["error"]`)
- Don't blanket-suppress `reportDeprecated` at file scope — no whole-file `# pyright: ignore` (or `# pyright: basic`) at the top of a file to kill every deprecation static-warning at once, since that also silences the next *unintended* use of a deprecated symbol in the file. Put `# pyright: ignore[reportDeprecated]` on each intentional call instead. (The runtime side is different and conventional: a module-level `warnings.filterwarnings('ignore', message=...)` scoped to one deprecation message, in a file that pervasively constructs the deprecated symbol it tests — see `test_temporal.py` / `test_prefect.py` — is fine, as long as a dedicated `pytest.warns` test still proves the deprecation fires.)
## Directory Structure
+42
View File
@@ -0,0 +1,42 @@
"""Tests that the deprecated Exa common tools emit `PydanticAIDeprecationWarning`.
Not a VCR test: the deprecation fires at construction time via `@deprecated`, before any Exa API
call is made, so there is no request to record. The `ExaToolset` case also pins the suppression of
the redundant per-factory warnings it triggers internally.
"""
from __future__ import annotations
import pytest
from pydantic_ai._warnings import PydanticAIDeprecationWarning
from pydantic_ai.common_tools.exa import (
ExaToolset, # pyright: ignore[reportDeprecated]
exa_answer_tool, # pyright: ignore[reportDeprecated]
exa_find_similar_tool, # pyright: ignore[reportDeprecated]
exa_get_contents_tool, # pyright: ignore[reportDeprecated]
exa_search_tool, # pyright: ignore[reportDeprecated]
)
def test_exa_factory_tools_deprecated():
# `match` pins both the symbol name and the `ExaSearch` migration target, so a message that
# dropped or misdirected the migration guidance would fail the test.
with pytest.warns(PydanticAIDeprecationWarning, match=r'`exa_search_tool` is deprecated.*`ExaSearch`'):
exa_search_tool(api_key='x') # pyright: ignore[reportDeprecated]
with pytest.warns(PydanticAIDeprecationWarning, match=r'`exa_find_similar_tool` is deprecated.*`ExaSearch`'):
exa_find_similar_tool(api_key='x') # pyright: ignore[reportDeprecated]
with pytest.warns(PydanticAIDeprecationWarning, match=r'`exa_get_contents_tool` is deprecated.*`ExaSearch`'):
exa_get_contents_tool(api_key='x') # pyright: ignore[reportDeprecated]
with pytest.warns(PydanticAIDeprecationWarning, match=r'`exa_answer_tool` is deprecated.*`ExaSearch`'):
exa_answer_tool(api_key='x') # pyright: ignore[reportDeprecated]
def test_exa_toolset_deprecated_emits_single_warning():
"""`ExaToolset` warns once; the per-factory warnings it triggers internally are suppressed."""
with pytest.warns(PydanticAIDeprecationWarning, match=r'`ExaToolset` is deprecated.*`ExaSearch`') as records:
toolset = ExaToolset(api_key='x') # pyright: ignore[reportDeprecated]
exa_warnings = [r for r in records if issubclass(r.category, PydanticAIDeprecationWarning)]
assert len(exa_warnings) == 1
assert set(toolset.tools) == {'exa_search', 'exa_find_similar', 'exa_get_contents', 'exa_answer'}