diff --git a/README.md b/README.md index 62b4dab..ea4eb8c 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,18 @@ Secrets are referenced as `${vault:NAME}` in config and `{{secret:NAME}}` in com
+Deep research — Multi-step web research with cited reports + +- `deep_research` tool: decompose the question → search → read sources → follow up on gaps → synthesize +- Structured, cited report published as a themed web artifact; brief summary + link in chat +- Fast/cheap model for sub-calls, main agent model (or a dedicated one) for synthesis +- Configurable depth, max sources, and a hard per-run token budget +- Streams progress to the chat; cancellable with `/stop` + +
+ +
+ Image generation — Visual answers - Optional `generate_image` tool (OpenRouter, fal.ai, or OpenAI) @@ -320,6 +332,7 @@ humux/ │ ├── artifacts.py Web artifact serving (sandboxed) │ ├── coding.py Confined workspace file tools │ ├── compaction.py Conversation compaction for session history +│ ├── deep_research.py Multi-step web research pipeline │ ├── github_app.py GitHub App JWT minting + installation tokens │ ├── history.py Conversation history persistence │ ├── imagegen.py Image generation with budget caps diff --git a/docs/content/docs/admin-ui.mdx b/docs/content/docs/admin-ui.mdx index 5d5cea8..9373d9b 100644 --- a/docs/content/docs/admin-ui.mdx +++ b/docs/content/docs/admin-ui.mdx @@ -53,6 +53,7 @@ Edit agent settings without touching config files: Enable optional tools. When a tool is enabled it is authenticated and advertised to the assistant in its system prompt; when disabled it stays hidden, keeping the prompt lean. Each tool honours the same per-agent whitelist and permission rules as any other tool. - **Web search** — let the agent look things up online via the `web_search` tool (currently Tavily). Toggle enabled, paste the API key (stored in the [vault](/docs/secrets)), pick max results, and **Test connection**. When disabled the tool is hidden from the model entirely. +- **Deep research** — autonomous multi-step web research via the `deep_research` tool: the question is decomposed into sub-questions, each is searched (using the configured Web search backend) and its top sources read, gaps trigger follow-up searches, and the result is a structured, cited report — published as a themed [web artifact](/docs/extending) when the workspace is enabled, so the chat reply stays a brief summary plus a link. Configure the **sub-call model** (fast/cheap; planning, extraction, gap analysis), the **synthesis model** (blank = the main agent model), and the cost guardrails: **depth** (search-read-iterate cycles), **max sources**, and a hard per-run **token budget**. The tool posts progress updates to the chat as it works and is cancellable with `/stop`. Needs Web search configured. - **GitHub CLI (`gh`)** — let the agent query and act on GitHub (issues, PRs, repos, `gh api`, search). Authenticate with a **Personal Access Token** (simple; actions appear as you) or a **[GitHub App](/docs/extending#authenticating-pat-or-github-app)** (recommended — its own bot identity `[bot]`, fine-grained per-repo permissions, and its own rate limit). For a PAT, paste a [token](https://github.com/settings/tokens); for an App, paste the **App ID**, **Installation ID**, and **private key** (PEM — pasting works on mobile, stored in the vault). Either way it is injected as `GH_TOKEN`; the App takes precedence, with the PAT as a fallback. Read operations (`list`/`view`/`status`/`api`/`search`) run without asking; creating issues, PRs, or releases ask for confirmation first. **Test connection** verifies the credentials and shows the authenticated identity (and, for an App, the accessible repos). The whole setup is completable at phone width. In session history mode, a newly enabled tool is advertised starting from the next `/new` conversation. ### Skills diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index ba32f2e..f8511fd 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -190,6 +190,23 @@ single config change with no code modification. exposes it at `http://searxng:8080`. Pick the provider and test reachability from the admin UI (Tools → Web search) or the setup wizard. +#### `tools.deep_research` + +The `deep_research` tool (Tools → Deep research in the admin UI): autonomous +multi-step research on top of `search`. Off by default; needs web search +configured. + +- `provider` / `model` — the fast/cheap model for sub-calls (planning, + per-source extraction, gap analysis). Default `deepseek` / + `deepseek-v4-flash`. +- `synthesis_provider` / `synthesis_model` — the model that writes the final + report. Blank (default) = the main agent model. +- `depth` (default `2`) — search-read-iterate cycles; also the ceiling when the + agent picks its own per-call value. +- `max_sources` (default `10`) — pages read per run; also a ceiling. +- `token_budget` (default `300000`) — hard token ceiling per run; when + exceeded the loop stops early and synthesizes from what was gathered. + #### `vision` Vision fallback. When the active model can't read images, image attachments are routed to `model` (on `provider`, reusing that provider's `agent` API key) for a text description / OCR, injected in place of the image so non-multimodal models can still "see". Off by default; engages only when the active model lacks native vision. The admin LLM tab and the setup wizard surface a one-tap enable prompt when you select a model that can't read images. diff --git a/docs/content/docs/permissions.mdx b/docs/content/docs/permissions.mdx index 0b825d1..2e436aa 100644 --- a/docs/content/docs/permissions.mdx +++ b/docs/content/docs/permissions.mdx @@ -37,6 +37,7 @@ humux ships with sensible defaults: "bash:pwd*" "bash:whoami*" "bash:id*" # cat/ls/wc/df/… "bash:wc*" "bash:df*" "bash:du*" # (see note below) "web_search" # Web searches +"deep_research" # Multi-step web research "set_reaction" # Emoji reactions (Telegram) ``` diff --git a/humux/api/admin.py b/humux/api/admin.py index 9baf77a..bdef692 100644 --- a/humux/api/admin.py +++ b/humux/api/admin.py @@ -2145,6 +2145,17 @@ async def partial_tools() -> HTMLResponse: ig_daily = await config_store.get("tools.imagegen.daily_budget") or "0" ig_monthly = await config_store.get("tools.imagegen.monthly_budget") or "0" + # Deep research (issue #293). One prefix fetch; fall back to the + # pydantic defaults so they live in config.py only. + from core.config import DeepResearchToolConfig + + dr_defaults = DeepResearchToolConfig() + dr_stored = await config_store.get_many("tools.deep_research.") + + def dr_get(field: str) -> str: + stored = dr_stored.get(f"tools.deep_research.{field}") + return stored if stored is not None else str(getattr(dr_defaults, field)).lower() + # WhatsApp tool (#97) — enable flag + wacli link status for the badge. wa_enabled = await config_store.get("tools.whatsapp.enabled") wa_enabled = wa_enabled if wa_enabled is not None else "false" @@ -2192,6 +2203,14 @@ async def partial_tools() -> HTMLResponse: imagegen_key_vaulted=ig_key_vaulted, imagegen_daily_budget=ig_daily, imagegen_monthly_budget=ig_monthly, + deep_research_enabled=dr_get("enabled"), + deep_research_provider=dr_get("provider"), + deep_research_model=dr_get("model"), + deep_research_synth_provider=dr_get("synthesis_provider"), + deep_research_synth_model=dr_get("synthesis_model"), + deep_research_depth=dr_get("depth"), + deep_research_max_sources=dr_get("max_sources"), + deep_research_token_budget=dr_get("token_budget"), search_enabled=search_enabled, search_provider=search_provider, search_api_key="" if search_vaulted else search_api_key, @@ -5194,6 +5213,7 @@ def _config_requires_restart(values: dict) -> bool: "search_contacts", "create_contact", "web_search", + "deep_research", "manage_jobs", "spawn_subagent", "generate_image", @@ -5220,6 +5240,7 @@ def _config_requires_restart(values: dict) -> bool: "search_contacts": "Look up people in a bound address book.", "create_contact": "Add a contact to a bound address book.", "web_search": "Search the web (needs the Web search tool enabled).", + "deep_research": "Multi-step web research with a synthesized, cited report.", "manage_jobs": "Schedule, list and cancel the agent's own jobs.", "spawn_subagent": "Delegate a scoped subtask to a child agent.", "generate_image": "Generate images and send them as photos.", diff --git a/humux/api/templates/partials/tools.html b/humux/api/templates/partials/tools.html index 42ab7ad..ccf731c 100644 --- a/humux/api/templates/partials/tools.html +++ b/humux/api/templates/partials/tools.html @@ -342,6 +342,91 @@

Web search

+ {# Deep research (issue #293) #} +
+
+

Deep research

+
+ + +
+
+

+ Autonomous multi-step web research (the deep_research tool): + the question is split into sub-questions, each is searched and its sources + read, gaps trigger follow-up searches, and the result is a structured, cited + report — published as a web artifact when the workspace is enabled. Needs the + Web search tool configured. Cancel a running research with + /stop (a background subagent run: from its cancel button in + Jobs). Assign it per agent in the Agents editor. +

+
+
+
+ + +

Fast/cheap model for planning, extraction and gap analysis. Uses that provider's configured API key.

+
+
+ + +
+
+ + +

Writes the final report. Blank = the model of the agent that ran the tool.

+
+
+ + +
+
+
+
+ + +

Search-read-iterate cycles (1-3). Also the cap when the agent picks its own.

+
+
+ + +

Pages read per run — a cap too.

+
+
+ + +

Hard token ceiling per run.

+
+
+
+
+ +
+ +
+ {# WhatsApp via the local wacli CLI (issue #97) — a tool, not a channel #}
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"