Per-domain action rules
};
}
+ function deepResearchTool(enabled, provider, model, synthProvider, synthModel, depth, maxSources, tokenBudget) {
+ return {
+ enabled: enabled,
+ provider: provider || 'deepseek',
+ model: model || 'deepseek-v4-flash',
+ synthProvider: synthProvider || '',
+ synthModel: synthModel || '',
+ depth: depth || '2',
+ maxSources: maxSources || '10',
+ tokenBudget: tokenBudget || '300000',
+ result: '', resultOk: false,
+ save() {
+ const vals = {
+ 'tools.deep_research.enabled': String(this.enabled === true || this.enabled === 'true'),
+ 'tools.deep_research.provider': String(this.provider || 'deepseek').trim(),
+ 'tools.deep_research.model': String(this.model || '').trim(),
+ 'tools.deep_research.synthesis_provider': String(this.synthProvider || '').trim(),
+ 'tools.deep_research.synthesis_model': String(this.synthModel || '').trim(),
+ 'tools.deep_research.depth': String(parseInt(this.depth) || 2),
+ 'tools.deep_research.max_sources': String(parseInt(this.maxSources) || 10),
+ 'tools.deep_research.token_budget': String(parseInt(this.tokenBudget) || 300000)
+ };
+ return fetch('/config', {
+ method: 'PATCH',
+ headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('admin_api_key') || '')},
+ body: JSON.stringify({values: vals})
+ })
+ .then(r => { this.resultOk = r.ok; return r.json(); })
+ .then(d => {
+ this.result = this.resultOk ? 'Deep research settings saved' : (d.detail || 'Error');
+ if (this.resultOk && window.showToast) { window.showToast('Deep research settings saved'); }
+ })
+ .catch(e => { this.resultOk = false; this.result = e.message; });
+ }
+ };
+ }
+
function imagegenTab(enabled, provider, model, apiKey, keyVaulted, daily, monthly) {
const truthy = (v) => v === true || v === 'true';
const DEFAULTS = { openrouter: 'google/gemini-2.5-flash-image', fal: 'fal-ai/flux/schnell', openai: 'gpt-image-1-mini' };
diff --git a/humux/core/agent.py b/humux/core/agent.py
index ed4c9ee..b2037f7 100644
--- a/humux/core/agent.py
+++ b/humux/core/agent.py
@@ -19,8 +19,9 @@
from tavily import TavilyClient
-from core import coding, imagegen
+from core import coding, deep_research, imagegen
from core.agents import Agent, AgentStore, default_agent_from_values, topic_base_id
+from core.artifacts import ARTIFACTS_SUBDIR
from core.compaction import compact_messages, should_compact
from core.config import Config, search_ready
from core.embeddings import LOCAL_PROVIDERS, EmbeddingClient, LocalEmbeddingClient
@@ -534,6 +535,41 @@ def _control_command(message: str) -> str:
"required": ["query"],
},
},
+ {
+ "name": "deep_research",
+ "description": (
+ "Run an autonomous multi-step web research job: the question is "
+ "decomposed into sub-questions, each is searched, the top sources are "
+ "read and distilled, gaps trigger follow-up searches, and everything "
+ "is synthesized into a structured, cited report. Takes minutes and "
+ "posts its own progress updates to the chat — use it for genuine "
+ "research requests ('research X', 'write a report on Y', 'compare A "
+ "vs B in depth'), NOT for quick lookups (use web_search for those). "
+ "When the result contains an artifact_url, reply briefly with the key "
+ "takeaways and that link — do not paste the whole report."
+ ),
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "The research question"},
+ "depth": {
+ "type": "integer",
+ "description": (
+ "Search-read-iterate cycles (1 = quick, 3 = exhaustive). "
+ "Defaults to the configured value, which is also the cap."
+ ),
+ },
+ "max_sources": {
+ "type": "integer",
+ "description": (
+ "Max pages to read. Defaults to the configured value, "
+ "which is also the cap."
+ ),
+ },
+ },
+ "required": ["query"],
+ },
+ },
{
"name": "generate_image",
"description": (
@@ -957,6 +993,7 @@ def apply_feature_gates(
imagegen_enabled: bool = False,
workspace_enabled: bool = False,
search_enabled: bool = False,
+ deep_research_enabled: bool = False,
) -> list[dict]:
"""Drop tools whose backing feature is unavailable/disabled, so the model is
never offered a capability it can't use (defence in depth — the tool handlers
@@ -970,6 +1007,9 @@ def apply_feature_gates(
out = [t for t in out if t["name"] != "generate_image"]
if not search_enabled:
out = [t for t in out if t["name"] != "web_search"]
+ # Deep research needs a working search backend too (#293).
+ if not (deep_research_enabled and search_enabled):
+ out = [t for t in out if t["name"] != "deep_research"]
if not workspace_enabled:
out = [t for t in out if t["name"] not in ("read", "write", "edit")]
return out
@@ -1628,7 +1668,7 @@ async def bind_chat_agent(
def _tools_for_turn(self, agent: Agent | None) -> list[dict]:
"""The function-tool schemas offered to the model this turn: the agent's
tool scope, with feature-gated tools dropped."""
- return apply_feature_gates(
+ tools = apply_feature_gates(
scoped_tools(agent),
secrets_available=self.secret_store is not None,
subagents_enabled=self.config.subagents.enabled,
@@ -1636,7 +1676,13 @@ def _tools_for_turn(self, agent: Agent | None) -> list[dict]:
workspace_enabled=self.config.workspace.enabled
and bool(self.config.workspace.directory.strip()),
search_enabled=self.search_enabled,
+ deep_research_enabled=self.config.tools.deep_research.enabled,
)
+ # deep_research fans out web searches — hide it from an agent whose
+ # scope withholds web_search (#293); the handler refuses too.
+ if agent and agent.tools and not agent.allows_tool("web_search"):
+ tools = [t for t in tools if t["name"] != "deep_research"]
+ return tools
async def _turn_preamble(
self,
@@ -2678,6 +2724,9 @@ async def _execute_tool_inner(
if name == "web_search":
log.info("Tool call: web_search — %s", params.get("query", ""))
return await self._tool_web_search(params)
+ if name == "deep_research":
+ log.info("Tool call: deep_research — %s", params.get("query", ""))
+ return await self._tool_deep_research(params, request_state)
if name == "generate_image":
return await self._tool_generate_image(params, request_state)
@@ -4154,6 +4203,137 @@ async def _searxng_search(self, query: str, max_results: int) -> dict:
]
return {"query": query, "results": results}
+ async def _tool_deep_research(self, params: dict, request_state: dict) -> dict:
+ """``deep_research`` (#293): plan → search → read → iterate → synthesize.
+
+ Runs inside the turn's tool call, so ``/stop`` (which sets the turn's
+ abort Event) cancels it between pipeline phases; progress updates go
+ straight to the originating chat. For a non-blocking run, the model can
+ spawn it inside a background subagent — the tool is offered there too;
+ that path has no turn Event, so it is cancelled from /subagents (the
+ registry's task.cancel unwinds the pipeline's awaits) instead of /stop.
+ """
+ cfg = self.config.tools.deep_research
+ if not cfg.enabled:
+ return {"error": "Deep research is disabled. Enable it in Settings → Tools."}
+ if not self.search_enabled:
+ return {"error": "Deep research needs web search — configure it in Settings → Tools."}
+ # Inherit-never-widen: deep research fans out web searches, so an agent
+ # whose scope withholds web_search may not reach them through this tool.
+ agent_obj: Agent | None = request_state.get("agent_obj")
+ if agent_obj and not agent_obj.allows_tool("web_search"):
+ return {
+ "error": (
+ "This agent's tool scope excludes web_search, which deep research requires."
+ )
+ }
+ providers_used = [("sub-call", cfg.provider)]
+ if cfg.synthesis_provider and cfg.synthesis_model: # else synthesis = main agent LLM
+ providers_used.append(("synthesis", cfg.synthesis_provider))
+ for role, provider in providers_used:
+ if (
+ provider
+ and provider != self.llm.provider
+ # A base_url-only setup (keyless local OpenAI-compatible sidecar)
+ # is a supported pattern — only refuse when neither is configured.
+ and not getattr(self.config.agent, f"{provider}_api_key", "")
+ and not getattr(self.config.agent, f"{provider}_base_url", "")
+ ):
+ return {
+ "error": (
+ f"Deep research {role} provider '{provider}' has no API key "
+ "configured — pick a configured provider in Settings → Tools."
+ )
+ }
+ query = str(params.get("query", "")).strip()
+ if not query:
+ return {"error": "A research query is required."}
+ # The configured values are ceilings the model may dial down, never past.
+ depth = resolve_cap(params.get("depth"), cfg.depth)
+ max_sources = resolve_cap(params.get("max_sources"), cfg.max_sources)
+
+ # Progress + cancellation ride on the originating chat (also correct
+ # inside a subagent, whose request_state carries the spawner's origin).
+ origin = request_state.get("origin") or {}
+ o_channel = str(origin.get("channel", ""))
+ o_user = str(origin.get("user_id", ""))
+ o_chat = str(origin.get("chat_id", ""))
+ abort = self._active_turns_map().get((o_channel, o_user, o_chat))
+ ch = self.channels.get(o_channel)
+
+ async def progress(msg: str) -> None:
+ if ch and o_chat:
+ await ch.send(o_chat, msg)
+
+ def gen_with(llm: LLMClient, model: str):
+ async def gen(prompt: str, max_tokens: int) -> tuple[str, int]:
+ r = await llm.generate(
+ model=model,
+ system="",
+ messages=[{"role": "user", "content": prompt}],
+ tools=[],
+ max_tokens=max_tokens,
+ )
+ u = r.usage or {}
+ return r.text, u.get("input_tokens", 0) + u.get("output_tokens", 0)
+
+ return gen
+
+ # Sub-calls on the configured fast/cheap model; synthesis on the main
+ # agent model unless a dedicated one is configured in the Tools tab.
+ gen_fast = gen_with(self._background_llm(cfg.provider), cfg.model)
+ if cfg.synthesis_provider and cfg.synthesis_model:
+ gen_synth = gen_with(self._background_llm(cfg.synthesis_provider), cfg.synthesis_model)
+ else:
+ synth_llm, synth_model, _ = self._agent_llm(request_state.get("agent_obj"))
+ gen_synth = gen_with(synth_llm, synth_model)
+
+ try:
+ result = await deep_research.run(
+ query,
+ depth=depth,
+ max_sources=max_sources,
+ token_budget=max(1000, cfg.token_budget),
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=lambda q: self._tool_web_search({"query": q}),
+ progress=progress,
+ cancelled=lambda: bool(abort and abort.is_set()),
+ )
+ except deep_research.ResearchCancelled:
+ return {"error": "Research cancelled by the user."}
+ except Exception as exc:
+ log.exception("Deep research failed for query: %s", query)
+ return {"error": f"Deep research failed: {exc}"}
+ if result.get("error"):
+ return result
+
+ # Publish the themed report as a web artifact when servable (#82); the
+ # chat reply then stays brief with a link. Without a workspace the full
+ # report rides back in the tool result for the model to relay.
+ ws = self._workspace_dir()
+ if ws and self.config.artifacts.enabled:
+ slug = deep_research.slugify(query, uuid.uuid4().hex[:6])
+ stamp = datetime.now(ZoneInfo(self.config.agent.timezone)).strftime("%B %d, %Y")
+ meta = f"{stamp} · {len(result['sources'])} sources · depth {depth}"
+ page = deep_research.render_report_html(
+ query, result["report"], result["sources"], meta
+ )
+ try:
+ # expanduser matches the serving route (artifacts.serving_base),
+ # so a "~/…" workspace serves what was written.
+ target = Path(ws).expanduser() / ARTIFACTS_SUBDIR / slug / "index.html"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(page, encoding="utf-8")
+ result["artifact_url"] = f"{self._base_url()}/artifacts/{slug}/"
+ result["note"] = (
+ "Report published as a web artifact. Reply briefly: the key "
+ "takeaways plus the artifact_url link — do not paste the report."
+ )
+ except OSError:
+ log.exception("Failed to write deep-research artifact %s", slug)
+ return result
+
async def _tool_generate_image(self, params: dict, request_state: dict) -> dict:
"""Generate an image and queue it for native-media delivery (issue #55).
diff --git a/humux/core/config.py b/humux/core/config.py
index 1739a6a..a39af39 100644
--- a/humux/core/config.py
+++ b/humux/core/config.py
@@ -441,6 +441,25 @@ class ImageGenToolConfig(BaseModel):
db_path: str = "data/imagegen.db" # usage counter store
+class DeepResearchToolConfig(BaseModel):
+ """Deep research (issue #293) — see core/deep_research.py.
+
+ Multi-step web research: plan → search → read → iterate → synthesize.
+ Sub-calls (planning, per-source extraction, gap analysis) run on a fast/cheap
+ model; the final synthesis defaults to the main agent model unless a
+ dedicated one is configured. ``token_budget`` is a hard per-run ceiling.
+ """
+
+ enabled: bool = False
+ provider: str = "deepseek" # sub-call (plan/extract/gap) model provider
+ model: str = "deepseek-v4-flash"
+ synthesis_provider: str = "" # blank = the main agent LLM
+ synthesis_model: str = ""
+ depth: int = 2 # max search-read-iterate cycles (ceiling for the tool arg)
+ max_sources: int = 10 # max pages read per run (ceiling for the tool arg)
+ token_budget: int = 300_000 # hard token ceiling per run
+
+
class WhatsAppToolConfig(BaseModel):
"""WhatsApp via the local `wacli` CLI (issue #97) — a tool, not a channel.
@@ -462,6 +481,7 @@ class ToolsConfig(BaseModel):
browser: BrowserToolConfig = BrowserToolConfig()
imagegen: ImageGenToolConfig = ImageGenToolConfig()
whatsapp: WhatsAppToolConfig = WhatsAppToolConfig()
+ deep_research: DeepResearchToolConfig = DeepResearchToolConfig()
class WorkspaceConfig(BaseModel):
diff --git a/humux/core/deep_research.py b/humux/core/deep_research.py
new file mode 100644
index 0000000..4646d88
--- /dev/null
+++ b/humux/core/deep_research.py
@@ -0,0 +1,403 @@
+"""Deep research — autonomous multi-step web research and report synthesis (#293).
+
+The ``deep_research`` tool runs a deterministic pipeline instead of a free-form
+agent loop: plan (decompose the question into sub-questions) → search (the
+configured web-search provider) → read (plain HTTP fetch + HTML-to-text; the
+browser tool is overkill for articles) → iterate (spot gaps, search again, up
+to ``depth`` cycles) → synthesize (a structured, cited report).
+
+Sub-calls (planning / per-source extraction / gap analysis) run on a fast,
+cheap model; the synthesis defaults to the main agent model. Both are
+configurable in the Tools tab. A hard ``token_budget`` bounds the whole run.
+
+The pipeline is pure orchestration: everything with side effects (LLM clients,
+web search, progress delivery, cancellation) is passed in by the tool handler
+in ``core/agent.py``, so this module stays independently testable.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import html
+import html.parser
+import logging
+import re
+from collections.abc import Awaitable, Callable
+
+log = logging.getLogger(__name__)
+
+# Cap on the page text handed to the extraction model — enough for most
+# articles; keeps a single huge page from eating the whole token budget.
+PAGE_TEXT_CAP = 8_000
+FETCH_TIMEOUT = 15.0
+# Sub-questions per plan and gap queries per cycle. Fixed: the knobs that
+# matter for cost (depth, max_sources, token_budget) are already configurable.
+MAX_SUBQUESTIONS = 5
+MAX_GAP_QUERIES = 3
+
+
+class ResearchCancelled(Exception):
+ """Raised between pipeline phases when the user stopped the turn."""
+
+
+class _TextExtractor(html.parser.HTMLParser):
+ """Strip an HTML document to its visible text (script/style/nav dropped)."""
+
+ _SKIP = {"script", "style", "noscript", "template", "svg", "head"}
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self._chunks: list[str] = []
+ self._skip_depth = 0
+
+ def handle_starttag(self, tag, attrs):
+ if tag in self._SKIP:
+ self._skip_depth += 1
+
+ def handle_endtag(self, tag):
+ if tag in self._SKIP and self._skip_depth:
+ self._skip_depth -= 1
+
+ def handle_data(self, data):
+ if not self._skip_depth and data.strip():
+ self._chunks.append(data)
+
+ def text(self) -> str:
+ return re.sub(r"\s+", " ", " ".join(self._chunks)).strip()
+
+
+def html_to_text(raw: str) -> str:
+ parser = _TextExtractor()
+ try:
+ parser.feed(raw)
+ parser.close()
+ except Exception: # tolerate broken markup — keep whatever was parsed
+ pass
+ return parser.text()
+
+
+async def fetch_page(url: str) -> str:
+ """Fetch a URL and return its visible text (capped), or '' on any failure.
+
+ Plain HTTP GET — deliberately not the JS-enabled browser tool; most
+ articles don't need it and a failed fetch just drops one source.
+ """
+ import httpx
+
+ try:
+ async with httpx.AsyncClient(
+ timeout=httpx.Timeout(FETCH_TIMEOUT),
+ follow_redirects=True,
+ headers={"User-Agent": "Mozilla/5.0 (compatible; humux-research)"},
+ ) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ ctype = resp.headers.get("content-type", "")
+ if "html" not in ctype and "text" not in ctype:
+ return ""
+ text = html_to_text(resp.text) if "html" in ctype else resp.text
+ return text[:PAGE_TEXT_CAP]
+ except Exception as exc:
+ log.info("deep_research: fetch failed for %s: %s", url, exc)
+ return ""
+
+
+def parse_lines(raw: str, cap: int) -> list[str]:
+ """Parse an LLM 'one item per line' reply: strip bullets/numbering, cap."""
+ out = []
+ for line in (raw or "").splitlines():
+ line = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", line).strip()
+ if line and line.upper() != "NONE":
+ out.append(line)
+ return out[:cap]
+
+
+# ── prompts ─────────────────────────────────────────────────────────────────
+
+_PLAN_PROMPT = (
+ "Decompose this research question into {n} focused, self-contained web search "
+ "queries covering its distinct angles. One query per line, nothing else.\n\n"
+ "Research question: {query}"
+)
+
+_EXTRACT_PROMPT = (
+ "You are extracting research notes. From the page text below, pull every fact, "
+ "figure, date, name or claim relevant to the research question. Terse bullet "
+ "points with specifics. If the page has nothing relevant, reply exactly "
+ "IRRELEVANT.\n\nResearch question: {query}\n\nPage ({url}):\n{text}"
+)
+
+_GAP_PROMPT = (
+ "Research question: {query}\n\nNotes gathered so far:\n{notes}\n\n"
+ "What important angles are still missing or unverified? Reply with up to "
+ "{n} web search queries that would fill the gaps, one per line. If the notes "
+ "already cover the question well, reply exactly NONE."
+)
+
+_SYNTH_PROMPT = (
+ "Write a structured research report in Markdown answering the research "
+ "question from the numbered source notes below. Requirements:\n"
+ "- Start with a '## Summary' section (a few tight paragraphs).\n"
+ "- Then 2-5 thematic '##
' finding sections.\n"
+ "- End with a '## Conclusions' section.\n"
+ "- Cite sources inline as [n] matching the source numbers.\n"
+ "- Only claims supported by the notes; note disagreements between sources.\n"
+ "- No preamble before the first heading, no source list (appended separately).\n\n"
+ "Research question: {query}\n\nSource notes:\n{notes}"
+)
+
+
+async def run(
+ query: str,
+ *,
+ depth: int,
+ max_sources: int,
+ token_budget: int,
+ gen_fast: Callable[[str, int], Awaitable[tuple[str, int]]],
+ gen_synth: Callable[[str, int], Awaitable[tuple[str, int]]],
+ search: Callable[[str], Awaitable[dict]],
+ fetch: Callable[[str], Awaitable[str]] = fetch_page,
+ progress: Callable[[str], Awaitable[None]] | None = None,
+ cancelled: Callable[[], bool] = lambda: False,
+) -> dict:
+ """Run the research pipeline; returns {query, report, sources, tokens_used}.
+
+ ``gen_fast``/``gen_synth`` are (prompt, max_tokens) → (text, tokens_spent)
+ callables; ``search`` returns the web_search tool's {results: [...]} shape.
+ ``cancelled`` is polled between phases (→ ResearchCancelled). The token
+ budget is enforced between phases: when exceeded, the loop stops early and
+ the report is synthesized from whatever was gathered.
+ """
+ tokens = 0
+
+ async def note_progress(msg: str) -> None:
+ if progress:
+ try:
+ await progress(msg)
+ except Exception:
+ log.exception("deep_research: progress delivery failed")
+
+ def check_cancel() -> None:
+ if cancelled():
+ raise ResearchCancelled()
+
+ # Plan
+ text, spent = await gen_fast(_PLAN_PROMPT.format(n=MAX_SUBQUESTIONS, query=query), 1000)
+ tokens += spent
+ queries = parse_lines(text, MAX_SUBQUESTIONS) or [query]
+ await note_progress(
+ "🔍 Researching: " + "; ".join(queries[:3]) + ("…" if len(queries) > 3 else "")
+ )
+
+ sources: list[dict] = [] # {n, url, title, notes}
+ seen_urls: set[str] = set()
+
+ for cycle in range(max(1, depth)):
+ check_cancel()
+ if tokens >= token_budget or len(sources) >= max_sources:
+ break
+
+ # Search all this cycle's queries concurrently; dedupe URLs across cycles.
+ results = await asyncio.gather(*(search(q) for q in queries), return_exceptions=True)
+ candidates: list[dict] = []
+ cycle_urls: set[str] = set()
+ for res in results:
+ if not isinstance(res, dict):
+ continue
+ for item in res.get("results", []):
+ url = (item.get("url") or "").strip()
+ if url and url not in seen_urls and url not in cycle_urls:
+ cycle_urls.add(url)
+ candidates.append({**item, "url": url}) # normalized url everywhere below
+ candidates = candidates[: max_sources - len(sources)]
+ # Only URLs actually read count as seen — a candidate dropped by the
+ # cap above stays eligible for a later cycle.
+ seen_urls.update(c["url"] for c in candidates)
+ if not candidates:
+ break
+
+ check_cancel()
+ await note_progress(f"📖 Reading {len(candidates)} sources (cycle {cycle + 1}/{depth})…")
+
+ # Fetch + extract per source in one independent chain, concurrently —
+ # no barrier, so one slow site doesn't stall the others' extraction.
+ # A failed fetch falls back to the search snippet.
+ async def read_source(cand: dict) -> tuple[dict, str, int]:
+ body = (await fetch(cand["url"])) or cand.get("content", "")
+ if not body:
+ return cand, "", 0
+ out, spent = await gen_fast(
+ _EXTRACT_PROMPT.format(query=query, url=cand["url"], text=body[:PAGE_TEXT_CAP]),
+ 1500,
+ )
+ return cand, out, spent
+
+ extracted = await asyncio.gather(
+ *(read_source(c) for c in candidates), return_exceptions=True
+ )
+ for item in extracted:
+ if not isinstance(item, tuple):
+ log.warning("deep_research: source extraction failed: %s", item)
+ continue
+ cand, notes, spent = item
+ tokens += spent
+ if notes and notes.strip().upper() != "IRRELEVANT":
+ sources.append(
+ {
+ "n": len(sources) + 1,
+ "url": cand["url"],
+ "title": cand.get("title", "") or cand["url"],
+ "notes": notes.strip(),
+ }
+ )
+
+ # Gap analysis feeds the next cycle's queries.
+ if cycle + 1 >= depth or tokens >= token_budget or len(sources) >= max_sources:
+ break
+ check_cancel()
+ notes_blob = _notes_blob(sources)
+ text, spent = await gen_fast(
+ _GAP_PROMPT.format(query=query, notes=notes_blob, n=MAX_GAP_QUERIES), 500
+ )
+ tokens += spent
+ queries = parse_lines(text, MAX_GAP_QUERIES)
+ if not queries:
+ break
+ await note_progress("🕳️ Following up on gaps: " + "; ".join(queries))
+
+ if not sources:
+ return {
+ "query": query,
+ "error": "No readable sources found — try rephrasing the question.",
+ "tokens_used": tokens,
+ }
+
+ check_cancel()
+ await note_progress(f"✍️ Synthesizing report from {len(sources)} sources…")
+ report, spent = await gen_synth(
+ _SYNTH_PROMPT.format(query=query, notes=_notes_blob(sources)), 8192
+ )
+ tokens += spent
+ return {
+ "query": query,
+ "report": report.strip(),
+ "sources": [{"n": s["n"], "url": s["url"], "title": s["title"]} for s in sources],
+ "tokens_used": tokens,
+ }
+
+
+def _notes_blob(sources: list[dict]) -> str:
+ return "\n\n".join(f"[{s['n']}] {s['title']} ({s['url']})\n{s['notes']}" for s in sources)
+
+
+# ── artifact rendering ──────────────────────────────────────────────────────
+# One shared theme so every deep-research artifact has the same look and feel.
+
+_PAGE = """
+
+
+
+
+{title}
+
+
+
+
+ Deep research
+ {title}
+ {meta}
+
+{body}
+Sources
+
+{sources}
+
+
+
+"""
+
+
+def _md_inline(text: str) -> str:
+ """Escape + render the inline Markdown subset the synthesis prompt yields."""
+ text = html.escape(text, quote=False)
+ text = re.sub(r"\[(\d+)\]", r'[\1]', text)
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
+ text = re.sub(r"(?\1", text)
+ text = re.sub(r"`([^`\n]+)`", r"\1", text)
+ return text
+
+
+def _md_to_html(md: str) -> str:
+ """Minimal Markdown→HTML for the report structure (headings, lists, paras).
+
+ ponytail: covers exactly what _SYNTH_PROMPT asks for; swap in a real
+ Markdown dependency if reports ever need tables/quotes/code fences.
+ """
+ out: list[str] = []
+ for block in re.split(r"\n{2,}", md.strip()):
+ lines = block.splitlines()
+ if all(re.match(r"\s*[-*•]\s+", ln) for ln in lines if ln.strip()):
+ out.append("")
+ for ln in lines:
+ item = re.sub(r"^\s*[-*•]\s+", "", ln).strip()
+ if item:
+ out.append(f"- {_md_inline(item)}
")
+ out.append("
")
+ continue
+ m = re.match(r"^(#{1,4})\s+(.*)", lines[0])
+ if m:
+ # md '##' → h2 (the styled section heading); h1 stays the page title.
+ level = min(max(len(m.group(1)), 2), 4)
+ out.append(f"{_md_inline(m.group(2))}")
+ rest = "\n".join(lines[1:]).strip()
+ if rest:
+ out.append(f"{_md_inline(rest)}
")
+ continue
+ out.append(f"{_md_inline(' '.join(ln.strip() for ln in lines))}
")
+ return "\n".join(out)
+
+
+def render_report_html(query: str, report_md: str, sources: list[dict], meta: str = "") -> str:
+ """The themed artifact page for one research run."""
+ src_items = "\n".join(
+ f'{html.escape(s["title"] or s["url"], quote=False)}'
+ for s in sources
+ )
+ return _PAGE.format(
+ title=html.escape(query, quote=False),
+ meta=html.escape(meta, quote=False),
+ body=_md_to_html(report_md),
+ sources=src_items,
+ )
+
+
+def slugify(query: str, suffix: str) -> str:
+ """Artifact slug: charset-safe, readable, collision-suffixed."""
+ base = re.sub(r"[^A-Za-z0-9]+", "-", query.lower()).strip("-")[:40].strip("-")
+ return f"research-{base}-{suffix}" if base else f"research-{suffix}"
diff --git a/humux/core/permissions.py b/humux/core/permissions.py
index a380bba..268248a 100644
--- a/humux/core/permissions.py
+++ b/humux/core/permissions.py
@@ -233,6 +233,7 @@ def _has_shell_control(command: str) -> bool:
"bash:git*push*": "ASK",
"bash:git*commit*": "ASK",
"web_search": "ALWAYS",
+ "deep_research": "ALWAYS", # read-only research; budgets cap the cost (#293)
"recall_memory": "ALWAYS",
"remember": "ALWAYS", # local memory write, low-stakes — no prompt (#13)
# Skill bodies live in the skills DB — reading them must never prompt (#178).
diff --git a/humux/tests/test_admin_ui.py b/humux/tests/test_admin_ui.py
index ae00b2d..4491483 100644
--- a/humux/tests/test_admin_ui.py
+++ b/humux/tests/test_admin_ui.py
@@ -54,6 +54,9 @@ async def get_section_redacted(self, section: str) -> dict:
async def set(self, key: str, value: str) -> None:
self._data[key] = value
+ async def get_many(self, prefix: str = "") -> dict[str, str]:
+ return {k: v for k, v in self._data.items() if k.startswith(prefix)}
+
async def set_many(self, values: dict) -> None:
self._last_set = values
self._data.update(values)
diff --git a/humux/tests/test_deep_research.py b/humux/tests/test_deep_research.py
new file mode 100644
index 0000000..26f52b1
--- /dev/null
+++ b/humux/tests/test_deep_research.py
@@ -0,0 +1,277 @@
+"""Deep research pipeline tests (issue #293)."""
+
+from __future__ import annotations
+
+import pytest
+
+from core import deep_research
+from core.agent import apply_feature_gates
+from core.deep_research import (
+ ResearchCancelled,
+ html_to_text,
+ parse_lines,
+ render_report_html,
+ slugify,
+)
+
+# ── helpers ─────────────────────────────────────────────────────────────────
+
+
+def make_gen(replies: list[str], tokens_per_call: int = 100):
+ """A gen_fast/gen_synth stub yielding canned replies in order (last repeats)."""
+ calls: list[str] = []
+
+ async def gen(prompt: str, max_tokens: int) -> tuple[str, int]:
+ calls.append(prompt)
+ reply = replies[min(len(calls) - 1, len(replies) - 1)]
+ return reply, tokens_per_call
+
+ gen.calls = calls
+ return gen
+
+
+async def fake_search(query: str) -> dict:
+ return {
+ "query": query,
+ "results": [
+ {"title": f"Result for {query}", "url": f"https://ex.com/{query}", "content": "snip"}
+ ],
+ }
+
+
+async def fake_fetch(url: str) -> str:
+ return f"page text of {url}"
+
+
+# ── unit helpers ────────────────────────────────────────────────────────────
+
+
+def test_html_to_text_strips_script_and_style():
+ raw = (
+ ""
+ "Hello world
"
+ )
+ assert html_to_text(raw) == "Hello world"
+
+
+def test_parse_lines_strips_bullets_numbering_and_none():
+ raw = "1. first query\n- second query\n• third\nNONE\n\n"
+ assert parse_lines(raw, 5) == ["first query", "second query", "third"]
+ assert parse_lines("NONE", 5) == []
+ assert parse_lines("a\nb\nc", 2) == ["a", "b"]
+
+
+def test_slugify_charset_and_suffix():
+ slug = slugify("Impact of X on Y? (2026)", "abc123")
+ assert slug == "research-impact-of-x-on-y-2026-abc123"
+ # servable by the artifacts route charset
+ from core.artifacts import valid_id
+
+ assert valid_id(slug)
+ assert valid_id(slugify("???", "abc123"))
+
+
+def test_render_report_html_escapes_and_links_citations():
+ page = render_report_html(
+ " & co",
+ "## Summary\nA **bold** claim [1].\n\n- item one\n- item two",
+ [{"n": 1, "url": "https://ex.com/a?x=1&y=2", "title": "Source "}],
+ meta="July 10, 2026 · 1 sources",
+ )
+ assert "<query> & co" in page
+ assert "Summary
" in page # '##' sections render as the styled h2
+ assert '[1]' in page
+ assert "bold" in page
+ assert "item one" in page
+ assert 'id="src-1"' in page
+ assert "Source <A>" in page
+
+
+# ── pipeline ────────────────────────────────────────────────────────────────
+
+
+async def test_run_happy_path_single_cycle():
+ gen_fast = make_gen(["q-one\nq-two", "fact about q-one", "fact about q-two"])
+ gen_synth = make_gen(["## Summary\nAnswer [1][2].\n\n## Conclusions\nDone."])
+ progress_msgs: list[str] = []
+
+ async def progress(msg: str) -> None:
+ progress_msgs.append(msg)
+
+ result = await deep_research.run(
+ "the question",
+ depth=1,
+ max_sources=10,
+ token_budget=100_000,
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=fake_search,
+ fetch=fake_fetch,
+ progress=progress,
+ )
+ assert "error" not in result
+ assert result["report"].startswith("## Summary")
+ assert [s["n"] for s in result["sources"]] == [1, 2]
+ assert result["tokens_used"] == 400 # plan + 2 extracts + synthesis
+ assert any("Synthesizing" in m for m in progress_msgs)
+ # depth=1 → no gap-analysis call: plan + 2 extracts only on the fast model
+ assert len(gen_fast.calls) == 3
+
+
+async def test_run_iterates_on_gaps_and_dedupes_urls():
+ # plan → one query; extract; gap → same query again (dupe URL) + a new one;
+ # extract for the new one; second gap never runs (depth=2).
+ gen_fast = make_gen(["alpha", "notes alpha", "alpha\nbeta", "notes beta"])
+ gen_synth = make_gen(["## Summary\nok"])
+ result = await deep_research.run(
+ "q",
+ depth=2,
+ max_sources=10,
+ token_budget=100_000,
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=fake_search,
+ fetch=fake_fetch,
+ )
+ urls = [s["url"] for s in result["sources"]]
+ assert urls == ["https://ex.com/alpha", "https://ex.com/beta"] # dupe dropped
+
+
+async def test_run_irrelevant_sources_dropped_and_empty_errors():
+ gen_fast = make_gen(["only-q", "IRRELEVANT"])
+ gen_synth = make_gen(["never"])
+ result = await deep_research.run(
+ "q",
+ depth=1,
+ max_sources=5,
+ token_budget=100_000,
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=fake_search,
+ fetch=fake_fetch,
+ )
+ assert "error" in result and "report" not in result
+
+
+async def test_run_token_budget_stops_iteration_but_still_synthesizes():
+ gen_fast = make_gen(["a\nb", "notes", "notes"], tokens_per_call=60_000)
+ gen_synth = make_gen(["## Summary\npartial"])
+ result = await deep_research.run(
+ "q",
+ depth=3,
+ max_sources=10,
+ token_budget=100_000,
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=fake_search,
+ fetch=fake_fetch,
+ )
+ # plan (60k) + 2 extracts (120k) blows the budget → no gap cycle, but the
+ # report is still written from what was gathered.
+ assert result["report"] == "## Summary\npartial"
+ assert len(gen_fast.calls) == 3
+
+
+async def test_run_cancelled_between_phases():
+ gen_fast = make_gen(["a", "notes"])
+ gen_synth = make_gen(["never"])
+ with pytest.raises(ResearchCancelled):
+ await deep_research.run(
+ "q",
+ depth=2,
+ max_sources=10,
+ token_budget=100_000,
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=fake_search,
+ fetch=fake_fetch,
+ cancelled=lambda: True,
+ )
+
+
+async def test_run_max_sources_cap():
+ async def many_results(query: str) -> dict:
+ return {
+ "results": [
+ {"title": f"t{i}", "url": f"https://ex.com/{i}", "content": "c"} for i in range(20)
+ ]
+ }
+
+ gen_fast = make_gen(["only-q"] + ["notes"] * 20)
+ gen_synth = make_gen(["## Summary\nok"])
+ result = await deep_research.run(
+ "q",
+ depth=1,
+ max_sources=3,
+ token_budget=100_000,
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=many_results,
+ fetch=fake_fetch,
+ )
+ assert len(result["sources"]) == 3
+
+
+async def test_run_fetch_failure_falls_back_to_snippet():
+ async def no_fetch(url: str) -> str:
+ return ""
+
+ gen_fast = make_gen(["only-q", "notes from snippet"])
+ gen_synth = make_gen(["## Summary\nok"])
+ result = await deep_research.run(
+ "q",
+ depth=1,
+ max_sources=5,
+ token_budget=100_000,
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=fake_search,
+ fetch=no_fetch,
+ )
+ assert len(result["sources"]) == 1 # snippet ("snip") was extracted instead
+
+
+async def test_run_capped_candidates_stay_eligible_next_cycle():
+ # Cycle 1 finds A, B, C but the cap reads only A, B; B is irrelevant. C was
+ # never read, so cycle 2 may still pick it up (dropped ≠ seen).
+ async def search(query: str) -> dict:
+ urls = ["A", "B", "C"] if query == "q1" else ["C"]
+ return {
+ "results": [{"title": u, "url": f"https://ex.com/{u}", "content": "c"} for u in urls]
+ }
+
+ async def gen_fast(prompt: str, max_tokens: int) -> tuple[str, int]:
+ if prompt.startswith("Decompose"):
+ return "q1", 10
+ if "still missing" in prompt:
+ return "q2", 10
+ if "/B" in prompt:
+ return "IRRELEVANT", 10
+ return f"notes for {prompt[-20:]}", 10
+
+ gen_synth = make_gen(["## Summary\nok"])
+ result = await deep_research.run(
+ "q",
+ depth=2,
+ max_sources=2,
+ token_budget=100_000,
+ gen_fast=gen_fast,
+ gen_synth=gen_synth,
+ search=search,
+ fetch=fake_fetch,
+ )
+ assert [s["url"] for s in result["sources"]] == ["https://ex.com/A", "https://ex.com/C"]
+
+
+# ── feature gating ──────────────────────────────────────────────────────────
+
+
+def test_feature_gate_requires_both_flags():
+ tools = [{"name": "deep_research"}, {"name": "web_search"}]
+
+ def gated(**kw):
+ return [t["name"] for t in apply_feature_gates(tools, secrets_available=True, **kw)]
+
+ assert "deep_research" in gated(search_enabled=True, deep_research_enabled=True)
+ assert "deep_research" not in gated(search_enabled=False, deep_research_enabled=True)
+ assert "deep_research" not in gated(search_enabled=True, deep_research_enabled=False)
diff --git a/humux/tests/test_github_app.py b/humux/tests/test_github_app.py
index be8351c..101226f 100644
--- a/humux/tests/test_github_app.py
+++ b/humux/tests/test_github_app.py
@@ -294,6 +294,9 @@ async def get(self, key: str):
async def set(self, key: str, value: str) -> None:
self._data[key] = value
+ async def get_many(self, prefix: str = "") -> dict[str, str]:
+ return {k: v for k, v in self._data.items() if k.startswith(prefix)}
+
async def verify_admin_password(self, password: str) -> bool:
return password == "secret"