localailb/assistant
0
1"""
2deep_research_agent.py — Agentic "Deep Research" via a two-agent smolagents
3setup, modeled on HuggingFace's own open_deep_research example:
4https://github.com/huggingface/smolagents/tree/main/examples/open_deep_research
5
6That example uses a MANAGER agent that plans out a research task and
7delegates focused sub-questions to a dedicated browser/search agent
8(there: a `ToolCallingAgent` wrapping `GoogleSearchTool` + page-reading
9tools, requiring a paid search API key and typically a hosted model like
10`o1`). This app has neither a search API key nor a hosted-model
11requirement, so the same manager+search-agent shape is rebuilt here using
12what's already available:
13
14 - The manager is a `ToolCallingAgent` (or CodeAgent fallback on models
15 without structured tool calling) with NO tools of its own except the
16 search sub-agent (passed via `managed_agents=`, which smolagents
17 exposes to the manager as a callable — `web_search_agent(task="...")`
18 — same as the real example's manager calling its own sub-agent). Tool
19 calling avoids CodeAgent code-parsing failures on research text.
20 - `planning_interval` makes the manager periodically stop and
21 re-evaluate its plan against what it's learned so far — the
22 characteristic "deep" part of deep research, as opposed to
23 general_agent.py's single-pass agentic chat.
24 - The search sub-agent reuses general_agent.py's own
25 TrackedDuckDuckGoSearchTool / TrackedVisitWebpageTool (free,
26 API-key-free DuckDuckGo search + page reading — already proven out
27 by General Chat's agentic mode) instead of GoogleSearchTool, and its
28 citation-tracking/verification helpers (resolve_actually_used_sources
29 etc.) are reused as-is from general_agent.py rather than duplicated.
30
31Same reliability caveat as every other agentic tab in this app: this
32needs a genuinely capable model to work well — small/local models can
33struggle even more here than in General Chat's agentic mode, since a
34manager also has to correctly invoke a SUB-AGENT (not just a tool) and
35periodically re-plan. Expect this to work best on Gemma-4-E4B and above,
36same guidance as the other agentic tabs.
37"""
38
39import threading
40import types
41from typing import Optional
42
43from smolagents import Tool, ToolCallingAgent, WikipediaSearchTool, UserInputTool
44
45from agents import agent_streaming
46from agents import general_agent
47from agents.agent_factory import build_smol_agent, init_params
48from backend import model_registry as mr
49from backend import models
50from ui import i18n
51
52try:
53 from agents import playwright_search_tool
54 _playwright_available = True
55except ImportError:
56 _playwright_available = False
57
58# smolagents' WikipediaSearchTool lazily imports `wikipediaapi` in its
59# __init__ and raises ImportError when the package is missing. Whether it
60# is actually usable is probed once here (same pattern as
61# `_playwright_available` above) so the search agent can simply skip the
62# tool instead of crashing the whole Deep Research tab on machines that
63# don't have the optional package — see AGENTS.md's "the app must always
64# launch" rule.
65_wikipedia_tool_available = True
66try:
67 import wikipediaapi # noqa: F401
68except ImportError:
69 _wikipedia_tool_available = False
70
71if _playwright_available:
72 class TrackedPlaywrightDuckDuckGoSearchTool(playwright_search_tool.PlaywrightDuckDuckGoSearchTool):
73 input_aliases = {
74 "query": ("q", "question", "search", "search_query", "keywords", "task", "text", "prompt"),
75 }
76
77 def __init__(self, *args, **kwargs):
78 super().__init__(*args, **kwargs)
79 self.queries_run = []
80 self.result_links = []
81
82 def forward(self, query: str) -> str:
83 self.queries_run.append(query)
84 result = super().forward(query)
85 import re
86 _MARKDOWN_LINK_RE = re.compile(r'\[([^\]]+)\]\((https?://[^\s\)]+)\)')
87 for title, url in _MARKDOWN_LINK_RE.findall(result or ""):
88 pair = (title.strip(), url.strip())
89 if pair not in self.result_links:
90 self.result_links.append(pair)
91 return result
92
93 class TrackedPlaywrightGoogleSearchTool(playwright_search_tool.PlaywrightGoogleSearchTool):
94 input_aliases = {
95 "query": ("q", "question", "search", "search_query", "keywords", "task", "text", "prompt"),
96 }
97
98 def __init__(self, *args, **kwargs):
99 super().__init__(*args, **kwargs)
100 self.queries_run = []
101 self.result_links = []
102
103 def forward(self, query: str, filter_year: str | None = None) -> str:
104 self.queries_run.append(query)
105 result = super().forward(query, filter_year)
106 import re
107 _MARKDOWN_LINK_RE = re.compile(r'\[([^\]]+)\]\((https?://[^\s\)]+)\)')
108 for title, url in _MARKDOWN_LINK_RE.findall(result or ""):
109 pair = (title.strip(), url.strip())
110 if pair not in self.result_links:
111 self.result_links.append(pair)
112 return result
113
114 class TrackedPlaywrightVisitPageTool(playwright_search_tool.PlaywrightVisitPageTool):
115 input_aliases = {
116 "url": ("link", "webpage", "page", "address", "site", "target"),
117 }
118
119 def __init__(self, *args, **kwargs):
120 super().__init__(*args, **kwargs)
121 self.urls_visited = []
122
123 def forward(self, url: str) -> str:
124 self.urls_visited.append(url)
125 return super().forward(url)
126
127
128class PlaywrightTextInspectorTool(Tool):
129 name = "playwright_inspect_file"
130 description = (
131 "Read a file from a local path or URL and return its text content. "
132 "Handles .txt, .md, .html, .json, .csv, .py, and similar text formats. "
133 "For PDF files use playwright_read_embedded_pdf instead. "
134 "For images use playwright_visualizer instead."
135 )
136 inputs = {
137 "file_path": {
138 "type": "string",
139 "description": "The local path or URL to the file to read.",
140 },
141 }
142 input_aliases = {
143 "file_path": ("filepath", "file", "path", "filename", "doc", "document", "src", "source"),
144 }
145 output_type = "string"
146
147 def forward(self, file_path: str) -> str:
148 import mimetypes
149 import requests
150
151 file_path = file_path.strip()
152 if not file_path:
153 return "Error: empty file path."
154
155 try:
156 if file_path.startswith("http://") or file_path.startswith("https://"):
157 resp = requests.get(file_path, timeout=30)
158 resp.raise_for_status()
159 content = resp.text
160 else:
161 with open(file_path, "r", encoding="utf-8", errors="replace") as f:
162 content = f.read()
163 except Exception as e:
164 return f"Error reading file '{file_path}': {e}"
165
166 if len(content) > 50000:
167 content = content[:50000] + "\n...[truncated at 50000 chars]"
168
169 return f"## File: {file_path}\n\n```\n{content}\n```"
170
171
172class PlaywrightVisualizerTool(Tool):
173 name = "playwright_visualizer"
174 description = (
175 "Answer a question about an image file. Provide the local path to the image "
176 "and an optional question. If no question is given, a detailed caption is returned."
177 )
178 inputs = {
179 "image_path": {
180 "type": "string",
181 "description": "The local path to the image file to analyze.",
182 },
183 "question": {
184 "type": "string",
185 "description": "The question about the image. Optional — if omitted, a caption is generated.",
186 "nullable": True,
187 },
188 }
189 input_aliases = {
190 "image_path": ("image", "file_path", "filepath", "path", "img", "photo", "pic"),
191 "question": ("prompt", "query", "task", "text", "q"),
192 }
193 output_type = "string"
194
195 def __init__(self, model_id: str = "", provider=None, n_ctx=None, **kwargs):
196 super().__init__(**kwargs)
197 # Same-model rule (see model_registry.is_multimodal_model): the
198 # image analysis runs on the ACTIVE Deep Research model — the one
199 # already loaded for this run — so no SECOND VLM is pulled into
200 # VRAM (a GGUF VLM rides the same llama-server, a transformers VLM
201 # reuses the loaded LLM via vlm_answer's reuse branch). When that
202 # model is TEXT-ONLY, a clean note is returned instead of loading
203 # the saved Vision-tab VLM. Empty model_id (defensive fallback)
204 # keeps the historical default-VLM behavior.
205 self._model_id = str(model_id or "")
206 self._provider = provider
207 self._n_ctx = n_ctx
208
209 def forward(self, image_path: str, question: str | None = None) -> str:
210 from PIL import Image
211
212 image_path = image_path.strip()
213 if not image_path:
214 return "Error: empty image path."
215
216 try:
217 pil_image = Image.open(image_path).convert("RGB")
218 except Exception as e:
219 return f"Error opening image '{image_path}': {e}"
220
221 q = question or "Please describe this image in detail."
222 if self._model_id and not mr.is_multimodal_model(self._model_id, self._provider):
223 # Text-only active model — do NOT load a second model just to
224 # look at the image; tell the agent to rely on page text.
225 return ("The active model is text-only and cannot analyze "
226 "images. Rely on the page's text content instead.")
227 try:
228 mmproj = mr.GGUF_VLM_MMPROJ_MAP.get(self._model_id) if self._model_id else None
229 answer = models.vlm_answer(
230 q, [pil_image],
231 model_id=self._model_id or None,
232 mmproj_path=mmproj,
233 n_ctx=self._n_ctx,
234 provider=self._provider,
235 )
236 except Exception as e:
237 answer = f"Could not process image through VLM: {e}"
238
239 if not question:
240 answer = f"Caption for '{image_path}':\n{answer}"
241
242 return answer
243
244
245_manager_agent = None
246_manager_agent_model_id = None
247_manager_agent_config = {}
248_manager_agent_n_ctx = None
249_manager_agent_lock = threading.Lock()
250
251# Module-level refs to the CURRENT run's tracked-tool instances (the
252# search sub-agent's own tools), so chat.py can read/reset them the same
253# way it does for general_agent.py's tools — see
254# general_agent.TrackedDuckDuckGoSearchTool / TrackedVisitWebpageTool.
255_search_tool = None
256_webpage_tool = None
257
258# The search sub-agent answers ONE focused sub-question per call and has
259# no memory of its own across calls, so it needs far fewer steps than the
260# manager, which is juggling the whole research task. The manager's
261# budget is larger than general_agent.py's single-agent default (12 vs
262# 8) since planning + delegation calls both cost steps on top of the
263# actual research.
264SEARCH_AGENT_DEFAULT_MAX_STEPS = 4
265MANAGER_DEFAULT_MAX_STEPS = 12
266
267# How often (in manager steps) the manager stops to re-plan — smolagents'
268# own `planning_interval` mechanism. This is the main thing that makes
269# this agent "deep" rather than a single-pass agentic chat: every few
270# steps it re-reads what it has found so far and can revise its approach
271# instead of blindly executing an initial plan to the end.
272DEEP_RESEARCH_PLANNING_INTERVAL = 3
273
274SEARCH_AGENT_DESCRIPTION = (
275 "Give this agent ONE focused sub-question (a plain-text string) and "
276 "it will search the web and read pages to answer just that "
277 "sub-question, returning what it found as plain text. It has NO "
278 "memory of any other call you make to it, so every call must be "
279 "fully self-contained — include whatever context it needs in the "
280 "sub-question itself. Call it once per sub-question; don't ask it "
281 "multiple unrelated things in one call.\n\n"
282 "Call it by passing the sub-question as the `task` argument — "
283 "web_search_agent(task=\"your question here\") — and it will run the "
284 "search itself."
285)
286
287DEEP_RESEARCH_INSTRUCTIONS = (
288 "You are a deep-research assistant. You have NO web-search tool of "
289 "your own — the ONLY way to get outside information is to delegate a "
290 "focused sub-question to the `web_search_agent` tool, e.g.\n"
291 "web_search_agent(task=\"<one focused sub-question>\")\n\n"
292 "THE ONLY AGENT THAT EXISTS is `web_search_agent`. There is no other "
293 "tool or function available — in particular there is no "
294 "`web_search`, `visit_webpage`, `conversation_history`, or `memory` "
295 "function. Never call, import, or reference anything else; it will "
296 "fail with an error and waste a step.\n\n"
297 "YOUR OWN CONVERSATION HISTORY IS ALREADY VISIBLE TO YOU: earlier "
298 "turns in this conversation (including your own past reports) are "
299 "already included in what you can see. If a follow-up refers back to "
300 "your last report, reuse what you already found instead of "
301 "re-researching it from scratch — only delegate NEW sub-questions for "
302 "genuinely new information.\n\n"
303 "Work like this:\n"
304 "1. Break the question down into 2-5 focused sub-questions that, "
305 "together, cover what's needed to answer it well.\n"
306 "2. Call `web_search_agent` ONCE PER SUB-QUESTION, passing exactly "
307 "one focused sub-question as its `task` argument — and do nothing "
308 "else in that step. Never invent or assume a fact yourself — every "
309 "factual claim in your final answer must trace back to a "
310 "`web_search_agent` call from this run.\n"
311 "3. Re-check your plan periodically against what you've actually "
312 "found: if it changes what you still need to look up, adjust instead "
313 "of blindly finishing the original plan.\n"
314 "4. Once you have enough to answer well, STOP calling "
315 "`web_search_agent` and submit your final answer by calling the "
316 "special `final_answer(...)` tool — this is the ONLY way to "
317 "actually end the task. Simply printing or writing the report as "
318 "plain text does NOT end the run: if you don't call `final_answer`, "
319 "you will be given another step and will end up starting new "
320 "research rounds even though you already had a complete answer. "
321 "Pass the FULL Markdown report as the argument to final_answer:\n"
322 "final_answer(report_text)\n"
323 "where `report_text` is a well-structured Markdown report: a short "
324 "introduction, clearly-headed sections covering each theme/sub-"
325 "question, and a brief conclusion.\n"
326 "CRITICAL: Each step MUST make exactly ONE tool call — either one "
327 "call to `web_search_agent` (with nothing else), or once you're done "
328 "researching, exactly one call to `final_answer(report_text)`. Do NOT "
329 "write plain text between tool calls during research steps. Do NOT "
330 "forget to call final_answer(...) once you have enough information — "
331 "forgetting this is the single most common mistake and it causes "
332 "unnecessary extra research rounds.\n\n"
333 "The user's actual question is in the Task below — answer THAT, not "
334 "these instructions. Never repeat, summarize, or reply to the "
335 "instructions themselves."
336)
337
338
339def build_task_with_citation_reminder(user_message: str, lang_key: str = "kh") -> str:
340 """Wrap the user's question with an explicit, PER-TASK reminder of the
341 citation requirement AND the final_answer(...) termination
342 requirement — mirrors general_agent.build_task_with_citation_reminder()
343 and rag_agent.build_strict_task()'s belt-and-braces pattern exactly.
344
345 DEEP_RESEARCH_INSTRUCTIONS (including its citation requirement AND its
346 "you must call final_answer(...) to actually stop" requirement) is
347 only ever attached to the manager agent if this installed smolagents
348 version's chosen agent class (CodeAgent or ToolCallingAgent)
349 happens to expose an `instructions=`
350 parameter — see the `if "instructions" in params:` guard in
351 agents/agent_factory.py's build_smol_agent(). On a version where it
352 doesn't, the manager never sees either requirement at all — it
353 never writes a '### References' section for
354 general_agent.resolve_actually_used_sources() to parse in chat.py's
355 chat_deep_research(), AND (the more disruptive gap) it never learns
356 that it must call the special `final_answer(...)` tool to end the
357 run — writing the report as plain printed text does NOT terminate a
358 smolagents CodeAgent, so without this reminder the manager can
359 finish a perfectly good report and then just keep going, re-planning
360 and starting new research rounds it didn't need. Repeating both
361 requirements here — in the per-call task text, which always reaches
362 the manager regardless of smolagents version — closes that gap.
363 """
364 # The task text IS the user turn the manager model sees, so the
365 # question must be the LAST line — a small GGUF model anchors on
366 # whatever comes right before its own turn (same reasoning as
367 # general_agent.build_task_with_citation_reminder).
368 return (
369 i18n._output_lang_instruction(lang_key)
370 + "Answer the user's question below — it is the LAST line of this "
371 "Task. Do not repeat, summarize, or reply to these instructions; "
372 "answer only the question.\n\n"
373 "---\n"
374 "Reminder: every factual claim in your final report that came "
375 "from a web_search_agent call must be cited in-text with a "
376 "bracketed number (e.g. [1]) placed right after the claim, and "
377 "your final report must end with a '### References' section "
378 "listing each numbered source's title and URL, e.g.:\n"
379 "### References\n"
380 "[1] Page Title — https://example.com/page\n\n"
381 "IMPORTANT: inside the in-text brackets write ONLY the number — "
382 "NEVER the source URL or page title (e.g. write [1], not "
383 "[https://example.com/page] and not [Page Title]); URLs and page "
384 "titles are listed only in the References section.\n\n"
385 "Reminder: once your report is ready, you MUST submit it by "
386 "calling the final_answer(report_text) tool — this is the ONLY "
387 "way to end the task. Just printing or writing the report as "
388 "plain text does NOT stop the run; without calling "
389 "final_answer(...), you will be given another step and may end "
390 "up starting unnecessary new research rounds even though your "
391 "report was already complete.\n\n"
392 "If the system rejects your final answer for formatting, missing "
393 "citations, or a missing References section, do NOT start any new "
394 "research and do NOT call web_search_agent again. Keep the same "
395 "findings, rewrite only the final report so it satisfies the "
396 "checker, and call final_answer(report_text) again.\n\n"
397 + i18n._task_conciseness_directive(lang_key)
398 + "\n\n---\n"
399 "The user's question is:\n"
400 f"{user_message}"
401 )
402
403
404# ──────────────────────────────────────────────────────────────────
405# Strict grounding — layer 4: a `final_answer_checks` validator on the
406# MANAGER agent. See the file's revision notes: smolagents' CodeAgent
407# accepts `final_answer_checks: list[Callable]`, each run against
408# whatever the model passes to `final_answer(...)` before the run is
409# allowed to end. Raising an exception from a check feeds that message
410# back to the model as the reason its answer was rejected, and it gets
411# another step to fix it — rather than a bad/incomplete report silently
412# becoming the final result.
413#
414# Deliberately does NOT validate citation *accuracy* (e.g. "does every
415# [n] have a matching reference line") — that's handled, more leniently
416# and more reliably, by general_agent.resolve_actually_used_sources()
417# AFTER the run completes. It also does NOT require the report to end
418# with a '### References' section of its own: chat.py's
419# chat_deep_research() strips the model's section and appends a VERIFIED
420# references block built from the URLs the search sub-agent really
421# visited/returned this run, so demanding one from the model only burns
422# steps on small models that write a complete report but forget the
423# trailing section — the same reasoning that dropped the requirement
424# from rag_agent._validate_final_report (see its comment there). One
425# cheap, high-value check remains:
426# 1. The report isn't empty/near-empty.
427# ──────────────────────────────────────────────────────────────────
428MIN_FINAL_REPORT_CHARS = 200
429
430
431def _validate_final_report(final_answer, *_, **__) -> bool:
432 """`final_answer_checks` validator for the Deep Research manager.
433
434 Raises a plain Exception with an actionable message on rejection —
435 smolagents surfaces that message back to the model as feedback for
436 its next step. Only rejects a report too short to be a real grounded
437 report; the model-written '### References' section is NOT required
438 because chat.py appends a verified references block from the run's
439 actually-used sources after the run completes (see the layer-4
440 comment above).
441 """
442 text = str(final_answer or "").strip()
443
444 if len(text) < MIN_FINAL_REPORT_CHARS:
445 raise ValueError(
446 f"Your final answer is only {len(text)} character(s) — too "
447 f"short to be a real research report (need at least "
448 f"{MIN_FINAL_REPORT_CHARS}). Write a complete Markdown report "
449 "(a short introduction, a clearly-headed section per "
450 "sub-question you researched, and a brief conclusion), then "
451 "call final_answer(report_text) again with the FULL report "
452 "text as the argument."
453 )
454
455 return True
456
457
458# The max-steps hand-off transcript cap for the search sub-agent — its
459# findings are verbatim search results / page contents (wordier than the
460# Data worker's compact tables), so it gets a larger cap than the
461# worker's 8000.
462_SEARCH_OBS_TRANSCRIPT_CAP = 12000
463
464
465def _search_agent_max_steps_handoff(self, task):
466 """The `web_search_agent`'s max-steps fallback (instance-patched onto
467 the agent in `_build_search_agent`): prefer the run's REAL gathered
468 sources — the verbatim tool observations (search results, page
469 contents) — over smolagents' stock last-ditch raw-text answer, so the
470 manager receives what the agent actually found instead of a leaked
471 channel marker / envelope. Thin wrapper over the shared
472 agent_streaming.max_steps_findings_handoff (same helper the Data
473 `data_worker` uses). NEVER raises."""
474 return agent_streaming.max_steps_findings_handoff(
475 self, task, cap=_SEARCH_OBS_TRANSCRIPT_CAP)
476
477
478def _build_search_agent(llm, model_id: str = "", use_playwright: bool = False, headless: bool = True, max_steps: Optional[int] = None, timeout: Optional[int] = None, provider=None, n_ctx=None):
479 global _search_tool, _webpage_tool
480
481 tools = []
482 if use_playwright and _playwright_available:
483 _search_tool = TrackedPlaywrightDuckDuckGoSearchTool(headless=headless)
484 _webpage_tool = TrackedPlaywrightVisitPageTool(headless=headless)
485 tools = [
486 _search_tool,
487 TrackedPlaywrightGoogleSearchTool(headless=headless),
488 _webpage_tool,
489 playwright_search_tool.PlaywrightExtractLegalDocumentLinksTool(headless=headless),
490 playwright_search_tool.PlaywrightReadEmbeddedPdfTool(),
491 playwright_search_tool.PlaywrightPageDownTool(),
492 playwright_search_tool.PlaywrightPageUpTool(),
493 playwright_search_tool.PlaywrightFindOnPageTool(),
494 playwright_search_tool.PlaywrightFindNextTool(),
495 playwright_search_tool.PlaywrightArchiveSearchTool(),
496 # App-local transcriber (NOT smolagents' stock SpeechToTextTool —
497 # that one eagerly imports transformers/torch in its constructor
498 # and crashes agent builds on machines without torch, e.g. the
499 # Store/MSIX build). Same name/schema, torch-free, routes through
500 # the app's own configured STT pipeline.
501 general_agent.SpeechToTextTool(),
502 UserInputTool(),
503 PlaywrightTextInspectorTool(),
504 PlaywrightVisualizerTool(model_id=model_id, provider=provider, n_ctx=n_ctx),
505 ]
506 if _wikipedia_tool_available:
507 tools.append(WikipediaSearchTool())
508 desc = (
509 "Give this agent ONE focused sub-question (a plain-text string) and "
510 "it will search the web using Playwright headless browser tools and read pages to answer just that "
511 "sub-question, returning what it found as plain text. It has NO "
512 "memory of any other call you make to it, so every call must be "
513 "fully self-contained — include whatever context it needs in the "
514 "sub-question itself. Call it once per sub-question; don't ask it "
515 "multiple unrelated things in one call."
516 )
517 else:
518 _search_tool = general_agent.TrackedDuckDuckGoSearchTool()
519 _webpage_tool = general_agent.TrackedVisitWebpageTool()
520 tools = [_search_tool, _webpage_tool]
521 desc = SEARCH_AGENT_DESCRIPTION
522
523 final_max_steps = max_steps if max_steps is not None else mr.get_max_steps_for_model(model_id, SEARCH_AGENT_DEFAULT_MAX_STEPS)
524 kwargs = dict(
525 model=llm,
526 tools=tools,
527 max_steps=final_max_steps,
528 verbosity_level=1,
529 name="web_search_agent",
530 description=desc,
531 planning_interval=4,
532 provide_run_summary=True,
533 )
534 params = init_params(ToolCallingAgent)
535 if "instructions" in params:
536 if use_playwright and _playwright_available:
537 kwargs["instructions"] = (
538 "You are a focused web search agent using real browser "
539 "(Playwright) tools — none of these tools use a paid API or "
540 "require an API key. Your tools are:\n\n"
541 "=== Search & Browse ===\n"
542 " - `playwright_duckduckgo_search(query=\"...\")` — your PRIMARY "
543 "search tool. Use this first for almost every search.\n"
544 " - `playwright_google_search(query=\"...\", filter_year=\"...\")` "
545 "— the FALLBACK search tool (queries Google via a real browser). "
546 "filter_year is optional. Use this only if "
547 "`playwright_duckduckgo_search` comes back empty, or the topic "
548 "needs Google's broader index.\n"
549 " - `playwright_visit_page(url=\"...\")` — opens a URL and returns "
550 "its visible text plus every link found on it (note: it is named "
551 "`playwright_visit_page`, NOT `visit_webpage` — that name does not "
552 "exist in this mode).\n"
553 " - `playwright_extract_legal_document_links(url=\"...\", "
554 "topic_keywords=\"...\")` — opens a listing/index page and returns "
555 "its links ranked by relevance to topic_keywords. Use this BEFORE "
556 "opening individual candidate pages one by one.\n"
557 " - `playwright_read_embedded_pdf(url=\"...\")` — opens a page, "
558 "finds any embedded/linked PDF, and extracts its text.\n"
559 " - `playwright_find_archived_url(url=\"...\", date=\"...\")` — "
560 "searches the Wayback Machine for an archived snapshot of a URL "
561 "near a given date. Use when a page is dead or has changed.\n\n"
562 "=== Page Navigation ===\n"
563 " - `playwright_page_down()` — scroll the viewport DOWN one page.\n"
564 " - `playwright_page_up()` — scroll the viewport UP one page.\n"
565 " - `playwright_find_on_page(search_string=\"...\")` — Ctrl+F "
566 "search on the currently visited page.\n"
567 " - `playwright_find_next()` — jump to the next match of the "
568 "last find_on_page search.\n\n"
569 "=== Utilities ===\n"
570 " - `transcriber(audio_url=\"...\")` — transcribes an audio file/URL "
571 "to text.\n"
572 + (
573 " - `wikipedia_search(query=\"...\")` — search Wikipedia for a "
574 "given query and return a summary.\n"
575 if _wikipedia_tool_available
576 else ""
577 )
578 + " - `ask_user(question=\"...\")` — ask the user for clarification "
579 "or additional input.\n"
580 " - `playwright_inspect_file(file_path=\"...\")` — read a local "
581 "or remote text file and return its contents.\n"
582 " - `playwright_visualizer(image_path=\"...\", question=\"...\")` "
583 "— answer a question about an image file.\n\n"
584 "There is no `web_search`, `visit_webpage`, or `web_search_agent` "
585 "function available to you (that last name is only how something "
586 "ELSE calls you from outside; it does not exist inside your own "
587 "code).\n\n"
588 "You do NOT need to write code or use fenced code blocks. Simply "
589 "call the tools by their name — this system understands structured "
590 "tool calls natively, without any Python scaffolding.\n\n"
591 "Work like this:\n"
592 "1. Search with `playwright_duckduckgo_search` first.\n"
593 "2. Open promising results with `playwright_visit_page` to read "
594 "their content.\n"
595 "3. If you find a PDF you need text from, use "
596 "`playwright_read_embedded_pdf` to extract it.\n"
597 "4. Once you have enough information, just respond with your "
598 "answer as plain text — that naturally ends the task. You can "
599 "also use the `final_answer` tool to explicitly finish with your "
600 "findings.\n\n"
601 "If after searching you find that you need more information to "
602 "answer the question, you can use `final_answer` with your "
603 "request for clarification as argument to request for more "
604 "information from the user."
605 )
606 else:
607 kwargs["instructions"] = (
608 "You are a focused web search agent. Your ONLY job is to answer "
609 "the sub-question you are given by searching the web and reading "
610 "pages. You have NO other tools besides `web_search` and "
611 "`visit_webpage` — there is no `web_search_agent` function "
612 "available to you (that name is only how something ELSE calls "
613 "you from outside; it does not exist inside your own code).\n\n"
614 "You do NOT need to write code or use fenced code blocks. Simply "
615 "call the tools by their name — this system understands structured "
616 "tool calls natively, without any Python scaffolding.\n\n"
617 "Work like this:\n"
618 "1. Search with `web_search` to find relevant pages.\n"
619 "2. Open promising results with `visit_webpage` to read their "
620 "content.\n"
621 "3. Once you have enough information, just respond with your "
622 "answer as plain text — that naturally ends the task. You can "
623 "also use the `final_answer` tool to explicitly finish with "
624 "your findings.\n\n"
625 "If after searching you find that you need more information to "
626 "answer the question, you can use `final_answer` with your "
627 "request for clarification as argument to request for more "
628 "information from the user."
629 )
630 if "executor_kwargs" in params and timeout is not None:
631 kwargs["executor_kwargs"] = {"timeout_seconds": timeout}
632
633 agent = ToolCallingAgent(**kwargs)
634 # Max-steps hand-off (see agent_streaming.max_steps_findings_handoff):
635 # when the search agent burns its whole step budget without emitting a
636 # real final answer (the Muse Glimmer DR runs shipped a bare
637 # `to=user<|eot|>` channel marker as its "final answer"), the manager
638 # would receive that garbage — losing the gathered sources. Deliver
639 # the run's verbatim tool observations (search results / page
640 # contents) instead. Same instance-level patch as the Data
641 # `data_worker`; the Deep Research MANAGER keeps the stock behavior.
642 try:
643 agent._orig_handle_max_steps_reached = agent._handle_max_steps_reached
644 agent._handle_max_steps_reached = types.MethodType(
645 _search_agent_max_steps_handoff, agent
646 )
647 except Exception:
648 pass
649 # Append managed-agent task prompt so the search agent knows how to
650 # handle .txt, .pdf, YouTube, and how to request clarification —
651 # mirrors the canonical open_deep_research pattern exactly:
652 # https://github.com/huggingface/smolagents/blob/main/examples/open_deep_research/run.py
653 agent.prompt_templates["managed_agent"]["task"] += (
654 "\nYou can navigate to .txt online files. "
655 "If a non-html page is in another format, especially .pdf or a Youtube "
656 "video, use a tool like 'inspect_file_as_text' or "
657 "'playwright_read_embedded_pdf' to inspect it. "
658 "Additionally, if after some searching you find out that you need more "
659 "information to answer the question, you can use `final_answer` with "
660 "your request for clarification as argument to request for more information."
661 )
662 return agent
663
664
665def _supports_native_tool_calls(llm) -> bool:
666 """Tool-calling policy for the Deep Research MANAGER.
667
668 Returns True for EVERY model — native tool calling is the app-wide
669 default (see agent_factory.build_smol_agent's default). Earlier
670 versions restricted ToolCallingAgent to LiteLLM / HF-Inference API
671 models that advertise function-calling; but llama-cpp-python's
672 ToolCallingAgent path handles small local GGUF models well (the
673 structured tool calls route through the app's smart
674 parse_json_blob/ReAct-envelope recovery in agent_streaming, and the
675 live Gemma-4-E2B verification ran web_search + final_answer natively
676 with zero code-parsing errors), so the restriction was lifted — the
677 checkbox on the Deep Research tab now genuinely controls the class,
678 and CodeAgent is only used when the user unchecks it.
679 """
680 return True
681
682
683def _build_manager_agent(llm, search_agent, model_id: str = "", max_steps: Optional[int] = None, timeout: Optional[int] = None, use_playwright: bool = False, headless: bool = True, use_tool_calling: bool = True, provider=None, n_ctx=None):
684 final_max_steps = max_steps if max_steps is not None else mr.get_max_steps_for_model(model_id, MANAGER_DEFAULT_MAX_STEPS)
685 tools = []
686 if use_playwright and _playwright_available:
687 tools = [
688 PlaywrightTextInspectorTool(),
689 PlaywrightVisualizerTool(model_id=model_id, provider=provider, n_ctx=n_ctx),
690 ]
691
692 instructions = DEEP_RESEARCH_INSTRUCTIONS
693 if use_playwright and _playwright_available:
694 instructions = DEEP_RESEARCH_INSTRUCTIONS + (
695 "You also have direct access to these utility tools:\n"
696 " - `playwright_inspect_file(file_path=\"...\")` — read a local "
697 "or remote text file and return its contents. Use this to inspect "
698 "downloaded or referenced files without needing to delegate to the "
699 "web_search_agent.\n"
700 " - `playwright_visualizer(image_path=\"...\", question=\"...\")` — "
701 "answer a question about an image file. Use this when you need to "
702 "analyze an image the user provided or that was found during research.\n\n"
703 )
704
705 agent = build_smol_agent(
706 llm,
707 tools=tools,
708 max_steps=final_max_steps,
709 use_tool_calling=use_tool_calling,
710 tool_calling_predicate=_supports_native_tool_calls,
711 instructions=instructions,
712 execution_timeout=timeout,
713 extra_kwargs={
714 "managed_agents": [search_agent],
715 # Per-model override: models flagged planning-incompatible
716 # (Muse Glimmer — it echo-loops the re-plan prompt) get None
717 # (planning off), everyone else keeps the default interval.
718 "planning_interval": mr.planning_interval_for_model(model_id, DEEP_RESEARCH_PLANNING_INTERVAL),
719 },
720 optional_kwargs={"final_answer_checks": [_validate_final_report]},
721 )
722 print(f"[DeepResearch] Building manager {type(agent).__name__} on '{model_id or '(shared)'}' …")
723 return agent
724
725
726def reset_tool_usage() -> None:
727 """Call right before agent.run() so this run's tracked queries/URLs
728 start from zero — mirrors general_agent.reset_tool_usage()."""
729 if _search_tool is not None:
730 _search_tool.queries_run = []
731 _search_tool.result_links = []
732 if _webpage_tool is not None:
733 _webpage_tool.urls_visited = []
734
735
736def get_tool_usage() -> tuple:
737 """Call right after agent.run() returns. Returns (queries_run,
738 urls_visited, result_links) from the search sub-agent's own tools —
739 see general_agent.get_tool_usage() for the equivalent on that tab.
740 Passed to general_agent.resolve_actually_used_sources() so chat.py
741 can print a guaranteed-accurate References list for this report."""
742 queries = list(_search_tool.queries_run) if _search_tool is not None else []
743 urls = list(_webpage_tool.urls_visited) if _webpage_tool is not None else []
744 links = list(_search_tool.result_links) if _search_tool is not None else []
745 return queries, urls, links
746
747
748def get_deep_research_agent(model_id: Optional[str] = None, use_playwright: bool = False, headless: bool = True, manager_max_steps: Optional[int] = None, search_max_steps: Optional[int] = None, execution_timeout: Optional[int] = None, use_tool_calling: bool = True, provider: Optional[str] = None, n_ctx: Optional[int] = None):
749 """Lazily build (or rebuild, if the model changed) the manager agent
750 and its search sub-agent.
751
752 `execution_timeout` is the per-step Python code-executor timeout in
753 seconds (the DrControls "Exec timeout" field in agentic mode) — threaded
754 to both the manager and the web_search_agent sub-agent builders' own
755 `timeout` (their per-step executor cap). None = AUTO (the factory
756 default). This is DISTINCT from the tab's whole-run generation timeout,
757 which is enforced at the WS stream layer (web_api._run_stream) and
758 never reaches this builder — a wall-clock run budget must not double as
759 a per-step executor cap."""
760 global _manager_agent, _manager_agent_model_id, _manager_agent_config, _manager_agent_n_ctx
761 target = model_id or models._llm_model_id
762
763 current_config = {
764 "use_playwright": use_playwright,
765 "headless": headless,
766 "manager_max_steps": manager_max_steps,
767 "search_max_steps": search_max_steps,
768 "execution_timeout": execution_timeout,
769 "n_ctx": n_ctx,
770 }
771
772 if _manager_agent is not None and target == _manager_agent_model_id and current_config == _manager_agent_config:
773 return _manager_agent
774
775 with _manager_agent_lock:
776 if _manager_agent is not None and target == _manager_agent_model_id and current_config == _manager_agent_config:
777 return _manager_agent
778
779 print(f"[DeepResearch] Building manager + web_search_agent on '{target}' (Playwright: {use_playwright}, Headless: {headless}) …")
780 llm = models.get_llm(target, provider=provider, n_ctx=n_ctx)
781 search_agent = _build_search_agent(llm, target, use_playwright, headless, search_max_steps, execution_timeout,
782 provider=provider, n_ctx=n_ctx)
783 _manager_agent = _build_manager_agent(llm, search_agent, target, manager_max_steps, execution_timeout, use_playwright, headless, use_tool_calling=use_tool_calling,
784 provider=provider, n_ctx=n_ctx)
785 _manager_agent_model_id = target
786 _manager_agent_config = current_config
787 _manager_agent_n_ctx = n_ctx
788 # Standard smolagents behaviour: a freshly-built agent starts with
789 # empty memory (see agent_memory.py's module docstring for why
790 # this app doesn't try to restore memory from a previous
791 # model/session here either).
792 return _manager_agent
793
794
795def reset_agent():
796 """Drop the cached manager (and its search sub-agent). Does NOT unload
797 the underlying LLM itself — that's shared/managed by models.get_llm().
798 Call this whenever the Deep Research tab's model changes or the LLM
799 is force-reloaded/unloaded elsewhere — mirrors general_agent.reset_agent().
800 """
801 global _manager_agent, _manager_agent_model_id, _manager_agent_config, _manager_agent_n_ctx, _search_tool, _webpage_tool
802 _manager_agent = None
803 _manager_agent_model_id = None
804 _manager_agent_config = {}
805 _manager_agent_n_ctx = None
806 _search_tool = None
807 _webpage_tool = None
808 