Skip to content

API Reference

The full public API of deep_search_agent. Everything documented here is exported from the top-level package and listed in __all__.

Factory

deep_search_agent.create_deep_search_agent

create_deep_search_agent(*, model: str | BaseChatModel, max_research_cycles: int = 3, max_query_variants: int = 3, max_search_results_per_query: int = 5, max_urls_to_scrape_per_cycle: int = 3, searxng_base_url: str = DEFAULT_SEARXNG_BASE_URL, searxng_engines: Sequence[str] | None = None, searxng_rate_limit: float | None = None, searxng_budget: int | None = None, request_timeout: float = 15.0, max_content_chars_per_page: int = 20000, enable_js_render_fallback: bool = False, js_render_timeout: float = 30.0, search_tools: Sequence[BaseTool] | None = None, enable_perspectives: bool = True, rubric: str | None = None, auto_rubric: bool = True, on_evaluation: Callable[[RubricEvaluation], None] | None = None, system_prompt: str | SystemMessage | None = None, middleware: Sequence[AgentMiddleware] = (), subagents_middleware: Sequence[AgentMiddleware] = (), subagents: Sequence[Any] | None = None, backend: BackendProtocol | BackendFactory | None = None, metrics: SessionMetrics | None = None, **create_deep_agent_kwargs: Any) -> CompiledStateGraph

Create a deep-search agent (orchestrator + specialized sub-agents).

The returned agent decomposes the user's query into sub-questions, delegates them to isolated sub-agents (web search, content fetching, fact checking), accumulates sourced findings on the shared filesystem backend, and synthesizes a cited answer. An LLM-as-a-judge loop (deepagents' beta RubricMiddleware) grades the answer against a rubric and triggers additional research cycles until the rubric is satisfied or max_research_cycles is reached.

Parameters:

Name Type Description Default
model str | BaseChatModel

Model for the orchestrator and, unless overridden per sub-agent, for the sub-agents too. The rubric grader inherits this same model. Required — either a provider string (e.g. "anthropic:claude-sonnet-4-6") or a BaseChatModel.

required
max_research_cycles int

Maximum refinement cycles of the evaluator loop (RubricMiddleware.max_iterations). Also quoted in the orchestrator's instructions as its iteration budget.

3
max_query_variants int

Number of distinct query reformulations the search agent issues in parallel per sub-question (synonyms, broader/narrower terms, English variants, different angle) to widen recall before deduplicating results. Quoted in the search agent's instructions.

3
max_search_results_per_query int

Result budget per search query, enforced by the SearxNG tool and quoted in the search agent's instructions.

5
max_urls_to_scrape_per_cycle int

URL-fetch budget per research cycle, quoted in the orchestrator's instructions.

3
searxng_base_url str

Root URL of the SearxNG instance used by the built-in search tool.

DEFAULT_SEARXNG_BASE_URL
searxng_engines Sequence[str] | None

Optional SearxNG engine allowlist (e.g. ["duckduckgo", "wikipedia"]).

None
searxng_rate_limit float | None

Minimum number of seconds between two SearxNG requests. Sub-agents may run searches concurrently through deepagents' thread pool, so this is enforced by a thread-safe min-interval limiter shared by the built-in search tool. When a request would have to wait longer than request_timeout for a free slot, the tool returns an ERROR: ... string instead of performing it. None (default) disables rate limiting.

None
searxng_budget int | None

Maximum number of SearxNG search operations allowed in a single research cycle. Once exhausted, the search tool returns an ERROR: ... string telling the model no budget is left; the counter is reset at each research-cycle boundary of the evaluator loop. None (default) means unlimited.

None
request_timeout float

HTTP timeout (seconds) for both the search and the fetch tools.

15.0
max_content_chars_per_page int

Truncation limit for content extracted by the fetch tool.

20000
enable_js_render_fallback bool

When True, a page whose static HTML yields no extractable content is re-fetched by the fetch tool through a headless Chromium (Playwright) and extracted again, recovering JavaScript-only pages and bot walls that would otherwise be lost as sources. Requires the js-render extra plus installed Playwright browsers (pip install deep-search-agent[js-render] and playwright install chromium); when either is missing the fetch tool returns an ERROR: ... string rather than raising. False (default) keeps the library browser-free.

False
js_render_timeout float

Seconds the headless renderer waits for a page to settle. Distinct from request_timeout because rendering is much slower than a plain HTTP request. Ignored unless enable_js_render_fallback is True.

30.0
search_tools Sequence[BaseTool] | None

Extra search tools (e.g. a Tavily tool or an internal RAG retrieval tool) made available to search-agent and fact-check-agent alongside the built-in SearxNG tool.

None
enable_perspectives bool

When True (default), adds perspective-agent to the built-in sub-agents and instructs the orchestrator to delegate to it before decomposing the query, so research explores the topic from 3-6 distinct angles (analysis axes, stakeholder viewpoints, dimensions of the problem) instead of a single flat list of sub-questions. Set to False for simple queries where a single-axis decomposition is sufficient, to skip the extra delegation cycle and its token cost.

True
rubric str | None

Custom grading rubric (newline-delimited checklist). Defaults to :data:~deep_search_agent.prompts.DEEP_SEARCH_RUBRIC.

None
auto_rubric bool

When True (default), the rubric is auto-injected into the invocation state so the evaluation loop works out of the box. When False, the loop only activates if the caller passes a rubric key in the invocation state.

True
on_evaluation Callable[[RubricEvaluation], None] | None

Optional callback invoked with each :class:~deepagents.middleware.rubric.RubricEvaluation after the grader scores a research cycle, e.g. to log the per-criterion verdicts or stream progress to a UI. Exceptions it raises are logged and suppressed by the underlying RubricMiddleware, so it must not be used to enforce control flow. None (default) registers no callback.

None
system_prompt str | SystemMessage | None

Override for the orchestrator system prompt. Defaults to the built-in deep-search orchestrator prompt parametrized with the cycle/URL budgets.

None
middleware Sequence[AgentMiddleware]

Extra middleware appended after the rubric middleware.

()
subagents_middleware Sequence[AgentMiddleware]

Extra middleware injected into each built-in sub-agent (perspective-agent when enabled, search-agent, fetch-agent, fact-check-agent), e.g. for logging or rate limiting. Sub-agents passed via subagents are caller-owned and left untouched.

()
subagents Sequence[Any] | None

Extra sub-agents (e.g. a RAG retrieval agent over an internal knowledge base) added alongside the built-in search-agent, fetch-agent, fact-check-agent, and (when enabled) perspective-agent.

None
backend BackendProtocol | BackendFactory | None

Filesystem backend shared by the orchestrator and every sub-agent, so that /findings/<source-slug>.md files written by a sub-agent flow back to the orchestrator on the same virtual filesystem. When omitted, a single :class:~deepagents.backends.StateBackend instance is created and shared; when provided, that exact instance is propagated to create_deep_agent.

None
metrics SessionMetrics | None

Optional :class:~deep_search_agent.metrics.SessionMetrics collector. When provided, observation-only middleware are injected into the orchestrator and each built-in sub-agent to record, over the whole session, per-cycle and global tool-call counts, sub-agent invocation counts and execution times (avg/min/max), and the overall execution time. The caller owns the instance and reads the results from it after the run (metrics accumulate until :meth:SessionMetrics.reset). None (default) disables metrics.

None
**create_deep_agent_kwargs Any

Any remaining create_deep_agent parameter (tools, checkpointer, store, skills, interrupt_on, ...), passed through unchanged.

{}

Returns:

Type Description
CompiledStateGraph

The compiled deep agent graph, ready for invoke / astream.

Raises:

Type Description
ValueError

If model is missing, an integer budget parameter is not a positive integer, searxng_rate_limit or (when the fallback is enabled) js_render_timeout is not a positive number, or a reserved sub-agent name is reused.

Source code in deep_search_agent/factory.py
def create_deep_search_agent(
    *,
    model: str | BaseChatModel,
    max_research_cycles: int = 3,
    max_query_variants: int = 3,
    max_search_results_per_query: int = 5,
    max_urls_to_scrape_per_cycle: int = 3,
    searxng_base_url: str = DEFAULT_SEARXNG_BASE_URL,
    searxng_engines: Sequence[str] | None = None,
    searxng_rate_limit: float | None = None,
    searxng_budget: int | None = None,
    request_timeout: float = 15.0,
    max_content_chars_per_page: int = 20_000,
    enable_js_render_fallback: bool = False,
    js_render_timeout: float = 30.0,
    search_tools: Sequence[BaseTool] | None = None,
    enable_perspectives: bool = True,
    rubric: str | None = None,
    auto_rubric: bool = True,
    on_evaluation: Callable[[RubricEvaluation], None] | None = None,
    system_prompt: str | SystemMessage | None = None,
    middleware: Sequence[AgentMiddleware] = (),
    subagents_middleware: Sequence[AgentMiddleware] = (),
    subagents: Sequence[Any] | None = None,
    backend: BackendProtocol | BackendFactory | None = None,
    metrics: SessionMetrics | None = None,
    **create_deep_agent_kwargs: Any,
) -> CompiledStateGraph:
    """Create a deep-search agent (orchestrator + specialized sub-agents).

    The returned agent decomposes the user's query into sub-questions,
    delegates them to isolated sub-agents (web search, content fetching,
    fact checking), accumulates sourced findings on the shared filesystem
    backend, and synthesizes a cited answer. An LLM-as-a-judge loop
    (deepagents' beta ``RubricMiddleware``) grades the answer against a
    rubric and triggers additional research cycles until the rubric is
    satisfied or ``max_research_cycles`` is reached.

    Args:
        model: Model for the orchestrator and, unless overridden per
            sub-agent, for the sub-agents too. The rubric grader inherits
            this same model. Required — either a provider string (e.g.
            ``"anthropic:claude-sonnet-4-6"``) or a ``BaseChatModel``.
        max_research_cycles: Maximum refinement cycles of the evaluator
            loop (``RubricMiddleware.max_iterations``). Also quoted in the
            orchestrator's instructions as its iteration budget.
        max_query_variants: Number of distinct query reformulations the
            search agent issues in parallel per sub-question (synonyms,
            broader/narrower terms, English variants, different angle) to
            widen recall before deduplicating results. Quoted in the search
            agent's instructions.
        max_search_results_per_query: Result budget per search query,
            enforced by the SearxNG tool and quoted in the search agent's
            instructions.
        max_urls_to_scrape_per_cycle: URL-fetch budget per research cycle,
            quoted in the orchestrator's instructions.
        searxng_base_url: Root URL of the SearxNG instance used by the
            built-in search tool.
        searxng_engines: Optional SearxNG engine allowlist
            (e.g. ``["duckduckgo", "wikipedia"]``).
        searxng_rate_limit: Minimum number of seconds between two SearxNG
            requests. Sub-agents may run searches concurrently through
            deepagents' thread pool, so this is enforced by a thread-safe
            min-interval limiter shared by the built-in search tool. When a
            request would have to wait longer than ``request_timeout`` for a
            free slot, the tool returns an ``ERROR: ...`` string instead of
            performing it. ``None`` (default) disables rate limiting.
        searxng_budget: Maximum number of SearxNG search operations allowed in
            a single research cycle. Once exhausted, the search tool returns an
            ``ERROR: ...`` string telling the model no budget is left; the
            counter is reset at each research-cycle boundary of the evaluator
            loop. ``None`` (default) means unlimited.
        request_timeout: HTTP timeout (seconds) for both the search and the
            fetch tools.
        max_content_chars_per_page: Truncation limit for content extracted
            by the fetch tool.
        enable_js_render_fallback: When ``True``, a page whose static HTML
            yields no extractable content is re-fetched by the fetch tool
            through a headless Chromium (Playwright) and extracted again,
            recovering JavaScript-only pages and bot walls that would otherwise
            be lost as sources. Requires the ``js-render`` extra plus installed
            Playwright browsers (``pip install deep-search-agent[js-render]``
            and ``playwright install chromium``); when either is missing the
            fetch tool returns an ``ERROR: ...`` string rather than raising.
            ``False`` (default) keeps the library browser-free.
        js_render_timeout: Seconds the headless renderer waits for a page to
            settle. Distinct from ``request_timeout`` because rendering is much
            slower than a plain HTTP request. Ignored unless
            ``enable_js_render_fallback`` is ``True``.
        search_tools: Extra search tools (e.g. a Tavily tool or an internal
            RAG retrieval tool) made available to ``search-agent`` and
            ``fact-check-agent`` alongside the built-in SearxNG tool.
        enable_perspectives: When ``True`` (default), adds ``perspective-agent``
            to the built-in sub-agents and instructs the orchestrator to
            delegate to it before decomposing the query, so research explores
            the topic from 3-6 distinct angles (analysis axes, stakeholder
            viewpoints, dimensions of the problem) instead of a single flat
            list of sub-questions. Set to ``False`` for simple queries where a
            single-axis decomposition is sufficient, to skip the extra
            delegation cycle and its token cost.
        rubric: Custom grading rubric (newline-delimited checklist).
            Defaults to :data:`~deep_search_agent.prompts.DEEP_SEARCH_RUBRIC`.
        auto_rubric: When ``True`` (default), the rubric is auto-injected
            into the invocation state so the evaluation loop works out of
            the box. When ``False``, the loop only activates if the caller
            passes a ``rubric`` key in the invocation state.
        on_evaluation: Optional callback invoked with each
            :class:`~deepagents.middleware.rubric.RubricEvaluation` after the
            grader scores a research cycle, e.g. to log the per-criterion
            verdicts or stream progress to a UI. Exceptions it raises are
            logged and suppressed by the underlying ``RubricMiddleware``, so it
            must not be used to enforce control flow. ``None`` (default)
            registers no callback.
        system_prompt: Override for the orchestrator system prompt. Defaults
            to the built-in deep-search orchestrator prompt parametrized
            with the cycle/URL budgets.
        middleware: Extra middleware appended after the rubric middleware.
        subagents_middleware: Extra middleware injected into each built-in
            sub-agent (``perspective-agent`` when enabled, ``search-agent``,
            ``fetch-agent``, ``fact-check-agent``), e.g. for logging or rate
            limiting. Sub-agents passed via ``subagents`` are caller-owned and
            left untouched.
        subagents: Extra sub-agents (e.g. a RAG retrieval agent over an
            internal knowledge base) added alongside the built-in
            ``search-agent``, ``fetch-agent``, ``fact-check-agent``, and
            (when enabled) ``perspective-agent``.
        backend: Filesystem backend shared by the orchestrator and every
            sub-agent, so that ``/findings/<source-slug>.md`` files written by
            a sub-agent flow back to the orchestrator on the same virtual
            filesystem. When omitted, a single :class:`~deepagents.backends.StateBackend`
            instance is created and shared; when provided, that exact instance
            is propagated to ``create_deep_agent``.
        metrics: Optional :class:`~deep_search_agent.metrics.SessionMetrics`
            collector. When provided, observation-only middleware are injected
            into the orchestrator and each built-in sub-agent to record, over the
            whole session, per-cycle and global tool-call counts, sub-agent
            invocation counts and execution times (avg/min/max), and the overall
            execution time. The caller owns the instance and reads the results
            from it after the run (metrics accumulate until
            :meth:`SessionMetrics.reset`). ``None`` (default) disables metrics.
        **create_deep_agent_kwargs: Any remaining ``create_deep_agent``
            parameter (``tools``, ``checkpointer``, ``store``, ``skills``,
            ``interrupt_on``, ...), passed through unchanged.

    Returns:
        The compiled deep agent graph, ready for ``invoke`` / ``astream``.

    Raises:
        ValueError: If ``model`` is missing, an integer budget parameter is
            not a positive integer, ``searxng_rate_limit`` or (when the
            fallback is enabled) ``js_render_timeout`` is not a positive
            number, or a reserved sub-agent name is reused.
    """
    if model is None:
        msg = (
            "create_deep_search_agent requires an explicit `model`: the "
            "rubric grader inherits it and deepagents' implicit default "
            "model is deprecated."
        )
        raise ValueError(msg)
    _validate_positive("max_research_cycles", max_research_cycles)
    _validate_positive("max_query_variants", max_query_variants)
    _validate_positive("max_search_results_per_query", max_search_results_per_query)
    _validate_positive("max_urls_to_scrape_per_cycle", max_urls_to_scrape_per_cycle)
    if searxng_rate_limit is not None:
        _validate_positive_number("searxng_rate_limit", searxng_rate_limit)
    if enable_js_render_fallback:
        _validate_positive_number("js_render_timeout", js_render_timeout)
    if searxng_budget is not None:
        _validate_positive("searxng_budget", searxng_budget)

    # Resolve the shared backend up front so the orchestrator and the
    # sub-agents provably operate on the same virtual filesystem: default to a
    # single StateBackend when the caller does not supply one, otherwise
    # propagate the caller's instance unchanged.
    effective_backend = backend if backend is not None else StateBackend()

    # --- Tools for the sub-agents -----------------------------------------
    # The budget lives in the tool closure so it is shared across the
    # (possibly concurrent) sub-agents that call the search tool; a middleware
    # resets it at each research-cycle boundary (see below).
    search_budget = SearchBudget(searxng_budget) if searxng_budget is not None else None
    searxng_tool = create_searxng_search_tool(
        base_url=searxng_base_url,
        engines=searxng_engines,
        timeout=request_timeout,
        max_results=max_search_results_per_query,
        min_request_interval=searxng_rate_limit,
        budget=search_budget,
    )
    all_search_tools: list[BaseTool] = [searxng_tool, *(search_tools or [])]
    fetch_tool = create_fetch_url_tool(
        timeout=request_timeout,
        max_content_chars=max_content_chars_per_page,
        enable_js_render_fallback=enable_js_render_fallback,
        js_render_timeout=js_render_timeout,
    )

    # --- Sub-agents --------------------------------------------------------
    # When metrics are collected, each built-in sub-agent also carries its own
    # metrics middleware (added after the caller's ``subagents_middleware``) so
    # invocations, timings, and tool calls are attributed to the right agent.
    def _subagent_middleware(name: str) -> Sequence[AgentMiddleware]:
        base = list(subagents_middleware)
        if metrics is not None:
            base.append(_SubagentMetricsMiddleware(metrics, name))
        return base

    built_in_subagents = []
    if enable_perspectives:
        built_in_subagents.append(
            build_perspective_subagent(
                all_search_tools,
                middleware=_subagent_middleware(PERSPECTIVE_AGENT_NAME),
            )
        )
    built_in_subagents.extend(
        [
            build_search_subagent(
                all_search_tools,
                max_query_variants=max_query_variants,
                max_search_results_per_query=max_search_results_per_query,
                middleware=_subagent_middleware(SEARCH_AGENT_NAME),
            ),
            build_fetch_subagent(
                fetch_tool, middleware=_subagent_middleware(FETCH_AGENT_NAME)
            ),
            build_fact_check_subagent(
                all_search_tools,
                fetch_tool,
                middleware=_subagent_middleware(FACT_CHECK_AGENT_NAME),
            ),
        ]
    )
    # perspective-agent is always reserved, even when enable_perspectives is
    # False, so a caller-supplied sub-agent can never collide with it and
    # toggling the flag never changes name-collision behavior.
    reserved_names = {
        SEARCH_AGENT_NAME,
        FETCH_AGENT_NAME,
        FACT_CHECK_AGENT_NAME,
        PERSPECTIVE_AGENT_NAME,
    }
    extra_subagents = list(subagents or [])
    for agent in extra_subagents:
        name = (
            agent["name"] if isinstance(agent, dict) else getattr(agent, "name", None)
        )
        if name in reserved_names:
            msg = f"subagent name {name!r} is reserved by deep_search_agent"
            raise ValueError(msg)

    # --- Evaluator/critic loop ----------------------------------------------
    effective_rubric = rubric if rubric is not None else DEEP_SEARCH_RUBRIC
    agent_middleware: list[AgentMiddleware] = []
    if metrics is not None:
        # Observation-only; placed first so its before_agent stamps the run
        # start before any other middleware runs. It never returns a jump_to,
        # so it does not interfere with the rubric loop's cycle boundaries.
        agent_middleware.append(_OrchestratorMetricsMiddleware(metrics))
    if auto_rubric:
        # Must precede RubricMiddleware so the rubric is in state when the
        # grading loop initializes.
        agent_middleware.append(DefaultRubricMiddleware(effective_rubric))
    # DeepSearchRubricMiddleware (not the bare RubricMiddleware) so the grader
    # sees the orchestrator's final report untruncated — otherwise long cited
    # reports get cut at 4,000 chars and the grader flags a phantom
    # "incomplete/truncated" gap (issue #22).
    agent_middleware.append(
        DeepSearchRubricMiddleware(
            model=model,
            max_iterations=max_research_cycles,
            on_evaluation=on_evaluation,
        )
    )
    # Reset the per-cycle search budget at each research-cycle boundary. Placed
    # after RubricMiddleware so it participates in the same before/after_agent
    # phases that delimit a research cycle.
    if search_budget is not None:
        agent_middleware.append(SearchBudgetResetMiddleware(search_budget))
    agent_middleware.extend(middleware)

    # --- Orchestrator --------------------------------------------------------
    if system_prompt is None:
        system_prompt = ORCHESTRATOR_PROMPT_TEMPLATE.format(
            max_research_cycles=max_research_cycles,
            max_urls_to_scrape_per_cycle=max_urls_to_scrape_per_cycle,
            perspective_step=PERSPECTIVE_STEP_BLOCK if enable_perspectives else "",
            perspective_decompose_hint=(
                PERSPECTIVE_DECOMPOSE_HINT if enable_perspectives else ""
            ),
        )

    return create_deep_agent(
        model=model,
        system_prompt=system_prompt,
        middleware=agent_middleware,
        subagents=[*built_in_subagents, *extra_subagents],
        backend=effective_backend,
        **create_deep_agent_kwargs,
    )

Tools

deep_search_agent.create_searxng_search_tool

create_searxng_search_tool(*, base_url: str = DEFAULT_SEARXNG_BASE_URL, engines: Sequence[str] | None = None, timeout: float = 15.0, max_results: int = 5, min_request_interval: float | None = None, budget: SearchBudget | None = None) -> BaseTool

Build a web-search tool backed by a SearxNG instance.

Parameters:

Name Type Description Default
base_url str

Root URL of the SearxNG instance (e.g. "http://localhost:8888"). Trailing slashes are ignored.

DEFAULT_SEARXNG_BASE_URL
engines Sequence[str] | None

Optional list of SearxNG engine names to restrict the search to (e.g. ["duckduckgo", "wikipedia"]). None lets SearxNG use its default engine set.

None
timeout float

Per-request timeout in seconds.

15.0
max_results int

Maximum number of results returned per query.

5
min_request_interval float | None

Minimum number of seconds between two SearxNG requests, enforced by a thread-safe limiter shared across the (possibly concurrent) callers of the returned tool. None disables rate limiting. When a request would have to wait longer than timeout for a free slot, the tool returns an ERROR: ... string instead of performing it.

None
budget SearchBudget | None

Optional :class:SearchBudget capping the number of search operations; when exhausted the tool returns an ERROR: ... string telling the model no budget is left. The caller owns the budget and is responsible for resetting it (e.g. per research cycle). None means unlimited.

None

Returns:

Type Description
BaseTool

A LangChain tool named internet_search that takes a query

BaseTool

string and returns markdown-formatted results, or an ERROR: ...

BaseTool

string on failure.

Source code in deep_search_agent/tools/search.py
def create_searxng_search_tool(
    *,
    base_url: str = DEFAULT_SEARXNG_BASE_URL,
    engines: Sequence[str] | None = None,
    timeout: float = 15.0,
    max_results: int = 5,
    min_request_interval: float | None = None,
    budget: SearchBudget | None = None,
) -> BaseTool:
    """Build a web-search tool backed by a SearxNG instance.

    Args:
        base_url: Root URL of the SearxNG instance
            (e.g. ``"http://localhost:8888"``). Trailing slashes are ignored.
        engines: Optional list of SearxNG engine names to restrict the search
            to (e.g. ``["duckduckgo", "wikipedia"]``). ``None`` lets SearxNG
            use its default engine set.
        timeout: Per-request timeout in seconds.
        max_results: Maximum number of results returned per query.
        min_request_interval: Minimum number of seconds between two SearxNG
            requests, enforced by a thread-safe limiter shared across the
            (possibly concurrent) callers of the returned tool. ``None``
            disables rate limiting. When a request would have to wait longer
            than ``timeout`` for a free slot, the tool returns an
            ``ERROR: ...`` string instead of performing it.
        budget: Optional :class:`SearchBudget` capping the number of search
            operations; when exhausted the tool returns an ``ERROR: ...``
            string telling the model no budget is left. The caller owns the
            budget and is responsible for resetting it (e.g. per research
            cycle). ``None`` means unlimited.

    Returns:
        A LangChain tool named ``internet_search`` that takes a ``query``
        string and returns markdown-formatted results, or an ``ERROR: ...``
        string on failure.
    """
    search_url = f"{base_url.rstrip('/')}/search"
    engines_param = ",".join(engines) if engines else None
    rate_limiter = (
        _MinIntervalRateLimiter(min_request_interval) if min_request_interval else None
    )

    @tool
    def internet_search(
        query: str,
        category: str | None = None,
        time_range: str | None = None,
    ) -> str:
        """Search the web via SearxNG and return the top results.

        Args:
            query: The search query. Be specific; reformulate and call again
                if the results are not relevant.
            category: Optional SearxNG category to bias the search toward a kind
                of source (e.g. ``"news"``, ``"science"``, ``"it"``). Use
                ``"science"`` for academic/research questions. ``None`` uses the
                instance's default general web search.
            time_range: Optional recency filter; one of ``"day"``, ``"week"``,
                ``"month"``, ``"year"``. Set it for time-sensitive questions to
                prioritize recent sources. ``None`` applies no time filter.

        Returns:
            Markdown list of results (title, URL, snippet, date, engine),
            or an ``ERROR: ...`` message if the search failed.
        """
        if time_range is not None and time_range not in _VALID_TIME_RANGES:
            return (
                f"ERROR: invalid time_range {time_range!r}. Use one of "
                f"{', '.join(sorted(_VALID_TIME_RANGES))}, or omit it."
            )
        # Budget gate first: cheap, and lets the model know immediately when
        # it has run out of searches without waiting on the rate limiter.
        if budget is not None and not budget.try_consume():
            return (
                "ERROR: search budget exhausted for this research cycle. No "
                "further searches can be run now; rely on the findings already "
                "gathered or wait for the next cycle."
            )
        # Rate limit second: reserve a slot spaced from other concurrent
        # searches. If the slot is farther away than the request timeout,
        # abort and refund the budget so it counts only dispatched searches.
        if rate_limiter is not None:
            wait = rate_limiter.acquire(timeout)
            if wait is None:
                if budget is not None:
                    budget.refund()
                return (
                    "ERROR: SearxNG rate limit would require waiting longer "
                    f"than the {timeout}s timeout for query {query!r}. Try "
                    "again shortly or reduce the number of parallel searches."
                )
            if wait > 0:
                time.sleep(wait)

        params: dict[str, str] = {"q": query, "format": "json"}
        if engines_param:
            params["engines"] = engines_param
        if category:
            params["categories"] = category
        if time_range:
            params["time_range"] = time_range
        try:
            response = httpx.get(search_url, params=params, timeout=timeout)
            response.raise_for_status()
            payload = response.json()
        except httpx.HTTPStatusError as exc:
            return (
                f"ERROR: SearxNG returned HTTP {exc.response.status_code} "
                f"for query {query!r}. Try again later or reformulate."
            )
        except httpx.HTTPError as exc:
            return (
                f"ERROR: could not reach SearxNG at {search_url}: {exc}. "
                "Check connectivity or retry with a different query."
            )
        except ValueError:
            return (
                "ERROR: SearxNG response was not valid JSON. The instance may "
                "not have the JSON format enabled."
            )

        results = payload.get("results") or []
        if not results:
            return f"No results found for query {query!r}. Try a reformulation."
        formatted = [
            _format_result(i, item)
            for i, item in enumerate(results[:max_results], start=1)
        ]
        return "\n\n".join(formatted)

    return internet_search

deep_search_agent.create_fetch_url_tool

create_fetch_url_tool(*, timeout: float = 20.0, max_content_chars: int = 20000, enable_js_render_fallback: bool = False, js_render_timeout: float = 30.0) -> BaseTool

Build a tool that downloads a URL and extracts its main content.

Parameters:

Name Type Description Default
timeout float

Per-request timeout in seconds.

20.0
max_content_chars int

Maximum number of content characters returned; longer content keeps its head and its tail, joined by an explicit marker signalling the omitted middle section.

20000
enable_js_render_fallback bool

When True, an HTML page whose static content cannot be extracted is re-fetched through a headless Chromium (Playwright) and extracted again, recovering JavaScript-only pages and bot walls. Requires the js-render extra and installed Playwright browsers; when either is missing the tool returns an ERROR: ... string. Off by default, since it costs a browser launch per recovered page.

False
js_render_timeout float

Seconds to wait for a rendered page to settle. Kept separate from timeout because rendering is much slower than the plain HTTP request. Ignored unless the fallback is enabled.

30.0

Returns:

Type Description
BaseTool

A LangChain tool named fetch_url that takes a url string and

BaseTool

returns the extracted text (HTML cleaned via trafilatura, PDFs read

BaseTool

via pypdf), or an ERROR: ... string on failure.

Source code in deep_search_agent/tools/fetch.py
def create_fetch_url_tool(
    *,
    timeout: float = 20.0,
    max_content_chars: int = 20_000,
    enable_js_render_fallback: bool = False,
    js_render_timeout: float = 30.0,
) -> BaseTool:
    """Build a tool that downloads a URL and extracts its main content.

    Args:
        timeout: Per-request timeout in seconds.
        max_content_chars: Maximum number of content characters returned;
            longer content keeps its head and its tail, joined by an explicit
            marker signalling the omitted middle section.
        enable_js_render_fallback: When ``True``, an HTML page whose static
            content cannot be extracted is re-fetched through a headless
            Chromium (Playwright) and extracted again, recovering
            JavaScript-only pages and bot walls. Requires the ``js-render``
            extra and installed Playwright browsers; when either is missing the
            tool returns an ``ERROR: ...`` string. Off by default, since it
            costs a browser launch per recovered page.
        js_render_timeout: Seconds to wait for a rendered page to settle. Kept
            separate from ``timeout`` because rendering is much slower than the
            plain HTTP request. Ignored unless the fallback is enabled.

    Returns:
        A LangChain tool named ``fetch_url`` that takes a ``url`` string and
        returns the extracted text (HTML cleaned via trafilatura, PDFs read
        via pypdf), or an ``ERROR: ...`` string on failure.
    """

    @tool
    def fetch_url(url: str) -> str:
        """Download a web page or PDF and return its cleaned main content.

        Args:
            url: The absolute URL to fetch. Both HTML pages and PDF
                documents are supported.

        Returns:
            The extracted text content (for very long documents, the opening
            and closing sections with the middle omitted), or an
            ``ERROR: ...`` message if the download/extraction failed.
        """
        try:
            response = httpx.get(
                url,
                headers=_build_headers(),
                timeout=timeout,
                follow_redirects=True,
            )
            response.raise_for_status()
        except httpx.HTTPStatusError as exc:
            return (
                f"ERROR: HTTP {exc.response.status_code} while fetching {url}. "
                "The page may be protected or gone; try another source."
            )
        except httpx.HTTPError as exc:
            return f"ERROR: could not fetch {url}: {exc}. Try another source."

        content_type = response.headers.get("content-type", "")
        if _is_pdf(url, content_type):
            try:
                text = _extract_pdf_text(response.content)
            except Exception as exc:  # pypdf raises many exception types
                return f"ERROR: could not parse PDF at {url}: {exc}."
            if not text.strip():
                return (
                    f"ERROR: the PDF at {url} contains no extractable text "
                    "(it may be a scanned document)."
                )
        else:
            text = _extract_html_text(response.text, url)
            if not text.strip() and enable_js_render_fallback:
                try:
                    text = _render_and_extract(url, js_render_timeout)
                except ImportError:
                    return (
                        f"ERROR: cannot render {url}: the JavaScript rendering "
                        "fallback needs Playwright. Install it with "
                        "'pip install deep-search-agent[js-render]' followed by "
                        "'playwright install chromium'."
                    )
                except Exception as exc:  # Playwright raises many error types
                    return (
                        f"ERROR: headless rendering of {url} failed: {exc}. "
                        "Try another source."
                    )
            if not text.strip():
                return (
                    f"ERROR: no main content could be extracted from {url} "
                    "(possibly a JavaScript-only page or a bot wall)."
                )

        return _truncate_head_tail(text, max_content_chars)

    return fetch_url

Middleware

deep_search_agent.DefaultRubricMiddleware

DefaultRubricMiddleware(rubric: str)

Bases: AgentMiddleware

Inject a default rubric into the state when none was provided.

Must be placed before RubricMiddleware in the middleware list so the rubric is already in the state when the grading loop initializes.

Parameters:

Name Type Description Default
rubric str

The rubric text to inject when the invocation state has no rubric key (or an empty one).

required
Source code in deep_search_agent/middleware.py
def __init__(self, rubric: str) -> None:
    super().__init__()
    if not rubric or not rubric.strip():
        msg = "DefaultRubricMiddleware requires a non-empty rubric"
        raise ValueError(msg)
    self.rubric = rubric

abefore_agent async

abefore_agent(state: RubricState, runtime: Runtime[Any]) -> dict[str, Any] | None

Async variant of :meth:before_agent.

Source code in deep_search_agent/middleware.py
async def abefore_agent(
    self,
    state: RubricState,
    runtime: Runtime[Any],  # noqa: ARG002
) -> dict[str, Any] | None:
    """Async variant of :meth:`before_agent`."""
    return self._inject(state)

before_agent

before_agent(state: RubricState, runtime: Runtime[Any]) -> dict[str, Any] | None

Return a state update with the default rubric, or None.

Parameters:

Name Type Description Default
state RubricState

Current agent state.

required
runtime Runtime[Any]

Agent runtime (unused).

required

Returns:

Type Description
dict[str, Any] | None

{"rubric": <default>} when the state has no rubric,

dict[str, Any] | None

otherwise None (caller-supplied rubric is preserved).

Source code in deep_search_agent/middleware.py
def before_agent(
    self,
    state: RubricState,
    runtime: Runtime[Any],  # noqa: ARG002
) -> dict[str, Any] | None:
    """Return a state update with the default rubric, or None.

    Args:
        state: Current agent state.
        runtime: Agent runtime (unused).

    Returns:
        ``{"rubric": <default>}`` when the state has no rubric,
        otherwise ``None`` (caller-supplied rubric is preserved).
    """
    return self._inject(state)

Observability

deep_search_agent.SessionMetrics

SessionMetrics()

Thread-safe collector of per-cycle and global deep-search metrics.

Pass an instance to :func:~deep_search_agent.factory.create_deep_search_agent through its metrics parameter, run the agent, then read the results either through the typed properties (:attr:cycles, :attr:global_tool_calls, :attr:subagent_stats, ...) or as a plain JSON-serializable mapping via :meth:to_dict. Metrics accumulate for the lifetime of the object; call :meth:reset to start a fresh session.

All reads return copies, so the returned structures never mutate under the caller while the agent keeps running.

Source code in deep_search_agent/metrics.py
def __init__(self) -> None:
    self._lock = threading.RLock()
    self._global_tool_calls: Counter[str] = Counter()
    self._global_subagent_invocations: Counter[str] = Counter()
    self._subagent_count: Counter[str] = Counter()
    self._subagent_total_time: defaultdict[str, float] = defaultdict(float)
    self._subagent_min_time: dict[str, float] = {}
    self._subagent_max_time: dict[str, float] = {}
    self._cycles: list[_CycleData] = []
    self._current_cycle = 0
    self._total_duration = 0.0
    self._run_start: float | None = None
    self._run_baseline = 0.0

cycle_count property

cycle_count: int

Number of research cycles recorded so far in the session.

cycles property

cycles: tuple[CycleMetrics, ...]

Immutable per-cycle snapshots, oldest first.

global_subagent_invocations property

global_subagent_invocations: dict[str, int]

Total invocation count per sub-agent across the session.

global_tool_calls property

global_tool_calls: dict[str, int]

Total call count per tool across the orchestrator and sub-agents.

subagent_stats property

subagent_stats: dict[str, SubagentStats]

Per-sub-agent execution stats (count and avg/min/max duration).

total_duration property

total_duration: float

Overall orchestrator execution time (seconds), summed over runs.

reset

reset() -> None

Clear every collected metric and start a fresh session.

Source code in deep_search_agent/metrics.py
def reset(self) -> None:
    """Clear every collected metric and start a fresh session."""
    with self._lock:
        self._global_tool_calls.clear()
        self._global_subagent_invocations.clear()
        self._subagent_count.clear()
        self._subagent_total_time.clear()
        self._subagent_min_time.clear()
        self._subagent_max_time.clear()
        self._cycles.clear()
        self._current_cycle = 0
        self._total_duration = 0.0
        self._run_start = None
        self._run_baseline = 0.0

to_dict

to_dict() -> dict[str, Any]

Return a JSON-serializable snapshot of every collected metric.

Returns:

Type Description
dict[str, Any]

A nested mapping with total_duration, global tool_calls and

dict[str, Any]

subagent_invocations, per-sub-agent subagent_stats (each with

dict[str, Any]

count/total_time/avg_time/min_time/max_time), and

dict[str, Any]

a cycles list of per-cycle counters.

Source code in deep_search_agent/metrics.py
def to_dict(self) -> dict[str, Any]:
    """Return a JSON-serializable snapshot of every collected metric.

    Returns:
        A nested mapping with ``total_duration``, global ``tool_calls`` and
        ``subagent_invocations``, per-sub-agent ``subagent_stats`` (each with
        ``count``/``total_time``/``avg_time``/``min_time``/``max_time``), and
        a ``cycles`` list of per-cycle counters.
    """
    with self._lock:
        return {
            "total_duration": self._total_duration,
            "tool_calls": dict(self._global_tool_calls),
            "subagent_invocations": dict(self._global_subagent_invocations),
            "subagent_stats": {
                name: {
                    "count": count,
                    "total_time": self._subagent_total_time[name],
                    "avg_time": self._subagent_total_time[name] / count
                    if count
                    else None,
                    "min_time": self._subagent_min_time.get(name),
                    "max_time": self._subagent_max_time.get(name),
                }
                for name, count in self._subagent_count.items()
            },
            "cycles": [
                {
                    "orchestrator_tool_calls": dict(cycle.orchestrator_tool_calls),
                    "subagent_invocations": dict(cycle.subagent_invocations),
                    "subagent_tool_calls": {
                        name: dict(counts)
                        for name, counts in cycle.subagent_tool_calls.items()
                    },
                }
                for cycle in self._cycles
            ],
        }

deep_search_agent.SubagentStats dataclass

SubagentStats(count: int, total_time: float, min_time: float | None, max_time: float | None)

Aggregated execution stats for a single sub-agent over the session.

Attributes:

Name Type Description
count int

Number of completed invocations of the sub-agent.

total_time float

Sum of the wall-clock durations of those invocations, in seconds.

min_time float | None

Shortest invocation duration in seconds, or None if the sub-agent was never invoked.

max_time float | None

Longest invocation duration in seconds, or None if the sub-agent was never invoked.

avg_time property

avg_time: float | None

Mean invocation duration in seconds, or None if never invoked.

deep_search_agent.CycleMetrics dataclass

CycleMetrics(orchestrator_tool_calls: dict[str, int], subagent_invocations: dict[str, int], subagent_tool_calls: dict[str, dict[str, int]])

Per-cycle counters for one research (iteration) cycle.

Attributes:

Name Type Description
orchestrator_tool_calls dict[str, int]

How many times the orchestrator invoked each of its own tools during the cycle (the task delegation tool is excluded).

subagent_invocations dict[str, int]

How many times each sub-agent was invoked during the cycle.

subagent_tool_calls dict[str, dict[str, int]]

For each sub-agent, how many times it invoked each of its tools during the cycle.

Prompts & rubric

deep_search_agent.DEEP_SEARCH_RUBRIC module-attribute

DEEP_SEARCH_RUBRIC = '- The answer addresses every part of the user\'s question, or explicitly declares which parts could not be answered and why.\n- Every factual claim in the answer is attributed to a specific source (URL) collected during the research.\n- The answer does not contain claims that lack a corresponding source in the collected findings.\n- When sources disagree on a point, the answer reports both positions instead of silently picking one.\n- The sources used are relevant to the question and, when the question is time-sensitive, recent enough.\n- The answer is written in the same language as the user\'s question and is coherent and well organized.\n- The answer opens with a concise executive summary of the key conclusions.\n- The answer is organized into sections that cover every planned perspective/sub-question, with depth proportional to the question\'s complexity.\n- The answer includes an explicit "Gaps & limitations" section stating what could not be answered (or that there are none).\n- The answer ends with a numbered bibliography whose entries (URL and, when available, date) correspond one-to-one with the in-text `[n]` citations.\n'

Default grading rubric used by the evaluator/critic loop.

Generic on purpose: it constrains completeness, source traceability, contradiction handling, and report structure (executive summary, sectioned coverage, an explicit gaps section, and a numbered bibliography) without assuming a specific research domain. Pass a custom rubric to the factory (or in the invocation state) to override it.

deep_search_agent.ORCHESTRATOR_PROMPT_TEMPLATE module-attribute

ORCHESTRATOR_PROMPT_TEMPLATE = 'You are a deep-search orchestrator. Your job is NOT to look up information\nyourself, but to plan, delegate to sub-agents, and synthesize their results.\n\n## Workflow\n{perspective_step}\n1. DECOMPOSE the user\'s query into 2-5 independent sub-questions using the\n   planning tool (write_todos).{perspective_decompose_hint} Each sub-question\n   must be specific enough to become a search query.\n\n2. DELEGATE each sub-question to `search-agent`. Launch independent\n   sub-questions in parallel (multiple task calls in one turn) to reduce\n   latency. When `search-agent` surfaces URLs that require in-depth reading\n   (beyond the snippet), delegate to `fetch-agent` passing the specific URL.\n   Fetch at most {max_urls_to_scrape_per_cycle} URLs per research cycle,\n   prioritizing the most authoritative ones. Before delegating, consult the\n   shared source index (see below) so you do not re-search or re-fetch URLs\n   that were already handled; skip a fetch when its URL is already `saved`.\n\n3. Instruct every sub-agent to save its raw findings to files, one per\n   source: `/findings/<source-slug>.md`, using this format:\n   - Source (URL)\n   - Date/age of the information, when available\n   - Main claims (bullet list)\n\n4. EVALUATE before synthesizing, explicitly checking:\n   - Does every todo have at least one associated source in /findings/?\n   - Are there contradictions between sources on the same claim?\n   - Are the sources /recent/authoritative enough for this kind of question?\n   If something is missing, do NOT synthesize: re-run search-agent with\n   reformulated queries (more specific or with different terms), or delegate\n   to fact-check-agent when you hold conflicting claims.\n\n5. Repeat the plan -> delegate -> evaluate cycle at most\n   {max_research_cycles} times overall. If information is still missing\n   after {max_research_cycles} cycles, proceed to synthesis anyway and\n   explicitly declare the remaining gaps.\n\n6. OUTLINE the report before writing it. Write `/report/outline.md` with the\n   sections the answer will have, derived from the researched perspectives or\n   sub-questions (todos). Reserve an executive summary as the first section and\n   a "Gaps & limitations" section plus a "Sources" section at the end. Scale\n   depth to the question\'s complexity: a simple question gets a short outline\n   with few sections, not an inflated one; a broad or multi-faceted question\n   gets one section per perspective/sub-question.\n\n7. SYNTHESIZE SECTION BY SECTION. For each section in the outline (other than\n   the executive summary and the trailing Gaps/Sources sections), read the\n   relevant files in /findings/ and write that section. Every factual claim\n   must be traceable to a specific source: mark it with a numbered citation\n   `[n]` keyed to the bibliography you assemble in the next step. Never invent\n   claims that are not present in /findings/.\n\n8. ASSEMBLE the final answer by combining, in order: the executive summary (a\n   few sentences capturing the key conclusions), the synthesized sections, the\n   "Gaps & limitations" section (state explicitly what could not be answered\n   and why — this section is always present, even if empty it says "none"),\n   and a numbered "Sources" bibliography. Each bibliography entry is\n   `[n] <title or description> — <URL> (<date>, when available)`, and every\n   `[n]` citation used in the sections must resolve to exactly one entry.\n\n## Shared source index\n\n`/findings/_sources.md` is a shared ledger that sub-agents maintain: one line\nper URL, `- <url> | <status> | <findings-file-or-dash>`, where `<status>` is\n`saved`, `failed`, or `discarded`. Sub-agents append to it and consult it\nthemselves, but you also read it to steer delegation: avoid re-issuing queries\nor fetches for URLs already listed, and reuse the `/findings/<source-slug>.md`\nfile of a URL already `saved` instead of fetching it again.\n\n## Refinement cycles\n\nWhen you are re-invoked because the grading of your previous answer against\nthe rubric found it lacking, do NOT restart from step 1:\n\n1. Read the grading feedback and map every criterion it flags to a concrete,\n   specific gap: a missing sub-topic, a claim without a source, an unresolved\n   contradiction, sources too old for the question, and so on.\n2. Write the gaps to `/research/gaps.md`, one bullet per gap, each annotated\n   with the rubric criterion it comes from.\n3. Add new todos ONLY for those gaps, marked as refinement work; keep the\n   already-completed todos intact.\n4. Delegate targeted queries that address each gap directly. Reuse what is\n   already in /findings/ — never re-research what you already have. Explicitly\n   instruct sub-agents to diversify domains and sources relative to the URLs\n   already in `/findings/_sources.md`, so refinement adds new evidence instead\n   of repeating prior searches and fetches.\n5. Update `/report/outline.md` if the gaps require new or reworked sections,\n   then re-synthesize and re-assemble the full answer (steps 6-8): fix the\n   flagged points and preserve the sections that already satisfied the rubric.\n   Keep the numbered citations and the "Sources" bibliography consistent after\n   the edits.\n\n## Rules\n- Never fetch pages yourself: always delegate.\n- If two sources contradict each other, report both positions in the final\n  answer instead of arbitrarily picking one.\n- If a sub-agent fails (rate limit, unreachable page), reroute: try a\n  different query, a different source, or another sub-agent. Never let a\n  single failure block the whole research flow.\n- If a sub-question turns out to be unanswerable after your attempts, say so\n  explicitly to the user instead of filling the gap.\n- Answer in the same language as the user\'s query.\n'

Orchestrator system prompt template.

Placeholders: max_research_cycles, max_urls_to_scrape_per_cycle, perspective_step, perspective_decompose_hint. The last two are filled by the factory with :data:PERSPECTIVE_STEP_BLOCK and :data:PERSPECTIVE_DECOMPOSE_HINT when enable_perspectives=True, or with empty strings otherwise.

deep_search_agent.SEARCH_AGENT_PROMPT_TEMPLATE module-attribute

SEARCH_AGENT_PROMPT_TEMPLATE = 'You are a specialized web-search agent. You receive a specific sub-question\nand must find relevant, sourced results for it.\n\n## Shared source index\n`/findings/_sources.md` is a shared ledger of every URL already handled by any\nagent, one line per URL:\n`- <url> | <status> | <findings-file-or-dash>`, where `<status>` is `saved`,\n`failed`, or `discarded`. BEFORE searching, read it (it may not exist yet) and\ndo not re-surface URLs already listed there — prefer new domains and sources.\nAFTER handling a result, append one line per URL you kept or discarded; never\nrewrite or remove existing lines.\n\n## Instructions\n- Do NOT rely on a single phrasing. For the sub-question you were given,\n  generate {max_query_variants} distinct query variants that attack it from\n  different angles — synonyms, broader/narrower terms, an English-language\n  reformulation (for non-English sub-questions), or a different framing — and\n  issue them as parallel tool calls in the SAME turn (one `internet_search`\n  call per variant). Running them together, rather than one at a time, is the\n  fastest way to widen recall.\n- After the variants return, pool their results, deduplicate by URL, and keep\n  only the best across all of them; a URL already surfaced by one variant must\n  not be recorded twice. If the whole batch comes back weak, reformulate once\n  more with fresh terms.\n- Tune `internet_search` to the sub-question: set `time_range` (`day`/`week`/\n  `month`/`year`) for time-sensitive questions to prioritize recent sources,\n  and set `category="science"` for academic or research-heavy questions (other\n  categories such as `news` or `it` are also available). Leave them unset for\n  general questions.\n- Keep at most {max_search_results_per_query} results per query; discard\n  duplicates, low-quality sources, and URLs already in `/findings/_sources.md`.\n- For each relevant result, record: title, URL, snippet, and (when present)\n  publication date and source engine.\n- Save your findings to files, one per source, at\n  `/findings/<source-slug>.md` in this format:\n  - Source (URL)\n  - Date/age of the information, when available\n  - Main claims (bullet list)\n- Append every result you save to `/findings/_sources.md` as\n  `- <url> | saved | /findings/<source-slug>.md`, and every result you\n  deliberately discard as `- <url> | discarded | -`.\n- Return to the orchestrator ONLY a concise summary: which sub-question you\n  addressed, which findings files you wrote, and which URLs deserve a full\n  fetch by `fetch-agent` (with a one-line reason each).\n- If a variant fails (network error, rate limit), retry that one once with a\n  reformulated query, then report the failure instead of blocking; the other\n  variants\' results still stand.\n- Never fabricate results: report only what the search tools returned.\n- DO NOT perform other web searches if search budget is exhausted.\n'

Search sub-agent system prompt template.

Placeholders: max_query_variants, max_search_results_per_query.

deep_search_agent.FETCH_AGENT_PROMPT module-attribute

FETCH_AGENT_PROMPT = 'You are a specialized content-extraction agent. You receive one or more\nspecific URLs and must extract their relevant content.\n\n## Shared source index\n`/findings/_sources.md` is a shared ledger of every URL already handled by any\nagent, one line per URL:\n`- <url> | <status> | <findings-file-or-dash>`, where `<status>` is `saved`,\n`failed`, or `discarded`. BEFORE fetching a URL, read it (it may not exist\nyet): if the URL is already `saved`, reuse its findings file instead of\nre-fetching; if it is `failed`, do not retry unless explicitly asked. AFTER\nfetching, append one line per URL; never rewrite or remove existing lines.\n\n## Instructions\n- Use the fetch tool to download and clean each URL (HTML pages and PDF\n  documents are both supported).\n- From the extracted content, isolate the parts relevant to the research\n  question you were given; do not dump entire pages.\n- Save the findings to `/findings/<source-slug>.md` in this format:\n  - Source (URL)\n  - Date/age of the information, when available\n  - Main claims (bullet list), each grounded in the fetched text\n- Append every URL you fetch to `/findings/_sources.md`: a successful fetch as\n  `- <url> | saved | /findings/<source-slug>.md`, a failed one as\n  `- <url> | failed | -`.\n- Return to the orchestrator ONLY a concise summary: which URLs you\n  processed, which findings files you wrote, and any URL that failed\n  (with the error) so the orchestrator can reroute.\n- If a fetch fails, do not retry more than once; report the failure.\n- Never fabricate content: extract only what is actually on the page.\n'

Fetch/reader sub-agent system prompt.

deep_search_agent.FACT_CHECK_AGENT_PROMPT module-attribute

FACT_CHECK_AGENT_PROMPT = 'You are a specialized fact-checking agent. You receive one or more claims,\npossibly with the sources that produced them, and must verify their\nconsistency against multiple independent sources.\n\n## Instructions\n- Read the relevant files in `/findings/` to understand the claims and their\n  provenance.\n- Use the search and fetch tools to locate at least two additional\n  independent sources per claim.\n- For each claim, produce a verdict: `confirmed`, `contested`, or\n  `unverifiable`, with the list of supporting/contradicting sources (URLs).\n- Save your analysis to `/findings/fact-check-<claim-slug>.md` including the\n  verdict, the evidence, and the URLs consulted.\n- Return to the orchestrator ONLY the verdict per claim with a one-line\n  rationale and the findings files you wrote.\n- When sources genuinely disagree, report the disagreement; do not force a\n  resolution.\n'

Fact-checking sub-agent system prompt.