Skip to content

Exa Search

ExaSearch gives an agent web search backed by the Exa API. web_search returns excerpts per hit, get_page reads one URL in full, and opt-in deep_search returns a cited answer. ExaAgent hands long research tasks to the Exa Agent API as deferred tool calls.

Source

While Pydantic AI Harness is on 0.x releases, the API may change between minor releases; when it does, deprecation warnings and release-note migration guidance tell you (or your agent) exactly how to upgrade. See the version policy.

The model calls tools added by ExaSearch. web_search calls Exa search and returns the top five hits with title, URL and excerpts; get_page calls Exa get_contents for one URL, capped at 10,000 characters; the opt-in deep_search calls Exa search with type deep and returns one cited answer. The separate ExaAgent capability adds exa_agent, which creates an Exa Agent run and defers until it completes. Every tool returns a ToolReturn: the text goes to the model and the sources entry of its metadata holds ExaSource records for your app.

The problem

Search tools that return only titles and snippets force a second round of 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 has to be done for each research agent.

ExaSearch bundles that plumbing into a single capability: the research tools, per-tool output budgets, and short research guidance in the system prompt.

Usage

Install the exa extra and set the EXA_API_KEY environment variable (create a key at https://dashboard.exa.ai):

Terminal
pip install "pydantic-ai-harness[exa]"

Then pass ExaSearch to an Agent via the capabilities parameter:

from pydantic_ai import Agent
from pydantic_ai_harness import ExaSearch

agent = Agent('anthropic:claude-sonnet-5-5', capabilities=[ExaSearch()])

result = agent.run_sync('What changed in the latest stable Python release?')
print(result.output)

Tools

ExaSearch contributes these tools to the agent:

ToolPurpose
web_searchSearch the web and return the top num_results pages, each with title, URL, and its most relevant excerpts.
get_pageRetrieve the full text of one specific URL — a promising web_search hit, or a URL the user provided.
deep_searchRun Exa’s multi-step deep search and return a synthesized, cited answer. Opt-in via include_deep_search=True.
exa_agentDelegate a research task to an asynchronous Exa agent run. Provided by the separate ExaAgent capability.

web_search returns short excerpts (Exa highlights) rather than full page text, following Exa’s own guidance for agents, so surveying several sources stays cheap; the agent reads a chosen page with get_page.

get_page returns the first max_text_chars characters of the page. A longer page ends with [... page text truncated at N characters]. At the 10,000-character API maximum Exa cannot return the extra character that reveals truncation, so no marker appears. web_search returns at most num_results results, even if Exa returns more.

A web_search query with no matches returns No results found for {query!r}., which the model can relay to the user. An empty page or deep search answer, an HTTP error response such as a rate limit, or a network failure is returned to the model as a retry prompt, up to the tool’s retry limit; after that the run raises. Errors 401 and 403 (API key) and 402 (credits) are raised, and so is any other exa_py error, such as an option it rejects or a response it can’t parse.

Untrusted content

Titles, excerpts, page text, and deep search answers come from third-party pages, so they can carry instructions aimed at the model. Each title, URL, author, date, and excerpt is rendered on one line, so those fields can’t fake another result’s Title: or URL: line. Page text is passed through as returned, and the text_summary summary and the deep_search answer are Exa’s own synthesized text, so any of them can still contain such lines.

include_domains and exclude_domains don’t restrict get_page: it reads any URL the model passes, including one a page told it to read, and the URL can carry data from the conversation to that site’s owner. To screen tool output, add PromptInjectionDefender; to vet each read, require approval for get_page.

deep_search calls Exa search with type='deep' and a plain-text output schema: Exa expands the question into several queries, searches, and returns one answer with its cited sources listed below it. A call takes longer and costs more than web_search, and the model decides when to call it, so it is off by default:

from pydantic_ai_harness import ExaSearch

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. deep_search returns Exa’s answer untruncated; max_text_chars applies only to get_page.

Text summary

Set text_summary to have every web_search call also request Exa’s plain-text output schema, so the response carries a short summary synthesized from the results for question-style queries. Pass True for an unconstrained summary, or a string describing the desired format (sent as the schema’s description):

from pydantic_ai_harness import ExaSearch

ExaSearch(text_summary='One concise sentence with the requested facts.')

When Exa returns a summary, it is prepended to the results as a Summary: line.

Structured citations

Every tool returns a ToolReturn: return_value carries the text the model sees, including any Sources: block, and metadata carries the sources as structured ExaSource records ({'url': ..., 'title': ...}, one per URL, with title=None when the page has none) under the 'sources' key. Metadata is never sent to the model; the application reads it from the ToolReturnPart in the message history, so rendering citations needs no text parsing:

from pydantic_ai.messages import ModelRequest, ToolReturnPart

for message in result.all_messages():
    if isinstance(message, ModelRequest):
        for part in message.parts:
            if isinstance(part, ToolReturnPart) and part.metadata is not None:
                for source in part.metadata.get('sources', []):
                    print(source['url'], source['title'])

exa_agent results additionally carry the Exa run ID in metadata under RUN_ID_METADATA_KEY.

Instructions

ExaSearch contributes short research guidance to the system prompt: search 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. Set guidance to replace the default text, or to '' to contribute no instructions at all.

Configuration

Every field of ExaSearch with its default:

from pydantic_ai_harness import ExaSearch

ExaSearch(
    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)
    text_summary=False,         # web_search also returns a synthesized text summary
    include_deep_search=False,  # also expose the deep_search tool
    include_domains=[],         # search only these domains (not get_page)
    exclude_domains=[],         # never search these domains (not get_page)
    native=False,               # prefer the model's native web search, Exa as fallback
    guidance=None,              # None = default instructions, '' = none, str = custom
    client=None,                # ExaClient -- None wraps exa_py.AsyncExa from EXA_API_KEY
)

include_domains and exclude_domains apply to web_search and deep_search, not get_page (see Untrusted content), and are mutually exclusive: set one, not both. Out-of-range limits and setting both domain lists raise at construction.

Exa agent runs

The Exa Agent API runs open-ended research tasks asynchronously: a run is created, moves through queued -> running, and reaches a terminal status (completed, failed, or cancelled) after up to an hour. The separate ExaAgent capability maps that lifecycle onto Pydantic AI’s deferred tool calls: its exa_agent tool creates the run and defers, carrying the Exa run ID in the deferred call’s metadata.

from pydantic_ai import Agent
from pydantic_ai_harness import ExaAgent

agent = Agent('anthropic:claude-sonnet-5-5', capabilities=[ExaAgent()])

By default (execution='inline') the capability resolves its own deferred calls within the agent run by polling the Exa run to completion, so the tool behaves like a regular (if slow) tool. With execution='external' the calls bubble up as DeferredToolRequests output for the host application to resolve out of band — including from a different process, since the Exa run ID survives in the request metadata under RUN_ID_METADATA_KEY. The agent’s output_type must include DeferredToolRequests, otherwise the run raises instead of returning the deferred requests:

from pydantic_ai import Agent
from pydantic_ai.tools import DeferredToolRequests

from pydantic_ai_harness import ExaAgent

agent = Agent(
    'anthropic:claude-sonnet-5-5',
    output_type=[str, DeferredToolRequests],
    capabilities=[ExaAgent(execution='external')],
)

Render a finished run with the agent_run_result helper, passing the output_schema the capability was constructed with, so external resolution validates and renders results the way inline execution does. A schema mismatch raises ModelRetry; store it as the call’s result so the model gets a retry prompt, as it does inline. Skip deferred calls from other tools, which carry no run ID. Then feed the results and the original message history back into the agent to resume the deferred run:

from pydantic import BaseModel

from pydantic_ai import Agent
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ModelMessage
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults

from pydantic_ai_harness.exa import RUN_ID_METADATA_KEY, ExaAgentRuns, agent_run_result


async def resolve(
    requests: DeferredToolRequests,
    runs: ExaAgentRuns,
    output_schema: type[BaseModel] | dict[str, object] | None = None,
) -> DeferredToolResults:  # e.g. in a worker process
    results = DeferredToolResults()
    for call in requests.calls:
        run_id = requests.metadata.get(call.tool_call_id, {}).get(RUN_ID_METADATA_KEY)
        if not isinstance(run_id, str):
            continue  # another tool's deferred call
        run = await runs.poll_until_finished(run_id)
        try:
            results.calls[call.tool_call_id] = agent_run_result(run, output_schema=output_schema)
        except ModelRetry as retry:
            results.calls[call.tool_call_id] = retry
    return results


async def resume(
    agent: Agent[None, str | DeferredToolRequests], messages: list[ModelMessage], results: DeferredToolResults
) -> str | DeferredToolRequests:
    result = await agent.run(message_history=messages, deferred_tool_results=results)
    return result.output

Every field of ExaAgent with its default:

from pydantic_ai_harness import ExaAgent

ExaAgent(
    effort=None,            # an exa_py AgentEffort, e.g. 'low' or 'auto' -- None = API default
    execution='inline',     # 'inline' polls to completion; 'external' bubbles DeferredToolRequests
    output_schema=None,     # BaseModel class or dict schema for structured output
    system_prompt=None,     # forwarded to the Exa agent run
    poll_interval=1000,     # ms between polls when resolving inline (positive)
    timeout_ms=3_600_000,   # ms to wait for each run when resolving inline (positive)
    guidance=None,          # None = default instructions, '' = none, str = custom
    runs=None,              # ExaAgentRuns -- None wraps exa_py.AsyncExa from EXA_API_KEY
)

An output_schema model class is validated against the completed run’s structured output (mismatches surface as ModelRetry), while a dict schema is forwarded without client-side validation and is the agent-spec form. Terminal failures (failed, cancelled) are returned to the model as a structured message rather than raised, so the agent can decide how to proceed. Each result includes the run ID, which the model can pass back as previous_run_id to ask follow-up questions in the context of a previous run.

Inline execution polls the runs one after another, and timeout_ms applies to each, so a response that starts several runs can wait up to timeout_ms per run. A run whose polling times out keeps running on Exa, and the retry prompt gives the model its run ID to follow up on. When the agent run is cancelled while it polls, or polling fails with an error the model can’t fix, the capability cancels the Exa runs nothing will read: the ones it hasn’t finished polling and the ones it was leaving for the model to follow up on. This needs a cancel method on the runs client, as exa_py’s has, and holds the agent run up to about 5 seconds while Exa accepts the cancellations. Under durable execution the runs are left running, since the engine can resume the wait. If creating a run fails in a way that Exa may have acted on (a 5xx response, or a network failure after the request was sent), the error is raised instead of retried, so the model can’t start a second run.

Multiple instances

Two instances of the same capability register the same tool names, and share the default id (exa_search or exa_agent), so two that differ raise an error. To run several differently configured instances in one agent (for example one open-web ExaSearch and one pinned to specific domains), give each extra instance a distinct id and wrap it in core’s PrefixTools capability, which prefixes their tool names:

from pydantic_ai import Agent
from pydantic_ai.capabilities import PrefixTools

from pydantic_ai_harness import ExaSearch

agent = Agent(
    'anthropic:claude-sonnet-5-5',
    capabilities=[
        ExaSearch(),  # web_search, get_page
        PrefixTools(
            wrapped=ExaSearch(include_domains=['crunchbase.com'], guidance='', id='crunchbase_search'),
            prefix='cb',
        ),  # cb_web_search, cb_get_page
    ],
)

Set guidance='' on the wrapped instance (or replace it with text that tells the model when to use the prefixed tools), since each instance otherwise contributes the same default research guidance.

This also works for ExaAgent: it identifies its deferred calls by metadata it wrote when deferring, not by tool name, so a prefixed exa_agent still resolves inline, and multiple ExaAgent instances never claim each other’s calls.

Durable execution

Under durable execution, each Exa request is recorded, so a recovered run reuses the result instead of making the request again. Temporal and Prefect record each tool call in its own activity or task. DBOS runs function tools in workflow code, so there the request runs as its own step. ExaAgent records creating the Exa run and, with execution='inline', polling it to completion.

On Temporal an activity has 60 seconds by default, which deep_search can exceed. Give the tool calls longer with TemporalDurability(toolset_activity_config={'exa_search': ActivityConfig(...)}), keyed by the capability’s id, as in Temporal timeouts. ExaAgent polls its run in a capability activity, which takes the base activity_config, so set its start_to_close_timeout above timeout_ms.

The records are named after the capability’s id, which defaults to exa_search for ExaSearch and exa_agent for ExaAgent, so durable execution needs no configuration. Changing an id renames the records, which in-flight runs then cannot find.

Custom client

The default client wraps an exa_py.AsyncExa configured from the EXA_API_KEY environment variable, and offers only the calls the capabilities make, not the rest of AsyncExa. When the variable is missing, building the Agent fails with a setup hint; the capability itself constructs without it. An AsyncExa connection pool belongs to the event loop that opened it, so the default client builds a new AsyncExa when the agent runs on a new asyncio event loop after the previous one has stopped, for example in a second asyncio.run. Under Trio, which has no asyncio event loop to compare, it keeps the one it has. A client you pass is used as is. Pass any object satisfying the ExaClient protocol (the subset of AsyncExa the toolset calls) to configure authentication or the base URL explicitly, or to substitute a fake in tests:

from exa_py import AsyncExa

from pydantic_ai_harness import ExaSearch

ExaSearch(client=AsyncExa(api_key='...'))

ExaSearch vs core WebSearch

Pydantic AI core ships a provider-adaptive WebSearch capability: on models with a native search tool it uses the provider’s own search, executed server-side. On other models it raises unless you pass a local= fallback, such as local=True for DuckDuckGo. Reach for it when you want search that follows the model.

Reach for ExaSearch when you want the same search behavior on every model.

To use the provider’s search where there is one and Exa’s elsewhere, either:

  • Set ExaSearch(native=True). The capability adds the native web search tool, and Exa’s web_search is only sent to models without one. get_page and deep_search stay available on every model. include_domains and exclude_domains become the native tool’s allowed_domains and blocked_domains. A model whose native search can’t apply a domain filter you set gets Exa’s web_search instead, so the filter still holds: on Gemini, whose native search takes no domain filters, setting either list means Exa searches.

  • Or pass Exa’s search as the fallback of core WebSearch, to configure the native search with WebSearch’s own options:

    from pydantic_ai.capabilities import WebSearch
    from pydantic_ai_harness.exa import ExaSearch
    
    WebSearch(local=ExaSearch(num_results=10).web_search_tool())
    

Don’t combine WebSearch() with the default ExaSearch() on native-search models: on Anthropic models the native tool is also named web_search on the wire, so the request would carry two tools with the same name.

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). 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 caps output size, turns empty results into retry prompts, adds research instructions, and accepts a fake client for tests. The MCP server exposes Exa’s full tool catalog through core’s MCP capability. Its 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:

from pydantic_ai import Agent
from pydantic_ai.capabilities import MCP
from pydantic_ai_harness import ExaSearch

agent = Agent('anthropic:claude-sonnet-5-5', capabilities=[ExaSearch(), MCP('https://mcp.exa.ai/mcp')])

Agent spec (YAML/JSON)

ExaSearch works with Pydantic AI’s agent spec, so you can declare it in a config file instead of Python:

# agent.yaml
model: anthropic:claude-sonnet-5-5
capabilities:
  - ExaSearch:
      num_results: 3
      include_deep_search: true
  - ExaAgent:
      effort: low
from pydantic_ai import Agent
from pydantic_ai_harness import ExaAgent, ExaSearch

agent = Agent.from_file('agent.yaml', custom_capability_types=[ExaSearch, ExaAgent])

The client and runs fields are not spec-serializable; spec-loaded instances always build the default client from EXA_API_KEY. In specs, output_schema takes the JSON-schema dict form; Pydantic model classes are only available when constructing the capability in Python.

Telemetry

ExaSearch and ExaAgent emit no spans of their own. Core’s tool-call spans cover each Exa search, page read, and run creation, and inline exa_agent polling runs inside the agent run’s span.

Further reading

API reference

ExaSearch

Bases: AbstractCapability[AgentDepsT]

Web research for agents, backed by the Exa search API.

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 text_summary to have web_search also return a synthesized text summary with the results. 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.

from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaSearch

agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaSearch()])

Authentication comes from the EXA_API_KEY environment variable by default; pass client to configure it explicitly.

Each tool’s Exa request runs as a durable operation, so under durable execution a recovered run reuses the recorded result instead of making the request again.

Attributes

num_results

Number of results web_search returns per query (1 to 100, the Exa API range).

Type: int Default: 5

max_text_chars

Maximum characters of page text get_page returns (1 to 10,000, the Exa API range).

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.

Type: int Default: 10000

text_summary

Have web_search also return a synthesized text summary above the results.

When enabled, each web_search call requests Exa’s plain-text output schema, so the response carries a short summary synthesized from the results in addition to the result list. Pass a string to describe the desired summary format (it is sent as the schema’s description), or True for an unconstrained summary. The tool’s return shape is unchanged: the summary is prepended as a Summary: line when Exa returns one.

Type: bool | str Default: False

Also expose the deep_search tool.

Deep search (Exa search type='deep') runs a multi-step agentic search and synthesizes a cited answer in one call. Each call invests more time and search depth than web_search, and the model decides when to invoke tools, so that investment is opt-in rather than the default.

Type: bool Default: False

include_domains

If non-empty, search results only come from these domains (allowlist).

Applies to web_search and deep_search, not get_page, which reads any URL the model passes. Mutually exclusive with exclude_domains.

Type: Sequence[str] Default: field(default_factory=(list[str]))

exclude_domains

Search results never come from these domains (denylist).

Applies to web_search and deep_search, not get_page. Mutually exclusive with include_domains.

Type: Sequence[str] Default: field(default_factory=(list[str]))

guidance

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.

Type: str | None Default: None

client

Exa client to use; when None, a default client wrapping exa_py.AsyncExa is built from EXA_API_KEY.

Any object satisfying the ExaClient protocol works: use it to pass an API key explicitly, point at a different base URL, or substitute a fake in tests.

Type: ExaClient | None Default: None

native

Use the model’s native web search where it has one, with Exa’s web_search as the fallback.

When enabled, the capability also adds the provider’s native web search tool, and Exa’s web_search is only sent to models that do not support it, so the two never share a request. get_page and deep_search stay Exa’s on every model. include_domains and exclude_domains are passed to the native tool as allowed_domains and blocked_domains, and a model whose native search can’t apply them (such as Gemini) gets Exa’s web_search instead. num_results and text_summary only apply to Exa’s search.

To use Exa as the fallback of a core WebSearch instead, with its native options and no get_page, pass WebSearch(local=ExaSearch().web_search_tool()).

Type: bool Default: False

id

Stable identity for durable execution, which records each Exa request under it.

Type: str | None Default: 'exa_search'

Methods

__post_init__
def __post_init__() -> None

Validate configuration against the Exa API’s documented bounds.

Returns

None

get_instructions
def get_instructions() -> AgentInstructions[AgentDepsT] | None

Static research guidance: search wide, read the promising pages in full, cite URLs.

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.

Returns

AgentInstructions[AgentDepsT] | None

combine

@classmethod

def combine(
    cls,
    capabilities: Sequence[AbstractCapability[AgentDepsT]],
) -> AbstractCapability[AgentDepsT]

Two under one id are one configuration stated twice; two that disagree raise rather than merge.

Returns

AbstractCapability[AgentDepsT]

get_toolset
def get_toolset() -> ExaSearchToolset[AgentDepsT]

Build the toolset providing web_search, get_page, and the optional deep_search tool.

Returns

ExaSearchToolset[AgentDepsT]

get_native_tools
def get_native_tools() -> Sequence[AbstractNativeTool]

The native web search tool, when native is set.

Returns

Sequence[AbstractNativeTool]

web_search_tool
def web_search_tool() -> Tool[AgentDepsT]

Exa’s web_search on its own, configured from this capability, to pass as WebSearch(local=...).

Returns

Tool[AgentDepsT]

from_spec

@classmethod

def from_spec(
    cls,
    *,
    num_results: int = 5,
    max_text_chars: int = 10000,
    text_summary: bool | str = False,
    include_deep_search: bool = False,
    include_domains: Sequence[str] = (),
    exclude_domains: Sequence[str] = (),
    guidance: str | None = None,
    native: bool = False,
    id: str | None = 'exa_search',
    description: str | None = None,
    defer_loading: bool = False,
) -> ExaSearch[AgentDepsT]

Construct the capability from serializable spec options.

The client field is not spec-serializable, so spec-loaded instances always build the default client, which wraps exa_py.AsyncExa, from EXA_API_KEY.

Returns

ExaSearch[AgentDepsT]

ExaAgent

Bases: AbstractCapability[AgentDepsT]

Delegation of deep research tasks to the Exa Agent API.

Adds one tool, exa_agent, which creates an asynchronous Exa agent run (queued -> running -> terminal, up to an hour) and defers the tool call. By default the capability resolves its own deferred calls inline by polling the run to completion. With execution='external' the calls surface as DeferredToolRequests output instead, for the host application to resolve out of band (the run ID is in the request metadata under RUN_ID_METADATA_KEY), including across process restarts.

from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaAgent

agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaAgent()])

Authentication comes from the EXA_API_KEY environment variable by default; pass runs to configure it explicitly.

Creating a run and polling it to completion are durable operations, so under durable execution a recovered run reuses the recorded run ID and result instead of creating or polling the Exa run again.

Attributes

effort

How much work the Exa agent invests per run; None uses the API default.

Type: AgentEffort | None Default: None

execution

How deferred exa_agent calls are resolved.

With 'inline', the capability polls the run to completion during the agent run. With 'external', calls bubble up as DeferredToolRequests output for the host application to resolve (see agent_run_result), which suits durable workers that outlive a single process.

Type: Literal[‘inline’, ‘external’] Default: 'inline'

output_schema

Structured output schema for the Exa agent’s result. None returns prose.

Accepts a Pydantic model class or a JSON-schema-style dict. A model class is forwarded to the API and a completed run’s structured output is validated against it (a mismatch surfaces as a retry). The dict form skips client-side validation and is the serializable shape used by agent specs.

Type: type[BaseModel] | dict[str, object] | None Default: None

system_prompt

System prompt forwarded to the Exa agent run; None uses the API default.

Type: str | None Default: None

poll_interval

Milliseconds between polls while resolving a run inline. Must be positive.

Type: int Default: 1000

timeout_ms

Milliseconds to wait for each run to finish when resolving inline. Must be positive.

Calls are polled one after another, each with its own timeout, so a model response that starts several runs can wait up to this long per run.

Type: int Default: 3600000

guidance

Custom delegation guidance for the system prompt.

Leave as None for the default guidance, or set '' to contribute no instructions at all.

Type: str | None Default: None

runs

Exa Agent runs client; when None, a default client wrapping exa_py.AsyncExa is built from EXA_API_KEY.

Any object satisfying the ExaAgentRuns protocol works: use it to pass an API key explicitly or substitute a fake in tests.

Type: ExaAgentRuns | None Default: None

id

Stable identity for durable execution, which records each Exa run’s creation and result under it.

Type: str | None Default: 'exa_agent'

Methods

__post_init__
def __post_init__() -> None

Validate the inline polling bounds.

Returns

None

get_instructions
def get_instructions() -> AgentInstructions[AgentDepsT] | None

Static delegation guidance: when to hand a task to exa_agent, and run ID continuation.

A non-None guidance replaces the default; '' disables instructions entirely.

Returns

AgentInstructions[AgentDepsT] | None

combine

@classmethod

def combine(
    cls,
    capabilities: Sequence[AbstractCapability[AgentDepsT]],
) -> AbstractCapability[AgentDepsT]

Two under one id are one configuration stated twice; two that disagree raise rather than merge.

Returns

AbstractCapability[AgentDepsT]

get_toolset
def get_toolset() -> ExaAgentToolset[AgentDepsT]

Build the toolset providing the exa_agent tool.

Returns

ExaAgentToolset[AgentDepsT]

handle_deferred_tool_calls

@async

def handle_deferred_tool_calls(
    ctx: RunContext[AgentDepsT],
    *,
    requests: DeferredToolRequests,
) -> DeferredToolResults | None

Resolve deferred exa_agent calls inline by polling the Exa run to completion.

With execution='external' all calls are left unresolved, so they bubble up as DeferredToolRequests output for the host application to resolve; the Exa run ID is available in requests.metadatatool_call_id.

Calls are claimed by the owner token in the deferred-call metadata rather than by tool name, so tool renaming or prefixing wrappers (e.g. PrefixTools) do not break inline resolution.

Returns

DeferredToolResults | None

from_spec

@classmethod

def from_spec(
    cls,
    *,
    effort: AgentEffort | None = None,
    execution: Literal['inline', 'external'] = 'inline',
    output_schema: dict[str, object] | None = None,
    system_prompt: str | None = None,
    poll_interval: int = 1000,
    timeout_ms: int = 3600000,
    guidance: str | None = None,
    id: str | None = 'exa_agent',
    description: str | None = None,
    defer_loading: bool = False,
) -> ExaAgent[AgentDepsT]

Construct the capability from serializable spec options.

The runs field is not spec-serializable, so spec-loaded instances always build the default client from EXA_API_KEY. output_schema takes the JSON-schema dict form here; Pydantic model classes are only available when constructing the capability in Python.

Returns

ExaAgent[AgentDepsT]

agent_run_result

def agent_run_result(
    run: AgentRun,
    *,
    output_schema: type[BaseModel] | dict[str, object] | None = None,
) -> ToolReturn[str]

Render a terminal Exa agent run as the exa_agent tool result.

Use it when resolving externally executed exa_agent calls in a host application (building DeferredToolResults), so external and inline execution produce the same result shape. When output_schema is a Pydantic model class, a completed run’s structured output is validated against it; a mismatch raises ModelRetry.

The ToolReturn.return_value text is what the model sees; its metadata carries the run ID under RUN_ID_METADATA_KEY and the citation sources (ExaSource dicts) for the application to use directly.

Returns

ToolReturn[str]

ExaSource

Bases: TypedDict

One source behind a tool result, carried in ToolReturn.metadata['sources'].