Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion nanobot/nanobot/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
Expand Down Expand Up @@ -99,6 +99,7 @@ def _register_default_tools(self) -> None:

# Web tools
self.tools.register(WebFetchTool())
self.tools.register(WebSearchTool())

# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
Expand Down
138 changes: 137 additions & 1 deletion nanobot/nanobot/agent/tools/web.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Web tools: web_fetch."""
"""Web tools: web_fetch, web_search."""

import html
import json
import os
import re
from typing import Any
from urllib.parse import urlparse
Expand Down Expand Up @@ -42,6 +43,13 @@ def _validate_url(url: str) -> tuple[bool, str]:
return False, str(e)


def _get_metaso_api_key() -> str:
"""Get Metaso API key from env var METASO_API_KEY, or fall back to the
built-in default key which has a free quota of ~100 searches/day.
Set your own key to raise that limit."""
return os.environ.get("METASO_API_KEY", "mk-E384C1DD5E8501BB7EFE27C949AFDE5B")


class WebFetchTool(Tool):
"""Fetch and extract content from a URL using Readability."""

Expand Down Expand Up @@ -146,3 +154,131 @@ def _to_markdown(self, html: str) -> str:
text = re.sub(r"</(p|div|section|article)>", "\n\n", text, flags=re.I)
text = re.sub(r"<(br|hr)\s*/?>", "\n", text, flags=re.I)
return _normalize(_strip_tags(text))


class WebSearchTool(Tool):
"""Search the web using the Metaso API."""

name = "web_search"
description = (
"Search the web for information. Returns a list of results "
"with titles, URLs, and snippets. Useful for finding current "
"information, looking up facts, or researching topics."
)
parameters = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query string",
},
"topK": {
"type": "integer",
"description": "Maximum number of results to return (1-100). Default: 10",
"minimum": 1,
"maximum": 100,
},
},
"required": ["query"],
}

_METASO_URL = "https://metaso.cn/api/v1/search"
_DEFAULT_TOP_K = 10
_REQUEST_TIMEOUT = 30.0

async def execute(
self,
query: str,
top_k: int | None = None,
**kwargs: Any,
) -> str:
if "topK" in kwargs and top_k is None:
top_k = kwargs["topK"]

top_k = max(1, min(top_k or self._DEFAULT_TOP_K, 100))
api_key = _get_metaso_api_key()

try:
async with httpx.AsyncClient(
timeout=self._REQUEST_TIMEOUT,
follow_redirects=True,
max_redirects=MAX_REDIRECTS,
) as client:
resp = await client.post(
self._METASO_URL,
json={"q": query, "scope": "webpage", "size": top_k},
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
)

if resp.status_code in (401, 403):
return json.dumps(
{
"error": "Metaso API unauthorized. Check METASO_API_KEY.",
"query": query,
}
)

if resp.status_code == 429:
return json.dumps(
{
"error": "Metaso API rate limited. Please retry later.",
"query": query,
}
)

resp.raise_for_status()
data = resp.json()

code = data.get("code")
if code == 3003:
return json.dumps({"error": "Metaso daily search limit reached.", "query": query})
if code == 2005:
return json.dumps(
{
"error": "Metaso API unauthorized (error 2005). Check METASO_API_KEY.",
"query": query,
}
)
if code and code != 0:
return json.dumps(
{
"error": f"Metaso API error {code}: {data.get('message', 'unknown')}",
"query": query,
}
)

webpages = data.get("webpages", [])
results = [
{
"title": wp.get("title", ""),
"url": wp.get("link", ""),
"snippet": wp.get("snippet", "") or wp.get("summary", ""),
}
for wp in webpages
]

return json.dumps(
{
"query": query,
"total": data.get("total", len(results)),
"results": results,
}
)

except httpx.ConnectError:
return json.dumps(
{"error": "Cannot connect to Metaso API (metaso.cn).", "query": query}
)
except httpx.HTTPStatusError as e:
return json.dumps(
{
"error": f"Metaso API HTTP error: {e.response.status_code}",
"query": query,
"detail": e.response.text[:500],
}
)
except Exception as e:
return json.dumps({"error": str(e), "query": query})
11 changes: 11 additions & 0 deletions nanobot/workspace/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ exec(command: str, working_dir: str = None) -> str

## Web Access

### web_search
Search the web for information. Returns a list of results with titles, URLs, and snippets.
```
web_search(query: str, topK: int = 10) -> str
```

**Notes:**
- Uses the Metaso search API
- Results limited to 1-100, defaults to 10
- Useful for finding current information and researching topics

### web_fetch
Fetch and extract main content from a URL.
```
Expand Down
Loading