Skip to content
Merged
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
79 changes: 0 additions & 79 deletions SHORTCUTS.md

This file was deleted.

61 changes: 53 additions & 8 deletions nanobot/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,22 +321,68 @@ def _build_summarize_prompt(raw: str) -> str | None:


async def _cmd_wikipedia(raw: str) -> str:
"""Fetch a Wikipedia summary for a subject."""
"""Fetch a Wikipedia summary for a subject. Tries local Kiwix first, falls back to Wikipedia REST API."""
import httpx
import re

idx = raw.lower().find("!wikipedia ")
subject = raw[idx + len("!wikipedia "):].strip() if idx != -1 else raw.strip()
if not subject:
return "Usage: `!wikipedia <subject>`"

# Wikipedia REST API — returns clean summary JSON, no parsing needed
# --- Try local Kiwix first (offline, faster) ---
kiwix_url = "http://localhost:8091"
wiki_book = "wikipedia_en_all_maxi_2026-02"
try:
async with httpx.AsyncClient(timeout=5, follow_redirects=True) as client:
search_resp = await client.get(
f"{kiwix_url}/search",
params={"pattern": subject, "lang": "eng", "books": wiki_book},
headers={"User-Agent": "mad-lab-bot/1.0"},
)
if search_resp.status_code == 200:
from lxml import html as lxml_html
dom = lxml_html.fromstring(search_resp.text)
links = dom.xpath('//div[@class="results"]//a[@href]')
if links:
href = links[0].get("href", "").strip()
article_resp = await client.get(
kiwix_url + href,
headers={"User-Agent": "mad-lab-bot/1.0"},
)
if article_resp.status_code == 200:
adom = lxml_html.fromstring(article_resp.text)
# Extract page title
page_title = "".join(adom.xpath('//h1//text()')).strip() or subject
# Extract lead paragraphs (before first section header)
paras = []
for p in adom.xpath('//div[@id="mw-content-text"]//p[not(ancestor::table)]'):
text = p.text_content().strip()
# Skip short/empty paragraphs and citation-only lines
text = re.sub(r'\[\d+\]', '', text).strip()
if len(text) > 60:
paras.append(text)
if sum(len(x) for x in paras) >= 1000:
break
if paras:
extract = " ".join(paras)[:1200]
if len(" ".join(paras)) > 1200:
extract += "…"
kiwix_article_url = kiwix_url + href
lines = [f"📖 **{page_title}** _(via local Kiwix)_", ""]
lines.append(extract)
lines.append(f"\n<{kiwix_article_url}>")
return "\n".join(lines)
except Exception:
pass # Fall through to Wikipedia REST API

# --- Fallback: Wikipedia REST API ---
title = subject.strip().replace(" ", "_")
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{title}"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(url, headers={"User-Agent": "mad-lab-bot/1.0"})
if resp.status_code == 404:
# Try search API to find closest match
search_resp = await client.get(
"https://en.wikipedia.org/w/api.php",
params={
Expand All @@ -361,17 +407,16 @@ async def _cmd_wikipedia(raw: str) -> str:
except Exception as e:
return f"❌ Wikipedia fetch failed: {e}"

page_title = data.get("title", subject)
extract = data.get("extract", "")
wiki_url = data.get("content_urls", {}).get("desktop", {}).get("page", "")
description = data.get("description", "")
page_title = data.get("title", subject)
extract = data.get("extract", "")
wiki_url = data.get("content_urls", {}).get("desktop", {}).get("page", "")
description = data.get("description", "")

lines = [f"📖 **{page_title}**"]
if description:
lines.append(f"_{description}_")
lines.append("")
if extract:
# Cap at ~1200 chars so it fits cleanly in Discord
lines.append(extract[:1200] + ("…" if len(extract) > 1200 else ""))
if wiki_url:
lines.append(f"\n<{wiki_url}>")
Expand Down
8 changes: 8 additions & 0 deletions nanobot/agent/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ async def execute(self, name: str, params: dict[str, Any]) -> str:
_HINT = "\n\n[Analyze the error above and try a different approach.]"

tool = self._tools.get(name)
if not tool:
# Models often output underscores where registered names use hyphens.
# Try normalizing hyphens→underscores in registered names and retry.
normalized = name.replace("-", "_")
tool = next(
(t for k, t in self._tools.items() if k.replace("-", "_") == normalized),
None,
)
if not tool:
return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"

Expand Down
7 changes: 7 additions & 0 deletions nanobot/channels/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ async def start(self) -> None:
break
except Exception as e:
logger.warning("Discord gateway error: {}", e)
# Non-recoverable close codes — retrying will never help.
err_str = str(e)
fatal_codes = ("4004", "4010", "4011", "4012", "4013", "4014")
if any(code in err_str for code in fatal_codes):
logger.error("Discord fatal error ({}), stopping reconnect loop.", err_str[:80])
self._running = False
break
if self._running:
logger.info("Reconnecting to Discord gateway in 5 seconds...")
await asyncio.sleep(5)
Expand Down
1 change: 1 addition & 0 deletions nanobot/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ def _make_provider(config: Config):
extra_headers=p.extra_headers if p else None,
provider_name=provider_name,
suppress_tools_param=p.suppress_tools_param if p else False,
request_timeout=p.request_timeout if p else None,
)

defaults = config.agents.defaults
Expand Down
1 change: 1 addition & 0 deletions nanobot/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ class ProviderConfig(Base):
api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
suppress_tools_param: bool = False # Don't send tools in API call; rely on text extraction
request_timeout: int | None = None # LLM request timeout in seconds (None = litellm default 600s)


class ProvidersConfig(Base):
Expand Down
79 changes: 76 additions & 3 deletions nanobot/providers/litellm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ def __init__(
extra_headers: dict[str, str] | None = None,
provider_name: str | None = None,
suppress_tools_param: bool = False,
request_timeout: int | None = None,
):
super().__init__(api_key, api_base)
self.default_model = default_model
self.extra_headers = extra_headers or {}
self.suppress_tools_param = suppress_tools_param
self.request_timeout = request_timeout

# Detect gateway / local deployment.
# provider_name (from config key) is the primary signal;
Expand Down Expand Up @@ -279,6 +281,9 @@ async def chat(
kwargs["tools"] = tools
kwargs["tool_choice"] = tool_choice or "auto"

if self.request_timeout is not None:
kwargs["timeout"] = self.request_timeout

try:
response = await acompletion(**kwargs)
return self._parse_response(response)
Expand Down Expand Up @@ -318,6 +323,8 @@ def _parse_response(self, response: Any) -> LLMResponse:
args = tc.function.arguments
if isinstance(args, str):
args = json_repair.loads(args)
if not isinstance(args, dict):
args = {}

provider_specific_fields = getattr(tc, "provider_specific_fields", None) or None
function_provider_specific_fields = (
Expand All @@ -341,8 +348,14 @@ def _parse_response(self, response: Any) -> LLMResponse:

# Fallback: some models (e.g. Qwen via Ollama/vLLM) embed tool-call JSON
# in the text content instead of the structured tool_calls field.
if tool_calls is None and content:
tool_calls, content = LiteLLMProvider._extract_text_tool_calls(content)
# Strip <think>...</think> blocks first — thinking models (e.g. Qwen3 with
# thinking=1) embed reasoning in content; tool calls appear after the block.
if not tool_calls and content:
import re as _re
stripped = _re.sub(r"<think>.*?</think>", "", content, flags=_re.DOTALL).strip()
tool_calls, extracted_content = LiteLLMProvider._extract_text_tool_calls(stripped)
if tool_calls:
content = extracted_content

# Strip EOS/EOT tokens that some models (e.g. Ministral via llama.cpp) leak
# into response content. If stored in history they break Jinja chat templating
Expand Down Expand Up @@ -374,7 +387,67 @@ def _extract_text_tool_calls(content: str) -> tuple[list[ToolCallRequest], str |
"""
import re

# Try XML format: <tool_call><function=name><parameter=key>value</parameter>...</function></tool_call>
# Try Qwen3 format: <tool_call>{"name": "...", "arguments": {...}}</tool_call>
qwen_calls = []
for qwen_match in re.finditer(r"<tool_call>(.*?)</tool_call>", content, re.DOTALL):
try:
obj = json_repair.loads(qwen_match.group(1).strip())
if isinstance(obj, dict) and isinstance(obj.get("name"), str) and isinstance(obj.get("arguments"), dict):
qwen_calls.append(ToolCallRequest(id=_short_tool_id(), name=obj["name"], arguments=obj["arguments"]))
except Exception:
pass
if qwen_calls:
first = re.search(r"<tool_call>", content)
preamble = (content[:first.start()].strip() if first else None) or None
logger.info("_parse_response: extracted {} Qwen3-embedded tool call(s): {}",
len(qwen_calls), [c.name for c in qwen_calls])
return qwen_calls, preamble

# Try [TOOL_CALLS]name[ARGS]{json} format (Nemotron/Orchestrator models)
# Don't regex-match the JSON body — find the opening { and let json_repair
# consume as much as it needs. This handles large result strings with } inside.
tc_calls = []
for tc_match in re.finditer(r"\[TOOL_CALLS\]([\w_\-]+)\[ARGS\](\{)", content, re.DOTALL):
try:
json_start = tc_match.start(2)
args = json_repair.loads(content[json_start:])
if not isinstance(args, dict):
args = {}
tc_calls.append(ToolCallRequest(id=_short_tool_id(), name=tc_match.group(1).strip(), arguments=args))
except Exception:
pass
if tc_calls:
first = re.search(r"\[TOOL_CALLS\]", content)
preamble = (content[:first.start()].strip() if first else None) or None
logger.info("_parse_response: extracted {} [TOOL_CALLS]-format tool call(s): {}",
len(tc_calls), [c.name for c in tc_calls])
return tc_calls, preamble

# Try Python code-block format: ```python\ntool_name(arg="value")\n```
import ast as _ast
py_block_match = re.search(r"```(?:python)?\s*\n?([\w_]+\(.*?\))\s*\n?```", content, re.DOTALL)
if py_block_match:
call_src = py_block_match.group(1).strip()
fn_match = re.match(r"([\w_]+)\((.*)\)$", call_src, re.DOTALL)
if fn_match:
fn_name = fn_match.group(1)
args_src = fn_match.group(2).strip()
try:
# Parse as keyword args only: key="val", key2=123, ...
dummy = f"_f({args_src})"
tree = _ast.parse(dummy, mode="eval")
arguments = {}
for kw in tree.body.keywords: # type: ignore[attr-defined]
if kw.arg:
arguments[kw.arg] = _ast.literal_eval(kw.value)
py_call = ToolCallRequest(id=_short_tool_id(), name=fn_name, arguments=arguments)
preamble = content[:py_block_match.start()].strip() or None
logger.info("_parse_response: extracted Python code-block tool call: {}", fn_name)
return [py_call], preamble
except Exception:
pass

# Try legacy XML format: <tool_call><function=name><parameter=key>value</parameter>...</function></tool_call>
# Model may omit closing </function> tag, so match up to </tool_call> or end of string.
xml_match = re.search(r"<tool_call>", content)
if xml_match:
Expand Down
Loading