diff --git a/docs/api.md b/docs/api.md index f30fed2..5cf05d4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -149,7 +149,7 @@ "id": "secretary", "name": "Personal Secretary", "description": "General-purpose assistant", - "enabled_tools": ["todo", "web_search", "filesystem", "..."], + "enabled_tools": ["todo", "mcp_navi-web_search", "filesystem", "..."], "llm_backend": "ollama", "model": ["gemma4:31b-cloud", "gemma4:26b-a4b-it-q4_K_M"], "temperature": 0.65, @@ -172,7 +172,7 @@ ```json [ { - "name": "web_search", + "name": "mcp_navi-web_search", "description": "Search the web using DuckDuckGo.", "parameters": {"type": "object", "properties": {...}, "required": [...]} }, @@ -334,7 +334,7 @@ "tool_calls": [ { "id": "abc123", - "name": "web_search", + "name": "mcp_navi-web_search", "arguments": { "query": "..." } } ] @@ -343,7 +343,7 @@ "role": "tool", "content": "tool result", "tool_call_id": "abc123", - "name": "web_search" + "name": "mcp_navi-web_search" } ] } @@ -657,7 +657,7 @@ ```json { "type": "tool_started", - "tool": "web_search", + "tool": "mcp_navi-web_search", "args": { "query": "weather in moscow" }, "is_subagent": false } @@ -671,7 +671,7 @@ ```json { "type": "tool_call", - "tool": "web_search", + "tool": "mcp_navi-web_search", "args": { "query": "weather in moscow" }, "result": "Today +12°C, cloudy.", "success": true, diff --git a/docs/profiles.md b/docs/profiles.md index 427eed2..dae70b9 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -131,7 +131,7 @@ "model": ["gemma4:31b-cloud", "gemma4:26b-a4b-it-q4_K_M"], "temperature": 0.5, "max_iterations": 20, - "enabled_tools": ["todo", "scratchpad", "web_search", "filesystem"], + "enabled_tools": ["todo", "scratchpad", "mcp_navi-web_search", "filesystem"], "subagent_tools": ["todo", "filesystem", "terminal"], "planning_enabled": true, "planning_mandatory": false, diff --git a/docs/tools.md b/docs/tools.md index 1da31f8..32fd056 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -10,10 +10,10 @@ | Tool | Name | Description | |---|---|---| -| `WebSearchTool` | `web_search` | DuckDuckGo search | -| `WebViewTool` | `web_view` | Fetch and render a URL | +| `WebSearchTool` | `mcp_navi-web_search` | DuckDuckGo search | +| `WebViewTool` | `mcp_navi-web_view` | Fetch and render a URL | | `FilesystemTool` | `filesystem` | Read/write/list local files (path restrictions via config) | -| `HttpRequestTool` | `http_request` | Generic HTTP client (GET/POST/etc.) | +| `HttpRequestTool` | `mcp_navi-web_request` | Generic HTTP client (GET/POST/etc.) | | `CodeExecTool` | `code_exec` | Execute Python in a subprocess sandbox | | `TerminalTool` | `terminal` | Run shell commands (command allowlist via config) | | `SshExecTool` | `ssh_exec` | SSH into remote hosts; connection pool keyed by session ID | diff --git a/navi/profiles/developer/config.json b/navi/profiles/developer/config.json index f469cee..7347827 100644 --- a/navi/profiles/developer/config.json +++ b/navi/profiles/developer/config.json @@ -6,7 +6,7 @@ "full_description": { "specialization": "Full-stack software development: writing code in any language, debugging, running tests, working with files and project structure, git, APIs, scripting. Works on the user's own projects, not Navi's internals.", "when_to_use": "When the user wants to build something — a game, a script, an app, a web service, anything. For writing Navi tools specifically, use tool_developer instead.", - "key_tools": "filesystem, code_exec, terminal, web_search, web_view, spawn_agent" + "key_tools": "filesystem, code_exec, terminal, mcp_navi-web_search, mcp_navi-web_view, spawn_agent" }, "llm_backend": "ollama", "model": [ diff --git a/navi/profiles/discuss/system_prompt.txt b/navi/profiles/discuss/system_prompt.txt index dc85bc7..3f5ddfa 100644 --- a/navi/profiles/discuss/system_prompt.txt +++ b/navi/profiles/discuss/system_prompt.txt @@ -18,7 +18,7 @@ ## Tools -Use `web_search` + `web_view` when a factual grounding would strengthen the discussion — not for every question, only when currency or precision matters. +Use `mcp_navi-web_search` + `mcp_navi-web_view` when a factual grounding would strengthen the discussion — not for every question, only when currency or precision matters. Use project `docs/` when discussing an active project. Prefer `docs/index.md` as the map, then query specific docs rather than rereading broad source trees. diff --git a/navi/profiles/modeler_3d/subagent_system_prompt.txt b/navi/profiles/modeler_3d/subagent_system_prompt.txt index 72c2bbd..af8b329 100644 --- a/navi/profiles/modeler_3d/subagent_system_prompt.txt +++ b/navi/profiles/modeler_3d/subagent_system_prompt.txt @@ -6,7 +6,7 @@ 1. Read the briefing and parent session context. 2. Identify the exact missing facts requested by the parent agent. -3. Use `web_search`, `web_view`, `filesystem`, and `image_view` as needed to gather evidence. +3. Use `mcp_navi-web_search`, `mcp_navi-web_view`, `filesystem`, and `image_view` as needed to gather evidence. 4. Prefer primary sources, product pages, datasheets, manuals, dimensions in local files, or images provided by the user. 5. Return only the facts found, source paths/URLs, confidence, and unresolved gaps. diff --git a/navi/profiles/secretary/config.json b/navi/profiles/secretary/config.json index a9ece8b..94b397b 100644 --- a/navi/profiles/secretary/config.json +++ b/navi/profiles/secretary/config.json @@ -6,7 +6,7 @@ "full_description": { "specialization": "General-purpose personal assistant. Web research, document writing, data analysis, email correspondence, planning, calculations, and any everyday task that doesn't require direct server access or tool development.", "when_to_use": "Default profile for most requests. If you're unsure which profile to use, this one is correct. Switch away only when the task clearly requires server/infrastructure access (server_admin) or modifying Navi's own tools (developer).", - "key_tools": "web_search, web_view, filesystem, code_exec, gmail, todo, scratchpad, spawn_agent, memory" + "key_tools": "mcp_navi-web_search, mcp_navi-web_view, filesystem, code_exec, gmail, todo, scratchpad, spawn_agent, memory" }, "llm_backend": "ollama", "model": [ diff --git a/navi/profiles/secretary/system_prompt.txt b/navi/profiles/secretary/system_prompt.txt index 94f3081..4409b97 100644 --- a/navi/profiles/secretary/system_prompt.txt +++ b/navi/profiles/secretary/system_prompt.txt @@ -69,11 +69,11 @@ --- ## Tool priorities -1. web_search — first choice for current info, facts, documentation. +1. mcp_navi-web_search — first choice for current info, facts, documentation. 2. code_exec — calculations, data processing, text parsing, format conversion. -3. web_view — view a specific page in full. +3. mcp_navi-web_view — view a specific page in full. 4. filesystem — read/write local documents, notes, data files. -5. http_request — external APIs, webhooks, content not suited for search. +5. mcp_navi-web_request — external APIs, webhooks, content not suited for search. 6. image_view — whenever an image path or URL is mentioned. ## Output style diff --git a/navi/profiles/server_admin/config.json b/navi/profiles/server_admin/config.json index a1700e0..4a404cc 100644 --- a/navi/profiles/server_admin/config.json +++ b/navi/profiles/server_admin/config.json @@ -6,7 +6,7 @@ "full_description": { "specialization": "Remote server operations via SSH, system diagnostics, service management, log analysis, network troubleshooting, process monitoring, and infrastructure automation.", "when_to_use": "When the task involves SSH access to servers, running system commands, managing Linux services, analyzing logs, monitoring resources, or any hands-on infrastructure work.", - "key_tools": "ssh_exec, terminal, filesystem, code_exec, web_search, spawn_agent, memory" + "key_tools": "ssh_exec, terminal, filesystem, code_exec, mcp_navi-web_search, spawn_agent, memory" }, "llm_backend": "ollama", "model": [ diff --git a/navi/profiles/server_admin/system_prompt.txt b/navi/profiles/server_admin/system_prompt.txt index 3d17b45..08ad591 100644 --- a/navi/profiles/server_admin/system_prompt.txt +++ b/navi/profiles/server_admin/system_prompt.txt @@ -46,7 +46,7 @@ 6. **Synthesise** — after all agents report back, write your conclusions and next steps. ### Plan → execution binding -- **TOOL** — direct local call (terminal, filesystem, http_request for health checks). +- **TOOL** — direct local call (terminal, filesystem, mcp_navi-web_request for health checks). - **AGENT** — call `spawn_agent` for THIS STEP ONLY. One AGENT step = one spawn_agent call. If your plan has steps 1, 2, 3 all marked AGENT — you make three separate spawn_agent calls. Never bundle multiple steps into one call. Never pass your full plan to a single subagent. @@ -80,8 +80,8 @@ 1. ssh_exec — direct single-command checks on known hosts when spawning is overkill. 2. terminal — local machine operations. 3. filesystem — local config files, scripts. -4. http_request — health check endpoints, REST APIs. -5. web_search — error lookups, documentation. +4. mcp_navi-web_request — health check endpoints, REST APIs. +5. mcp_navi-web_search — error lookups, documentation. ## Execution environment `terminal`, `filesystem`, and `code_exec` run on the LOCAL machine (where Navi's server is running) — NOT on any remote host. diff --git a/navi/tools/http_request.py b/navi/tools/http_request.py deleted file mode 100644 index 1f01674..0000000 --- a/navi/tools/http_request.py +++ /dev/null @@ -1,79 +0,0 @@ -"""HTTP request tool — make outbound HTTP calls.""" - -import json - -import httpx - -from .base import Tool, ToolResult - -_TIMEOUT = 30.0 - - -class HttpRequestTool(Tool): - name = "http_request" - description = ( - "Make a raw HTTP request (GET/POST/PUT/PATCH/DELETE). " - "Use for REST APIs, JSON endpoints, local services (e.g. Home Assistant), webhooks, " - "or any service requiring custom headers or auth tokens. " - "Returns the raw response body — use web_view instead if you need to read a web page " - "meant for humans (web_view handles JavaScript and returns clean readable text)." - ) - parameters = { - "type": "object", - "properties": { - "method": { - "type": "string", - "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], - "description": "HTTP method", - }, - "url": {"type": "string", "description": "Full URL to request"}, - "headers": { - "type": "object", - "description": "Optional HTTP headers as key-value pairs", - }, - "body": { - "type": "object", - "description": "Optional JSON body for POST/PUT/PATCH requests", - }, - "params": { - "type": "object", - "description": "Optional query parameters", - }, - }, - "required": ["method", "url"], - } - - async def execute(self, params: dict) -> ToolResult: - method = params["method"].upper() - url = params["url"] - headers = params.get("headers") or {} - body = params.get("body") - query_params = params.get("params") - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT, follow_redirects=True) as client: - response = await client.request( - method=method, - url=url, - headers=headers, - json=body, - params=query_params, - ) - - # Try to decode as JSON for nicer output - try: - body_repr = json.dumps(response.json(), ensure_ascii=False, indent=2) - except Exception: - body_repr = response.text[:4096] # cap large responses - - output = f"Status: {response.status_code}\n\n{body_repr}" - return ToolResult( - success=response.is_success, - output=output, - metadata={"status_code": response.status_code, "headers": dict(response.headers)}, - error=None if response.is_success else f"HTTP {response.status_code}", - ) - except httpx.TimeoutException: - return ToolResult(success=False, output=f"Request timed out after {_TIMEOUT}s", error="timeout") - except Exception as e: - return ToolResult(success=False, output=f"Request failed: {e}", error=str(e)) diff --git a/navi/tools/web_search.py b/navi/tools/web_search.py deleted file mode 100644 index 6865781..0000000 --- a/navi/tools/web_search.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Web search tool — DDG html backend (primary) with Brave/SearXNG fallback.""" - -import asyncio - -import httpx -from ddgs import DDGS - -from navi.config import settings - -from .base import Tool, ToolResult - -# html is the only DDG backend that reliably returns results; auto/lite are flaky. -# On failure we fall back to html once more, then Brave/SearXNG. -_DDG_RETRY_DELAY = 2.0 # seconds before second DDG attempt - - -class WebSearchTool(Tool): - name = "web_search" - description = ( - "Search the web (DuckDuckGo with Brave/SearXNG fallback). " - "Use when you need current info, real-time data, documentation, or facts you're " - "uncertain about. Returns titles, URLs, snippets. " - "Prefer this over your own training knowledge when recency or accuracy matters." - ) - parameters = { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "max_results": { - "type": "integer", - "description": "Number of results to return (default 5)", - "default": 5, - }, - }, - "required": ["query"], - } - - async def execute(self, params: dict) -> ToolResult: - query = params["query"] - max_results = int(params.get("max_results", 5)) - last_error: str = "" - - # 1. SearXNG (primary — self-hosted, aggregates multiple engines) - if settings.searxng_url: - try: - results = await self._searxng(query, max_results) - if results: - return self._format(results, source="SearXNG") - last_error = "SearXNG: empty response" - except Exception as e: - last_error = f"SearXNG: {e}" - - # 2. DuckDuckGo html fallback, retry once - for attempt in range(2): - try: - results = await asyncio.to_thread( - lambda: list( - DDGS().text(query, backend="html", max_results=max_results, region="ua-uk") - ) - ) - if results: - return self._format(results, source="DDG") - last_error = "DDG: empty response" - except Exception as e: - last_error = f"DDG: {e}" - if attempt == 0: - await asyncio.sleep(_DDG_RETRY_DELAY) - - # 3. Brave Search fallback - if settings.brave_search_api_key: - try: - results = await self._brave(query, max_results) - if results: - return self._format(results, source="Brave") - except Exception as e: - last_error = f"Brave: {e}" - - return ToolResult( - success=False, - output=( - f"All search backends failed for query: {query!r}\n" - f"Last error: {last_error}" - ), - error=last_error, - ) - - # ── helpers ─────────────────────────────────────────────────────────────── - - def _format(self, results: list[dict], source: str) -> ToolResult: - lines = [ - f"[{i+1}] {r.get('title', '')}\n" - f" URL: {r.get('href') or r.get('url', '')}\n" - f" {r.get('body') or r.get('description', '')}" - for i, r in enumerate(results) - ] - output = f"[{source}]\n\n" + "\n\n".join(lines) - return ToolResult( - success=True, - output=output, - metadata={"results": results, "source": source}, - ) - - async def _brave(self, query: str, max_results: int) -> list[dict]: - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.get( - "https://api.search.brave.com/res/v1/web/search", - params={"q": query, "count": min(max_results, 20), "country": "ALL"}, - headers={ - "Accept": "application/json", - "Accept-Encoding": "gzip", - "X-Subscription-Token": settings.brave_search_api_key, - }, - ) - resp.raise_for_status() - items = resp.json().get("web", {}).get("results", []) - return [ - {"title": r["title"], "href": r["url"], "body": r.get("description", "")} - for r in items - ] - - async def _searxng(self, query: str, max_results: int) -> list[dict]: - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.get( - f"{settings.searxng_url.rstrip('/')}/search", - params={ - "q": query, - "format": "json", - "engines": "google,bing,duckduckgo", - "language": "uk-UA", - }, - ) - resp.raise_for_status() - results = resp.json().get("results", [])[:max_results] - return [ - {"title": r["title"], "href": r["url"], "body": r.get("content", "")} - for r in results - ] diff --git a/navi/tools/web_view.py b/navi/tools/web_view.py deleted file mode 100644 index ab347d4..0000000 --- a/navi/tools/web_view.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Web view tool — open a URL in a real browser and extract readable content.""" - -import base64 -import re - -from playwright.async_api import async_playwright - -from .base import Tool, ToolResult - -_TIMEOUT = 30_000 # ms for page load -_MAX_TEXT = 20_000 # chars — cap huge pages - - -def _clean(text: str) -> str: - """Collapse excessive blank lines and strip trailing whitespace.""" - lines = [line.rstrip() for line in text.splitlines()] - result: list[str] = [] - blank_run = 0 - for line in lines: - if line == "": - blank_run += 1 - if blank_run <= 2: - result.append("") - else: - blank_run = 0 - result.append(line) - return "\n".join(result).strip() - - -class WebViewTool(Tool): - name = "web_view" - description = ( - "Open a URL in a real headless browser and return clean readable text. " - "Use this to browse web pages — it executes JavaScript, waits for the page " - "to finish loading, and strips HTML/scripts so you get the actual content. " - "Optionally takes a screenshot so you can see the page visually. " - "Use http_request instead when working with REST APIs, JSON endpoints, " - "or services that need custom headers/auth." - ) - parameters = { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "Full URL to open (must start with http:// or https://)", - }, - "screenshot": { - "type": "boolean", - "description": "If true, also capture a screenshot of the page (default: false)", - }, - "wait_until": { - "type": "string", - "enum": ["load", "domcontentloaded", "networkidle"], - "description": ( - "When to consider the page loaded. " - "'networkidle' (default) waits for no network activity — best for SPAs. " - "'load' is faster but may miss dynamic content." - ), - }, - }, - "required": ["url"], - } - - async def execute(self, params: dict) -> ToolResult: - url: str = params["url"].strip() - take_screenshot: bool = params.get("screenshot", False) - wait_until: str = params.get("wait_until", "networkidle") - - if not url.startswith(("http://", "https://")): - return ToolResult(success=False, output="URL must start with http:// or https://", - error="invalid url") - - try: - async with async_playwright() as pw: - browser = await pw.chromium.launch(headless=True) - context = await browser.new_context( - viewport={"width": 1280, "height": 800}, - user_agent=( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - ), - ) - page = await context.new_page() - - try: - await page.goto(url, wait_until=wait_until, timeout=_TIMEOUT) - except Exception: - # Timeout waiting for networkidle is often fine — page may still be usable - pass - - title = await page.title() - final_url = page.url - - # Extract readable text: hide noise, grab visible text - text = await page.evaluate("""() => { - const kill = ['script','style','noscript','iframe', - 'nav','header','footer','aside', - '[role="navigation"]','[role="banner"]', - '[role="contentinfo"]']; - const clone = document.body.cloneNode(true); - kill.forEach(sel => { - clone.querySelectorAll(sel).forEach(el => el.remove()); - }); - return clone.innerText || clone.textContent || ''; - }""") - - text = _clean(text) - if len(text) > _MAX_TEXT: - text = text[:_MAX_TEXT] + f"\n\n[… truncated at {_MAX_TEXT} chars]" - - output_parts = [] - if title: - output_parts.append(f"Title: {title}") - if final_url != url: - output_parts.append(f"Final URL: {final_url}") - output_parts.append("") - output_parts.append(text) - output = "\n".join(output_parts) - - # Screenshot - screenshot_b64: str | None = None - if take_screenshot: - png = await page.screenshot(full_page=False) - screenshot_b64 = base64.b64encode(png).decode() - - await context.close() - await browser.close() - - metadata: dict = {} - if screenshot_b64: - metadata = {"base64": screenshot_b64, "mime": "image/png", "is_image": True} - - return ToolResult(success=True, output=output, metadata=metadata or None) - - except Exception as e: - return ToolResult(success=False, output=f"Browser error: {e}", error=str(e))