CoolFace
Apppublic

localailb/assistant

sourceHugging Facemitupdated 1mo agoView on Hugging Face
0likes
deep_research_agent.py808 linesDownload Raw Back to agents
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