deep-search-agent 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ """Deep search agent library built on LangChain deepagents.
2
+
3
+ Public API:
4
+
5
+ - :func:`create_deep_search_agent` — factory returning a configured deep
6
+ agent implementing the orchestrator + specialized sub-agents + rubric-graded
7
+ refinement architecture.
8
+ - :data:`DEEP_SEARCH_RUBRIC` — the default grading rubric.
9
+ - :class:`DefaultRubricMiddleware` — auto-injects a rubric into the state.
10
+ - :class:`DeepSearchRubricMiddleware` — the evaluator/critic loop; grades the
11
+ orchestrator's final answer against the rubric in full (untruncated).
12
+ - :class:`SearchBudgetResetMiddleware` — resets the per-cycle search budget at
13
+ each research-cycle boundary.
14
+ - :class:`SearchBudget` — thread-safe per-cycle search budget shared with the
15
+ search tool.
16
+ - :class:`SessionMetrics` — thread-safe collector of per-cycle and global
17
+ observability metrics (tool calls, sub-agent invocations and timings, overall
18
+ execution time), wired in via ``create_deep_search_agent(metrics=...)``.
19
+ :class:`SubagentStats` and :class:`CycleMetrics` are its typed read models.
20
+ - :func:`create_searxng_search_tool` / :func:`create_fetch_url_tool` — the
21
+ tool factories used by the built-in sub-agents, reusable standalone.
22
+ """
23
+
24
+ from deep_search_agent.factory import create_deep_search_agent
25
+ from deep_search_agent.metrics import CycleMetrics, SessionMetrics, SubagentStats
26
+ from deep_search_agent.middleware import (
27
+ DeepSearchRubricMiddleware,
28
+ DefaultRubricMiddleware,
29
+ SearchBudgetResetMiddleware,
30
+ )
31
+ from deep_search_agent.prompts import (
32
+ DEEP_SEARCH_RUBRIC,
33
+ FACT_CHECK_AGENT_PROMPT,
34
+ FETCH_AGENT_PROMPT,
35
+ ORCHESTRATOR_PROMPT_TEMPLATE,
36
+ PERSPECTIVE_AGENT_PROMPT,
37
+ SEARCH_AGENT_PROMPT_TEMPLATE,
38
+ )
39
+ from deep_search_agent.tools import (
40
+ SearchBudget,
41
+ create_fetch_url_tool,
42
+ create_searxng_search_tool,
43
+ )
44
+
45
+ __all__ = [
46
+ "DEEP_SEARCH_RUBRIC",
47
+ "FACT_CHECK_AGENT_PROMPT",
48
+ "FETCH_AGENT_PROMPT",
49
+ "ORCHESTRATOR_PROMPT_TEMPLATE",
50
+ "PERSPECTIVE_AGENT_PROMPT",
51
+ "SEARCH_AGENT_PROMPT_TEMPLATE",
52
+ "CycleMetrics",
53
+ "DeepSearchRubricMiddleware",
54
+ "DefaultRubricMiddleware",
55
+ "SearchBudget",
56
+ "SearchBudgetResetMiddleware",
57
+ "SessionMetrics",
58
+ "SubagentStats",
59
+ "create_deep_search_agent",
60
+ "create_fetch_url_tool",
61
+ "create_searxng_search_tool",
62
+ ]
63
+
64
+ __version__ = "0.4.1"
@@ -0,0 +1,385 @@
1
+ """Factory for the deep search agent.
2
+
3
+ :func:`create_deep_search_agent` wires together the multi-agent deep-search
4
+ architecture on top of ``deepagents``:
5
+
6
+ - **Orchestrator** = the main deep agent, instructed to decompose the query,
7
+ delegate to sub-agents, and synthesize a cited answer.
8
+ - **Specialized sub-agents** = ``perspective-agent`` (multi-angle topic
9
+ exploration before decomposition, enabled by default), ``search-agent``
10
+ (SearxNG + optional extra search tools), ``fetch-agent`` (trafilatura +
11
+ pypdf), and ``fact-check-agent``; callers can add more via ``subagents``.
12
+ - **Shared scratchpad** = deepagents' filesystem (pluggable ``backend``),
13
+ where every sub-agent writes ``/findings/<source-slug>.md`` files with
14
+ provenance.
15
+ - **Evaluator/critic loop** = ``DeepSearchRubricMiddleware`` (a thin subclass
16
+ of deepagents' beta ``RubricMiddleware`` that grades the final answer in
17
+ full), which grades the final answer against a rubric and re-runs the
18
+ orchestrator up to ``max_research_cycles`` times; the default deep-search
19
+ rubric is auto-injected unless the caller provides one.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from deepagents import create_deep_agent
27
+ from deepagents.backends import StateBackend
28
+
29
+ from deep_search_agent.metrics import (
30
+ _OrchestratorMetricsMiddleware,
31
+ _SubagentMetricsMiddleware,
32
+ )
33
+ from deep_search_agent.middleware import (
34
+ DeepSearchRubricMiddleware,
35
+ DefaultRubricMiddleware,
36
+ SearchBudgetResetMiddleware,
37
+ )
38
+ from deep_search_agent.prompts import (
39
+ DEEP_SEARCH_RUBRIC,
40
+ ORCHESTRATOR_PROMPT_TEMPLATE,
41
+ PERSPECTIVE_DECOMPOSE_HINT,
42
+ PERSPECTIVE_STEP_BLOCK,
43
+ )
44
+ from deep_search_agent.subagents import (
45
+ FACT_CHECK_AGENT_NAME,
46
+ FETCH_AGENT_NAME,
47
+ PERSPECTIVE_AGENT_NAME,
48
+ SEARCH_AGENT_NAME,
49
+ build_fact_check_subagent,
50
+ build_fetch_subagent,
51
+ build_perspective_subagent,
52
+ build_search_subagent,
53
+ )
54
+ from deep_search_agent.tools import create_fetch_url_tool, create_searxng_search_tool
55
+ from deep_search_agent.tools.search import DEFAULT_SEARXNG_BASE_URL, SearchBudget
56
+
57
+ if TYPE_CHECKING:
58
+ from collections.abc import Callable, Sequence
59
+
60
+ from deepagents.backends.protocol import BackendFactory, BackendProtocol
61
+ from deepagents.middleware.rubric import RubricEvaluation
62
+ from langchain.agents.middleware.types import AgentMiddleware
63
+ from langchain_core.language_models import BaseChatModel
64
+ from langchain_core.messages import SystemMessage
65
+ from langchain_core.tools import BaseTool
66
+ from langgraph.graph.state import CompiledStateGraph
67
+
68
+ from deep_search_agent.metrics import SessionMetrics
69
+
70
+
71
+ def _validate_positive(name: str, value: int) -> None:
72
+ """Raise ``ValueError`` unless ``value`` is a positive integer."""
73
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
74
+ msg = f"{name} must be a positive integer, got {value!r}"
75
+ raise ValueError(msg)
76
+
77
+
78
+ def _validate_positive_number(name: str, value: float) -> None:
79
+ """Raise ``ValueError`` unless ``value`` is a positive real number."""
80
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
81
+ msg = f"{name} must be a positive number, got {value!r}"
82
+ raise ValueError(msg)
83
+
84
+
85
+ def create_deep_search_agent(
86
+ *,
87
+ model: str | BaseChatModel,
88
+ max_research_cycles: int = 3,
89
+ max_query_variants: int = 3,
90
+ max_search_results_per_query: int = 5,
91
+ max_urls_to_scrape_per_cycle: int = 3,
92
+ searxng_base_url: str = DEFAULT_SEARXNG_BASE_URL,
93
+ searxng_engines: Sequence[str] | None = None,
94
+ searxng_rate_limit: float | None = None,
95
+ searxng_budget: int | None = None,
96
+ request_timeout: float = 15.0,
97
+ max_content_chars_per_page: int = 20_000,
98
+ enable_js_render_fallback: bool = False,
99
+ js_render_timeout: float = 30.0,
100
+ search_tools: Sequence[BaseTool] | None = None,
101
+ enable_perspectives: bool = True,
102
+ rubric: str | None = None,
103
+ auto_rubric: bool = True,
104
+ on_evaluation: Callable[[RubricEvaluation], None] | None = None,
105
+ system_prompt: str | SystemMessage | None = None,
106
+ middleware: Sequence[AgentMiddleware] = (),
107
+ subagents_middleware: Sequence[AgentMiddleware] = (),
108
+ subagents: Sequence[Any] | None = None,
109
+ backend: BackendProtocol | BackendFactory | None = None,
110
+ metrics: SessionMetrics | None = None,
111
+ **create_deep_agent_kwargs: Any,
112
+ ) -> CompiledStateGraph:
113
+ """Create a deep-search agent (orchestrator + specialized sub-agents).
114
+
115
+ The returned agent decomposes the user's query into sub-questions,
116
+ delegates them to isolated sub-agents (web search, content fetching,
117
+ fact checking), accumulates sourced findings on the shared filesystem
118
+ backend, and synthesizes a cited answer. An LLM-as-a-judge loop
119
+ (deepagents' beta ``RubricMiddleware``) grades the answer against a
120
+ rubric and triggers additional research cycles until the rubric is
121
+ satisfied or ``max_research_cycles`` is reached.
122
+
123
+ Args:
124
+ model: Model for the orchestrator and, unless overridden per
125
+ sub-agent, for the sub-agents too. The rubric grader inherits
126
+ this same model. Required — either a provider string (e.g.
127
+ ``"anthropic:claude-sonnet-4-6"``) or a ``BaseChatModel``.
128
+ max_research_cycles: Maximum refinement cycles of the evaluator
129
+ loop (``RubricMiddleware.max_iterations``). Also quoted in the
130
+ orchestrator's instructions as its iteration budget.
131
+ max_query_variants: Number of distinct query reformulations the
132
+ search agent issues in parallel per sub-question (synonyms,
133
+ broader/narrower terms, English variants, different angle) to
134
+ widen recall before deduplicating results. Quoted in the search
135
+ agent's instructions.
136
+ max_search_results_per_query: Result budget per search query,
137
+ enforced by the SearxNG tool and quoted in the search agent's
138
+ instructions.
139
+ max_urls_to_scrape_per_cycle: URL-fetch budget per research cycle,
140
+ quoted in the orchestrator's instructions.
141
+ searxng_base_url: Root URL of the SearxNG instance used by the
142
+ built-in search tool.
143
+ searxng_engines: Optional SearxNG engine allowlist
144
+ (e.g. ``["duckduckgo", "wikipedia"]``).
145
+ searxng_rate_limit: Minimum number of seconds between two SearxNG
146
+ requests. Sub-agents may run searches concurrently through
147
+ deepagents' thread pool, so this is enforced by a thread-safe
148
+ min-interval limiter shared by the built-in search tool. When a
149
+ request would have to wait longer than ``request_timeout`` for a
150
+ free slot, the tool returns an ``ERROR: ...`` string instead of
151
+ performing it. ``None`` (default) disables rate limiting.
152
+ searxng_budget: Maximum number of SearxNG search operations allowed in
153
+ a single research cycle. Once exhausted, the search tool returns an
154
+ ``ERROR: ...`` string telling the model no budget is left; the
155
+ counter is reset at each research-cycle boundary of the evaluator
156
+ loop. ``None`` (default) means unlimited.
157
+ request_timeout: HTTP timeout (seconds) for both the search and the
158
+ fetch tools.
159
+ max_content_chars_per_page: Truncation limit for content extracted
160
+ by the fetch tool.
161
+ enable_js_render_fallback: When ``True``, a page whose static HTML
162
+ yields no extractable content is re-fetched by the fetch tool
163
+ through a headless Chromium (Playwright) and extracted again,
164
+ recovering JavaScript-only pages and bot walls that would otherwise
165
+ be lost as sources. Requires the ``js-render`` extra plus installed
166
+ Playwright browsers (``pip install deep-search-agent[js-render]``
167
+ and ``playwright install chromium``); when either is missing the
168
+ fetch tool returns an ``ERROR: ...`` string rather than raising.
169
+ ``False`` (default) keeps the library browser-free.
170
+ js_render_timeout: Seconds the headless renderer waits for a page to
171
+ settle. Distinct from ``request_timeout`` because rendering is much
172
+ slower than a plain HTTP request. Ignored unless
173
+ ``enable_js_render_fallback`` is ``True``.
174
+ search_tools: Extra search tools (e.g. a Tavily tool or an internal
175
+ RAG retrieval tool) made available to ``search-agent`` and
176
+ ``fact-check-agent`` alongside the built-in SearxNG tool.
177
+ enable_perspectives: When ``True`` (default), adds ``perspective-agent``
178
+ to the built-in sub-agents and instructs the orchestrator to
179
+ delegate to it before decomposing the query, so research explores
180
+ the topic from 3-6 distinct angles (analysis axes, stakeholder
181
+ viewpoints, dimensions of the problem) instead of a single flat
182
+ list of sub-questions. Set to ``False`` for simple queries where a
183
+ single-axis decomposition is sufficient, to skip the extra
184
+ delegation cycle and its token cost.
185
+ rubric: Custom grading rubric (newline-delimited checklist).
186
+ Defaults to :data:`~deep_search_agent.prompts.DEEP_SEARCH_RUBRIC`.
187
+ auto_rubric: When ``True`` (default), the rubric is auto-injected
188
+ into the invocation state so the evaluation loop works out of
189
+ the box. When ``False``, the loop only activates if the caller
190
+ passes a ``rubric`` key in the invocation state.
191
+ on_evaluation: Optional callback invoked with each
192
+ :class:`~deepagents.middleware.rubric.RubricEvaluation` after the
193
+ grader scores a research cycle, e.g. to log the per-criterion
194
+ verdicts or stream progress to a UI. Exceptions it raises are
195
+ logged and suppressed by the underlying ``RubricMiddleware``, so it
196
+ must not be used to enforce control flow. ``None`` (default)
197
+ registers no callback.
198
+ system_prompt: Override for the orchestrator system prompt. Defaults
199
+ to the built-in deep-search orchestrator prompt parametrized
200
+ with the cycle/URL budgets.
201
+ middleware: Extra middleware appended after the rubric middleware.
202
+ subagents_middleware: Extra middleware injected into each built-in
203
+ sub-agent (``perspective-agent`` when enabled, ``search-agent``,
204
+ ``fetch-agent``, ``fact-check-agent``), e.g. for logging or rate
205
+ limiting. Sub-agents passed via ``subagents`` are caller-owned and
206
+ left untouched.
207
+ subagents: Extra sub-agents (e.g. a RAG retrieval agent over an
208
+ internal knowledge base) added alongside the built-in
209
+ ``search-agent``, ``fetch-agent``, ``fact-check-agent``, and
210
+ (when enabled) ``perspective-agent``.
211
+ backend: Filesystem backend shared by the orchestrator and every
212
+ sub-agent, so that ``/findings/<source-slug>.md`` files written by
213
+ a sub-agent flow back to the orchestrator on the same virtual
214
+ filesystem. When omitted, a single :class:`~deepagents.backends.StateBackend`
215
+ instance is created and shared; when provided, that exact instance
216
+ is propagated to ``create_deep_agent``.
217
+ metrics: Optional :class:`~deep_search_agent.metrics.SessionMetrics`
218
+ collector. When provided, observation-only middleware are injected
219
+ into the orchestrator and each built-in sub-agent to record, over the
220
+ whole session, per-cycle and global tool-call counts, sub-agent
221
+ invocation counts and execution times (avg/min/max), and the overall
222
+ execution time. The caller owns the instance and reads the results
223
+ from it after the run (metrics accumulate until
224
+ :meth:`SessionMetrics.reset`). ``None`` (default) disables metrics.
225
+ **create_deep_agent_kwargs: Any remaining ``create_deep_agent``
226
+ parameter (``tools``, ``checkpointer``, ``store``, ``skills``,
227
+ ``interrupt_on``, ...), passed through unchanged.
228
+
229
+ Returns:
230
+ The compiled deep agent graph, ready for ``invoke`` / ``astream``.
231
+
232
+ Raises:
233
+ ValueError: If ``model`` is missing, an integer budget parameter is
234
+ not a positive integer, ``searxng_rate_limit`` or (when the
235
+ fallback is enabled) ``js_render_timeout`` is not a positive
236
+ number, or a reserved sub-agent name is reused.
237
+ """
238
+ if model is None:
239
+ msg = (
240
+ "create_deep_search_agent requires an explicit `model`: the "
241
+ "rubric grader inherits it and deepagents' implicit default "
242
+ "model is deprecated."
243
+ )
244
+ raise ValueError(msg)
245
+ _validate_positive("max_research_cycles", max_research_cycles)
246
+ _validate_positive("max_query_variants", max_query_variants)
247
+ _validate_positive("max_search_results_per_query", max_search_results_per_query)
248
+ _validate_positive("max_urls_to_scrape_per_cycle", max_urls_to_scrape_per_cycle)
249
+ if searxng_rate_limit is not None:
250
+ _validate_positive_number("searxng_rate_limit", searxng_rate_limit)
251
+ if enable_js_render_fallback:
252
+ _validate_positive_number("js_render_timeout", js_render_timeout)
253
+ if searxng_budget is not None:
254
+ _validate_positive("searxng_budget", searxng_budget)
255
+
256
+ # Resolve the shared backend up front so the orchestrator and the
257
+ # sub-agents provably operate on the same virtual filesystem: default to a
258
+ # single StateBackend when the caller does not supply one, otherwise
259
+ # propagate the caller's instance unchanged.
260
+ effective_backend = backend if backend is not None else StateBackend()
261
+
262
+ # --- Tools for the sub-agents -----------------------------------------
263
+ # The budget lives in the tool closure so it is shared across the
264
+ # (possibly concurrent) sub-agents that call the search tool; a middleware
265
+ # resets it at each research-cycle boundary (see below).
266
+ search_budget = SearchBudget(searxng_budget) if searxng_budget is not None else None
267
+ searxng_tool = create_searxng_search_tool(
268
+ base_url=searxng_base_url,
269
+ engines=searxng_engines,
270
+ timeout=request_timeout,
271
+ max_results=max_search_results_per_query,
272
+ min_request_interval=searxng_rate_limit,
273
+ budget=search_budget,
274
+ )
275
+ all_search_tools: list[BaseTool] = [searxng_tool, *(search_tools or [])]
276
+ fetch_tool = create_fetch_url_tool(
277
+ timeout=request_timeout,
278
+ max_content_chars=max_content_chars_per_page,
279
+ enable_js_render_fallback=enable_js_render_fallback,
280
+ js_render_timeout=js_render_timeout,
281
+ )
282
+
283
+ # --- Sub-agents --------------------------------------------------------
284
+ # When metrics are collected, each built-in sub-agent also carries its own
285
+ # metrics middleware (added after the caller's ``subagents_middleware``) so
286
+ # invocations, timings, and tool calls are attributed to the right agent.
287
+ def _subagent_middleware(name: str) -> Sequence[AgentMiddleware]:
288
+ base = list(subagents_middleware)
289
+ if metrics is not None:
290
+ base.append(_SubagentMetricsMiddleware(metrics, name))
291
+ return base
292
+
293
+ built_in_subagents = []
294
+ if enable_perspectives:
295
+ built_in_subagents.append(
296
+ build_perspective_subagent(
297
+ all_search_tools,
298
+ middleware=_subagent_middleware(PERSPECTIVE_AGENT_NAME),
299
+ )
300
+ )
301
+ built_in_subagents.extend(
302
+ [
303
+ build_search_subagent(
304
+ all_search_tools,
305
+ max_query_variants=max_query_variants,
306
+ max_search_results_per_query=max_search_results_per_query,
307
+ middleware=_subagent_middleware(SEARCH_AGENT_NAME),
308
+ ),
309
+ build_fetch_subagent(
310
+ fetch_tool, middleware=_subagent_middleware(FETCH_AGENT_NAME)
311
+ ),
312
+ build_fact_check_subagent(
313
+ all_search_tools,
314
+ fetch_tool,
315
+ middleware=_subagent_middleware(FACT_CHECK_AGENT_NAME),
316
+ ),
317
+ ]
318
+ )
319
+ # perspective-agent is always reserved, even when enable_perspectives is
320
+ # False, so a caller-supplied sub-agent can never collide with it and
321
+ # toggling the flag never changes name-collision behavior.
322
+ reserved_names = {
323
+ SEARCH_AGENT_NAME,
324
+ FETCH_AGENT_NAME,
325
+ FACT_CHECK_AGENT_NAME,
326
+ PERSPECTIVE_AGENT_NAME,
327
+ }
328
+ extra_subagents = list(subagents or [])
329
+ for agent in extra_subagents:
330
+ name = (
331
+ agent["name"] if isinstance(agent, dict) else getattr(agent, "name", None)
332
+ )
333
+ if name in reserved_names:
334
+ msg = f"subagent name {name!r} is reserved by deep_search_agent"
335
+ raise ValueError(msg)
336
+
337
+ # --- Evaluator/critic loop ----------------------------------------------
338
+ effective_rubric = rubric if rubric is not None else DEEP_SEARCH_RUBRIC
339
+ agent_middleware: list[AgentMiddleware] = []
340
+ if metrics is not None:
341
+ # Observation-only; placed first so its before_agent stamps the run
342
+ # start before any other middleware runs. It never returns a jump_to,
343
+ # so it does not interfere with the rubric loop's cycle boundaries.
344
+ agent_middleware.append(_OrchestratorMetricsMiddleware(metrics))
345
+ if auto_rubric:
346
+ # Must precede RubricMiddleware so the rubric is in state when the
347
+ # grading loop initializes.
348
+ agent_middleware.append(DefaultRubricMiddleware(effective_rubric))
349
+ # DeepSearchRubricMiddleware (not the bare RubricMiddleware) so the grader
350
+ # sees the orchestrator's final report untruncated — otherwise long cited
351
+ # reports get cut at 4,000 chars and the grader flags a phantom
352
+ # "incomplete/truncated" gap (issue #22).
353
+ agent_middleware.append(
354
+ DeepSearchRubricMiddleware(
355
+ model=model,
356
+ max_iterations=max_research_cycles,
357
+ on_evaluation=on_evaluation,
358
+ )
359
+ )
360
+ # Reset the per-cycle search budget at each research-cycle boundary. Placed
361
+ # after RubricMiddleware so it participates in the same before/after_agent
362
+ # phases that delimit a research cycle.
363
+ if search_budget is not None:
364
+ agent_middleware.append(SearchBudgetResetMiddleware(search_budget))
365
+ agent_middleware.extend(middleware)
366
+
367
+ # --- Orchestrator --------------------------------------------------------
368
+ if system_prompt is None:
369
+ system_prompt = ORCHESTRATOR_PROMPT_TEMPLATE.format(
370
+ max_research_cycles=max_research_cycles,
371
+ max_urls_to_scrape_per_cycle=max_urls_to_scrape_per_cycle,
372
+ perspective_step=PERSPECTIVE_STEP_BLOCK if enable_perspectives else "",
373
+ perspective_decompose_hint=(
374
+ PERSPECTIVE_DECOMPOSE_HINT if enable_perspectives else ""
375
+ ),
376
+ )
377
+
378
+ return create_deep_agent(
379
+ model=model,
380
+ system_prompt=system_prompt,
381
+ middleware=agent_middleware,
382
+ subagents=[*built_in_subagents, *extra_subagents],
383
+ backend=effective_backend,
384
+ **create_deep_agent_kwargs,
385
+ )