Improve MCP tool profiles, routing, and Gitea agent guidance

- Route forge-style messages to forge-related profiles instead of arbitrary *mcp* keys
- Expand agent loop, custom provider, session manager, filesystem/web tools
- Document local MCP server setup; extend setup-mcp-servers.sh and backlog
- System prompt: Gitea list_issues vs search_issues, issues vs PRs, since/date windows

Made-with: Cursor
This commit is contained in:
tanyar09 2026-04-02 12:17:39 -04:00
parent 4b808f9a30
commit cb7bbaf6e5
13 changed files with 648 additions and 131 deletions

View File

@ -744,6 +744,8 @@ nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external to
For a full Gmail MCP walkthrough (config + OAuth + verification), see [`docs/gmail_mcp_setup.md`](docs/gmail_mcp_setup.md).
For Proxmox VE via a local clone of [ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus.git), plus **`tools.toolProfiles`** so each MCP stays in its own small tool set (needed for many local LLMs), see **[`mcp-servers/README.md`](mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus)**.
Add MCP servers to your `config.json`:
```json
@ -766,7 +768,7 @@ Two transport modes are supported:
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
| **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) |
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
MCP tools are registered when each server connects. If you set `tools.toolProfiles`, nanobot only connects the MCP servers listed for the active profile (see [`mcp-servers/README.md`](mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus)); otherwise all configured MCP servers are used for the run.

View File

@ -81,7 +81,7 @@ nanobot/
├── mcp-servers/ # <-- NEW: local MCP server clones
│ ├── gitea-mcp/ # git clone from gitea.com/gitea/gitea-mcp
│ ├── google-calendar-mcp/ # git clone from github.com/nspady/google-calendar-mcp
│ ├── mcp-proxmox/ # git clone from github.com/antonio-mello-ai/mcp-proxmox
│ ├── proxmox-mcp-plus/ # git clone from github.com/RekklesNA/ProxmoxMCP-Plus
│ └── fetch-browser/ # git clone from github.com/TheSethRose/Fetch-Browser
├── nanobot/
├── docs/
@ -195,10 +195,10 @@ These are the 4 MCP servers we plan to integrate in the immediate next phase. Ea
| Field | Detail |
|---|---|
| **Upstream** | `github.com/antonio-mello-ai/mcp-proxmox` (Python, pip-installable, MIT) |
| **Transport** | Stdio via `python -m mcp_proxmox` |
| **Auth** | Proxmox API token (user `nanobot@pam!mcp-token` + secret) |
| **Complexity** | **Medium**requires network route to Proxmox cluster API, API token creation on Proxmox, and careful permission scoping |
| **Upstream** | [RekklesNA/ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus.git) (Python 3.11+, MIT) |
| **Transport** | Stdio via `python -m proxmox_mcp.server` and `PROXMOX_MCP_CONFIG` |
| **Auth** | Proxmox API token (in `proxmox-config/config.json`; see upstream example) |
| **Complexity** | **Medium**Proxmox API reachability, token scoping; **many tools** → use `tools.toolProfiles` per MCP |
| **New capability** | Homelab infrastructure visibility and management from chat |
| **Target agents** | `@ilia` only (infrastructure admin; never exposed to `@family` or `@wife`) |
@ -211,26 +211,24 @@ These are the 4 MCP servers we plan to integrate in the immediate next phase. Ea
#### Technical notes
- **Build**: `pip install -e ./mcp-servers/mcp-proxmox/` into nanobot's venv, or use a dedicated venv.
- **Local clone path**: `mcp-servers/mcp-proxmox/`
- **Build**: `./scripts/setup-mcp-servers.sh proxmox-mcp-plus`. Full steps: [`mcp-servers/README.md`](../mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus).
- **Local clone path**: `mcp-servers/proxmox-mcp-plus/`
- **Proxmox setup**:
1. Create API token: Datacenter → Permissions → API Tokens → Add (`nanobot@pam`, token ID `mcp-token`).
2. Assign minimum roles: `PVEAuditor` for read-only, `PVEVMAdmin` for lifecycle ops (Phase 1 starts read-only).
3. Store token secret in `~/.nanobot/config.json` env or in a `.env` file.
3. Copy `proxmox-config/config.example.json` to `config.json` and set host + token fields.
- **Config entry**:
```jsonc
"proxmox": {
"command": "python",
"args": ["-m", "mcp_proxmox"],
"command": "/mnt/data/nanobot/venv/bin/python",
"args": ["-m", "proxmox_mcp.server"],
"env": {
"PROXMOX_HOST": "https://10.0.30.1:8006",
"PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token",
"PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET",
"PROXMOX_VERIFY_SSL": "false"
"PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json"
}
}
```
- **Expected tool names**: `mcp_proxmox_list_nodes`, `mcp_proxmox_list_vms`, `mcp_proxmox_list_containers`, `mcp_proxmox_vm_status`, `mcp_proxmox_start_vm`, `mcp_proxmox_stop_vm`, `mcp_proxmox_create_snapshot`, `mcp_proxmox_list_storage`
- **Tool profiles**: One profile per MCP (e.g. `toolProfiles.proxmox` with `"mcpServers": ["proxmox"]` only). Enable `toolRouting` so the active profile switches per message. See [`mcp-servers/README.md`](../mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus).
- **Expected tool names** (examples): `mcp_proxmox_get_vms`, `mcp_proxmox_get_nodes` — exact set depends on upstream (many tools).
- **Safety**: Phase 1 deploys with `PVEAuditor` role (read-only). Write operations (start/stop/snapshot) added in Phase 2 behind confirmation prompts. Restricted to `@ilia` profile only — never exposed to `@family`.
---
@ -411,12 +409,12 @@ cd gitea-mcp && go build -o gitea-mcp . && cd ..
git clone https://github.com/nspady/google-calendar-mcp.git
cd google-calendar-mcp && npm install && npm run build && cd ..
# Proxmox MCP (Python)
git clone https://github.com/antonio-mello-ai/mcp-proxmox.git
cd mcp-proxmox && pip install -e . && cd ..
# Proxmox MCP (Python 3.11+; from repo root)
cd .. && ./scripts/setup-mcp-servers.sh proxmox-mcp-plus && cd mcp-servers
# Fetch Browser (TypeScript)
git clone https://github.com/TheSethRose/Fetch-Browser.git fetch-browser
git clone https://github.com/zcaceres/fetch-mcp.git fetch-browser
# or https://github.com/ChromeDevTools/chrome-devtools-mcp.git
cd fetch-browser && npm install && npm run build && cd ..
```
@ -424,19 +422,53 @@ To update a server: `cd mcp-servers/<name> && git pull && <rebuild>`. Pin to a k
### Per-agent MCP wiring
Since each agent is a separate Docker container, MCP servers are configured in each agent's own `config.json`. An agent only gets the MCP servers listed in its config -- no routing needed.
Since each agent is a separate Docker container, MCP servers are configured in each agent's own `config.json`. **`@ilia` should use `tools.toolProfiles`** so each heavy MCP is exposed alone (required for many local LLMs). See [`mcp-servers/README.md`](../mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus).
**`~/.nanobot-user1/config.json`** (@ilia — all MCP servers):
**`~/.nanobot-user1/config.json`** (@iliaregisters all MCPs; **one profile active per message**):
```jsonc
{
"tools": {
"defaultToolProfile": "default",
"toolRouting": { "enabled": true },
"mcpServers": {
"gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] },
"gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": [], "env": { "GITEA_URL": "http://10.0.30.169:3000", "GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN" } },
"gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": ["-t", "stdio", "--host", "http://10.0.30.169:3000"], "env": { "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" } },
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
"proxmox": { "command": "python", "args": ["-m", "mcp_proxmox"], "env": { "PROXMOX_HOST": "https://10.0.30.1:8006", "PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token", "PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET", "PROXMOX_VERIFY_SSL": "false" } },
"proxmox": {
"command": "/mnt/data/nanobot/venv/bin/python",
"args": ["-m", "proxmox_mcp.server"],
"env": {
"PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json"
}
},
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
},
"toolProfiles": {
"default": {
"description": "Builtin tools only; no MCP servers.",
"mcpServers": []
},
"gmail_mcp": {
"description": "Gmail MCP: inbox search, read, send.",
"mcpServers": ["gmail_mcp"]
},
"gitea": {
"description": "Gitea: PRs, issues, repos.",
"mcpServers": ["gitea"]
},
"calendar": {
"description": "Google Calendar MCP: events, free/busy.",
"mcpServers": ["google_calendar"]
},
"proxmox": {
"description": "Proxmox VE: VMs, LXC, cluster, snapshots, backups.",
"mcpServers": ["proxmox"]
},
"web_scraper": {
"description": "Headless fetch / scrape MCP.",
"mcpServers": ["web_scraper"]
}
}
}
}
@ -527,7 +559,7 @@ Read-only. No confirmation needed.
All three containers (`nanobot-user1`, `nanobot-user2`, `nanobot-user3`) share the same Docker image. MCP server processes are spawned inside each container as needed. The Dockerfile must include:
- **Go** (for Gitea MCP binary — or copy pre-built binary)
- **Node.js 18+** (for Calendar MCP and Fetch Browser)
- **Python pip deps** (for Proxmox MCP — install into the same venv or a sidecar)
- **Python 3.11+** (image or host venv) with `proxmox-mcp-plus` installed editable from `mcp-servers/proxmox-mcp-plus/` — same venv as nanobot, no nested `.venv` under the clone
- **Chromium** (for Fetch Browser headless rendering — `npx puppeteer browsers install chrome` or use Playwright)
The `mcp-servers/` directory is mounted read-only into all containers so each agent can spawn the MCP servers listed in its config. Alternatively, build MCP binaries in a multi-stage Docker build and copy only the artifacts into the image.
@ -571,3 +603,4 @@ Week 6: Retrospective, update this document, plan Phase 2 write-skills
|---|---|
| 2026-03-30 | Updated to reflect multi-container workspace architecture (Option B). Added `@wife` as third agent. Rewrote per-agent MCP wiring with separate config.json per container. Updated skill assignments across all three agents. |
| 2026-03-30 | Initial version — shortlist (4 MCP), backlog (16 ideas), skill catalog (16 skills), Phase 1 defined (3 MCP + 5 skills) |
| 2026-04-01 | Proxmox: switched shortlist to [ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus); setup via `scripts/setup-mcp-servers.sh proxmox-mcp-plus`; per-MCP `toolProfiles` documented in [`mcp-servers/README.md`](../mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus). |

View File

@ -32,3 +32,112 @@ Then configure nanobot (example):
}
```
## Proxmox MCP (ProxmoxMCP-Plus)
[ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus.git) integrates Proxmox VE over MCP stdio. It registers **many** tools; use nanobot **`tools.toolProfiles`** so only **one MCP server** (plus builtins) is connected per turn, and **`tools.toolRouting.enabled`** so the right profile is chosen from the user message (keyword fast-paths for Proxmox and Gitea in `nanobot/agent/tool_routing.py`, then a small router LLM call).
### Prerequisites
- Python **3.11+** where the server runs.
- Proxmox API token (Datacenter → Permissions → API Tokens). Scope roles to what you accept (`PVEAuditor` for read-only).
### Install (local clone)
From the nanobot repo root (e.g. `/mnt/data/nanobot`):
```bash
# optional: source venv/bin/activate — setup script prefers ./venv/bin/python when present
./scripts/setup-mcp-servers.sh proxmox-mcp-plus
```
This clones into `mcp-servers/proxmox-mcp-plus/` and runs `pip install -e` using **`venv/bin/python`** in the repo if it exists, otherwise `python3`. Override with `NANOBOT_PYTHON=/path/to/python` if needed. No extra `.venv` under the clone.
It also seeds `proxmox-config/config.json` from `config.example.json` if missing.
Edit **`mcp-servers/proxmox-mcp-plus/proxmox-config/config.json`**: set `proxmox.host`, `auth.user`, `auth.token_name`, `auth.token_value`. Keep `mcp.transport` **`STDIO`** for nanobot. Do not commit secrets (the tree under `mcp-servers/` is gitignored except this file).
### Register in nanobot `config.json`
Use the **same Python** that ran `pip install -e` (absolute path avoids `PATH` mismatches in Docker/systemd).
Example for this checkout (`venv` at repo root — adjust if your clone lives elsewhere):
```jsonc
{
"tools": {
"mcpServers": {
"proxmox": {
"command": "/mnt/data/nanobot/venv/bin/python",
"args": ["-m", "proxmox_mcp.server"],
"env": {
"PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json"
}
}
}
}
}
```
(`pip install -e` registers `proxmox_mcp`; you do not need `PYTHONPATH` for normal use.)
Tools appear as `mcp_proxmox_<tool>` (normalized from upstream names).
### One profile per MCP (local LLMs)
Add **`tools.toolProfiles`**: each profiles `mcpServers` lists **only** the server keys to connect for that turn. A **`default`** profile with **`"mcpServers": []`** keeps ordinary chat free of huge MCP tool lists.
Example (trim or extend profiles to match your `mcpServers` keys):
```jsonc
{
"tools": {
"defaultToolProfile": "default",
"toolRouting": { "enabled": true },
"mcpServers": {
"gmail_mcp": {
"command": "npx",
"args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"]
},
"gitea": {
"command": "/mnt/data/nanobot/mcp-servers/gitea-mcp/gitea-mcp",
"args": ["-t", "stdio", "--host", "https://gitea.example.com"],
"env": { "GITEA_ACCESS_TOKEN": "$GITEA_TOKEN" }
},
"proxmox": {
"command": "/mnt/data/nanobot/venv/bin/python",
"args": ["-m", "proxmox_mcp.server"],
"env": {
"PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json"
}
}
},
"toolProfiles": {
"default": {
"description": "Built-in tools only (filesystem, shell, web, cron, etc.). No MCP servers.",
"mcpServers": []
},
"gmail_mcp": {
"description": "Gmail: read/search/send mail via Gmail MCP.",
"mcpServers": ["gmail_mcp"]
},
"gitea": {
"description": "Gitea: pull requests, issues, repos, code search.",
"mcpServers": ["gitea"]
},
"proxmox": {
"description": "Proxmox VE: nodes, VMs, LXC, storage, snapshots, backups, cluster status.",
"mcpServers": ["proxmox"]
}
}
}
}
```
Profile keys **`proxmox`** and **`gitea`** are matched by router heuristics. For Gmail, use profile key **`gmail_mcp`** if that matches your server name, or rely on the router model and each profiles `description`.
### Verify
1. Start nanobot with the edited config.
2. Ask something Proxmox-specific (e.g. “list VMs on Proxmox”) and check logs for `Tool profile 'proxmox': … tools exposed`.
3. Confirm only `mcp_proxmox_*/tools` are available for that turn.

View File

@ -98,12 +98,28 @@ Some LLM backends may not support native function-calling. When you decide to us
2) Calendar shortcut (allowed only for the built-in `calendar` tool):
{{"action":"list_events", ...}}
After a tool result is returned, respond normally in plain text unless the user asks for another tool action.
If the request has multiple steps, keep working until all requested steps are done:
- After each tool result, decide whether another tool call is still needed.
- If yes, output the next JSON tool call (same format as above).
- Only switch to a normal plain-text answer when the task is complete or blocked.
Link references: If the user says "first/last/second link" or "from your last link" without pasting a URL, they mean a URL from **your immediately previous assistant message** (not a new web search). "Last link" = the **last** `https://` URL in that message, top to bottom. Call `web_fetch` on that URL do not substitute unrelated sites.
## Using this chat's history (session)
The API sends **prior messages in this conversation** before the latest user line: older user text, your past assistant replies, and **`role: tool` results** (directory listings, `read_file`/`web_fetch` output, etc.). This is **one ongoing thread**, not isolated questions.
- Resolve **follow-ups** (that, same, as before, the file you listed, the joke) from **earlier turns and tool outputs**, not only from the latest short message.
- Prefer **facts already established** in the thread (paths, decisions, quoted content) over starting fresh or asking the user to repeat themselvesunless something is genuinely missing.
- If the latest user message is vague but **earlier context or a tool result** makes the intent clear, act on that intent.
### MCP quick mappings (use these when the intent matches)
- If the user asks for **my Gitea user info** (who am I / my profile / my account): call `mcp_gitea_get_me` with `{{}}`.
- If the user asks for **Gitea MCP server version**: call `mcp_gitea_get_gitea_mcp_server_version` with `{{}}`.
- If the user asks to **list my repos**: call `mcp_gitea_list_my_repos` with pagination defaults.
- If the user asks to **list, show, count, or name the latest/recent/last issue(s)** for a repo **owner/repo** (e.g. `ilia/punimtag`): call **`mcp_gitea_list_issues`** with `owner`, `repo`, and optional `state` / pagination per the tool schema. Pick the most recently updated (or newest) issue from the returned list when they ask for "the last" or "latest" one.
- **Issues vs PRs:** Gitea's issue list API returns **pull requests in the same listing** as issues by default. If the user asked for **issues only** (not PRs), pass the tool/API filter for **issues only** if the schema has it (Gitea uses `type=issues`; the MCP may expose this as e.g. `type` → `issues`). If they want **only PRs**, use **`mcp_gitea_list_pull_requests`** instead. If you only called `list_issues` without filtering, **do not** tell them you listed "pull requests" as a separate step—either filter to issues-only in the tool params or say the response may include PRs mixed in.
- **Time windows** ("last 2 days", "since Monday"): compute **`since`** (or equivalent) from **## Current Time** above—ISO 8601 UTC or the user's timezone as appropriate. **Never** invent placeholder dates (e.g. random past years).
- **`mcp_gitea_search_issues` requires `query`** (non-empty search string). Use it only for **keyword / full-text search**. **Do not** call `mcp_gitea_search_issues` with only `owner` and `repo`that will fail. For browsing or "most recent issue" always use **`mcp_gitea_list_issues`**.
## Current Time
{now} ({tz})
@ -117,7 +133,7 @@ Your workspace is at: {workspace_path}
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. **`list_dir` takes one directory path**no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory.
**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. If you listed a **subfolder** (e.g. `list_dir` on `workspace` and saw `joke.txt`), then `read_file` must use **`workspace/joke.txt`**, not `joke.txt` alone. **`list_dir` takes one directory path**no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory.
**Answering after tools:** When a tool already returned what the user needs, base your reply **only on that tool output**same topic as the users question, no hijacking.
- After **`list_dir`:** If they asked for PDFs (or another extension), list **only** matching names (paths under `{workspace_path}` if useful). If none, say so briefly. No essays, no calling the folder "code" unless they asked for analysis.
@ -136,6 +152,8 @@ IMPORTANT: When responding to direct questions or conversations, reply directly
Only use the 'message' tool when the user explicitly asks you to send a message to someone else or to a different channel.
For normal conversation, acknowledgments (Thanks, OK, etc.), or when the user is talking to YOU, just respond with text - do NOT call the message tool.
**Accepting your own offer (critical):** If *your previous assistant message* asked a yes/no question or offered **one clear next step** (e.g. "Would you like me to read `workspace/joke.txt`?", "Should I search for that?"), and the user replies with a short affirmation ("yes", "sure", "yep", "ok", "please", "go ahead", "do it"), you MUST **carry out that step** using the right tool (e.g. `read_file` with the path you named). Do **not** say they were vague or need to specify a functionuse the path or action from **your** prior message.
For simple acknowledgments like "Thanks", "OK", "You're welcome", "Got it", etc., respond naturally and conversationally - just say "You're welcome!", "No problem!", "Happy to help!", etc. Do not explain your reasoning or mention tools. Just be friendly and brief.
Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
@ -185,6 +203,13 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
system_prompt = self.build_system_prompt(skill_names)
if channel and chat_id:
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
if history:
n = len(history)
system_prompt += (
f"\n\n## Session message count\n"
f"There are **{n} prior message(s)** in the messages array below (before the latest user input). "
f"They are part of **this same chat**—read and use them when answering."
)
messages.append({"role": "system", "content": system_prompt})
# History

View File

@ -1,6 +1,7 @@
"""Agent loop: the core processing engine."""
import asyncio
import copy
import json
import re
from contextlib import AsyncExitStack
@ -274,6 +275,151 @@ class AgentLoop:
return "\n".join(parts).strip()
return ""
@staticmethod
def _contains_relative_link_reference(text: str) -> bool:
"""
Detect phrases like "first link you gave me" when no URL is provided.
Local models often miss this reference resolution step, so we inject a
lightweight hint with recent URLs from assistant history.
"""
t = (text or "").lower()
if not t:
return False
# If user already supplied a URL, no hint needed.
if "http://" in t or "https://" in t or "www." in t:
return False
markers = [
"first link", "1st link", "second link", "2nd link", "third link", "3rd link",
"last link", "final link", "from your last", "your last link",
"that link", "the link above", "link above", "previous link",
"first result", "second result", "third result",
"last result", "your last result",
"the first one", "the second one", "the third one", "the last one",
]
return any(m in t for m in markers)
@staticmethod
def _ordered_urls_from_latest_assistant(
history: list[dict], max_messages: int = 24, max_urls: int = 40
) -> list[str]:
"""
URLs from the single most recent assistant message only, in document order.
"Last link" / "first link" refer to this message, not older turns or deduped
global lists local models otherwise pick unrelated URLs (e.g. joke APIs).
"""
url_pattern = re.compile(r"https?://[^\s)>\]\"']+")
for m in reversed(history[-max_messages:]):
if m.get("role") != "assistant":
continue
content = m.get("content")
if not isinstance(content, str) or not content:
continue
found = url_pattern.findall(content)
if not found:
continue
out: list[str] = []
seen: set[str] = set()
for u in found:
if u not in seen:
seen.add(u)
out.append(u)
if len(out) >= max_urls:
break
return out
return []
@staticmethod
def _extract_recent_assistant_urls(history: list[dict], max_messages: int = 12, max_urls: int = 10) -> list[str]:
"""Extract unique URLs from the most recent assistant messages."""
url_pattern = re.compile(r"https?://[^\s)>\]\"']+")
urls: list[str] = []
seen: set[str] = set()
for m in reversed(history[-max_messages:]):
if m.get("role") != "assistant":
continue
content = m.get("content")
if not isinstance(content, str) or not content:
continue
found = url_pattern.findall(content)
if not found:
continue
for u in found:
if u not in seen:
seen.add(u)
urls.append(u)
if len(urls) >= max_urls:
return urls
return urls
def _build_link_reference_hint(self, user_text: str, history: list[dict]) -> str | None:
"""Build an explicit URL mapping hint for relative link references."""
if not self._contains_relative_link_reference(user_text):
return None
# Prefer URLs from the latest assistant reply only ("your last link" = last URL there).
urls = self._ordered_urls_from_latest_assistant(history)
if not urls:
urls = self._extract_recent_assistant_urls(history)
if not urls:
return None
lines = [f"{idx + 1}. {url}" for idx, url in enumerate(urls)]
n = len(urls)
mapping = (
f'- "first link" / "first result" → URL #1.\n'
f'- "last link" / "from your last link" / "final link" → URL #{n}.\n'
f'- "second link" → URL #2 (if {n} >= 2).\n'
)
return (
"The user refers to a link from your **previous assistant message**. "
"Do not guess a URL (e.g. random joke sites). Use `web_fetch` on the URL below.\n"
f"{mapping}\n"
"URLs from that message (order = how they appeared):\n"
+ "\n".join(lines)
)
@staticmethod
def _append_turn_to_session(
session: Session,
user_content: str,
turn_tail: list[dict],
tools_used: list[str],
final_for_fallback: str | None = None,
) -> None:
"""Persist one user turn and the in-loop assistant/tool messages for the next LLM context."""
session.add_message("user", user_content)
if not turn_tail:
session.add_message(
"assistant",
final_for_fallback or "",
tools_used=tools_used if tools_used else None,
)
return
for i, m in enumerate(turn_tail):
role = m.get("role")
if role not in ("assistant", "tool"):
continue
content = m.get("content")
if content is None:
content = ""
kwargs = {
k: v for k, v in m.items() if k not in ("role", "content")
}
if role == "assistant" and i == len(turn_tail) - 1 and tools_used:
kwargs["tools_used"] = tools_used
session.add_message(role, content, **kwargs)
if (
final_for_fallback
and turn_tail
and turn_tail[-1].get("role") == "tool"
):
session.add_message(
"assistant",
final_for_fallback,
tools_used=tools_used if tools_used else None,
)
async def _pick_tool_profile(self, user_text: str) -> str:
"""Resolve profile key when tools.toolProfiles is configured."""
if not self._tool_profiles:
@ -305,8 +451,12 @@ class AgentLoop:
on_progress: Optional callback to push intermediate content to the user.
Returns:
Tuple of (final_content, list_of_tools_used).
Tuple of (final_content, list_of_tools_used, turn_messages).
``turn_messages`` are messages appended after ``initial_messages`` (assistant
tool rounds + tool results + final assistant) for session persistence so
follow-up turns see tool outputs, not only final chat text.
"""
n0 = len(initial_messages)
messages = initial_messages
iteration = 0
final_content = None
@ -368,10 +518,10 @@ class AgentLoop:
logger.debug(f"LLM provider returned response, has_tool_calls={response.has_tool_calls}")
except asyncio.TimeoutError:
logger.error("LLM provider call timed out after 120 seconds")
return "Error: Request timed out. The LLM provider may be slow or unresponsive.", tools_used
return "Error: Request timed out. The LLM provider may be slow or unresponsive.", tools_used, []
except Exception as e:
logger.error(f"LLM provider error: {e}")
return f"Error calling LLM: {str(e)}", tools_used
return f"Error calling LLM: {str(e)}", tools_used, []
if response.has_tool_calls:
if on_progress:
@ -437,12 +587,20 @@ class AgentLoop:
]
final_content = None
continue
# Record final assistant in-message (was missing); enables session turn_tail extraction.
messages = self.context.add_assistant_message(
messages,
final_content,
None,
reasoning_content=response.reasoning_content,
)
break
if final_content is None and iteration >= self.max_iterations:
logger.warning(f"Max iterations ({self.max_iterations}) reached without final response. Last tool calls: {tools_used[-3:] if len(tools_used) >= 3 else tools_used}")
return final_content, tools_used
turn_tail = copy.deepcopy(messages[n0:])
return final_content, tools_used, turn_tail
async def run(self) -> None:
"""Run the agent loop, processing messages from the bus."""
@ -548,9 +706,18 @@ class AgentLoop:
asyncio.create_task(_consolidate_with_timeout())
self._set_tool_context(msg.channel, msg.chat_id)
history = session.get_history(max_messages=self.memory_window)
hinted_message = msg.content
if link_hint := self._build_link_reference_hint(msg.content, history):
hinted_message = (
f"{msg.content}\n\n"
f"[Context hint for reference resolution]\n{link_hint}"
)
logger.debug("Injected link reference hint for follow-up resolution")
initial_messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
history=history,
current_message=hinted_message,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
@ -565,19 +732,24 @@ class AgentLoop:
metadata=msg.metadata or {},
))
final_content, tools_used = await self._run_agent_loop(
final_content, tools_used, turn_tail = await self._run_agent_loop(
initial_messages, on_progress=on_progress or _bus_progress,
)
outbound_fallback = final_content
if final_content is None:
final_content = "I've completed processing but have no response to give."
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
session.add_message("user", msg.content)
session.add_message("assistant", final_content,
tools_used=tools_used if tools_used else None)
self._append_turn_to_session(
session,
msg.content,
turn_tail,
tools_used,
final_for_fallback=outbound_fallback,
)
self.sessions.save(session)
return OutboundMessage(
@ -615,13 +787,20 @@ class AgentLoop:
channel=origin_channel,
chat_id=origin_chat_id,
)
final_content, _ = await self._run_agent_loop(initial_messages)
final_content, tools_used, turn_tail = await self._run_agent_loop(initial_messages)
sys_user = f"[System: {msg.sender_id}] {msg.content}"
outbound_fallback = final_content
if final_content is None:
final_content = "Background task completed."
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self._append_turn_to_session(
session,
sys_user,
turn_tail,
tools_used,
final_for_fallback=outbound_fallback,
)
self.sessions.save(session)
return OutboundMessage(

View File

@ -27,9 +27,36 @@ async def route_tool_profile(
if not profiles:
return default_profile
# Heuristic fast-path: if the request clearly needs a dev/forge MCP (PRs, issues, repos),
# prefer an MCP-enabled profile without spending an LLM call.
# Heuristic fast-paths: pick a narrow tool profile without spending an LLM call.
msg_l = (user_message or "").lower()
needs_proxmox = any(
k in msg_l
for k in [
"proxmox",
"pve ",
" pve",
"pve.",
" qemu",
"qm ",
"qmcreate",
" pct ",
"pct ",
" vzdump",
"lxc ",
" lxc",
"vmid",
"hypervisor",
"proxmox cluster",
"datacenter",
]
)
if needs_proxmox:
for candidate in ("proxmox", "proxmox_mcp", "proxmox-mcp", "pve"):
if candidate in profiles:
logger.info(f"Tool router selected profile '{candidate}' (proxmox heuristic)")
return candidate
needs_forge = any(
k in msg_l
for k in [
@ -53,15 +80,47 @@ async def route_tool_profile(
]
)
if needs_forge:
# Prefer an explicit "*mcp*" profile key if present, else any profile that enables MCP servers.
for key in profiles.keys():
if "mcp" in key.lower():
logger.info(f"Tool router selected profile '{key}' (heuristic)")
return key
for candidate in ("gitea", "gitea_mcp", "forge"):
if candidate in profiles:
logger.info(f"Tool router selected profile '{candidate}' (forge heuristic)")
return candidate
# Avoid picking an unrelated "*mcp*" profile (e.g. workspace-only MCP). Prefer
# profiles whose key, description, or mcpServers list looks forge-related.
forge_hints = (
"gitea",
"github",
"gitlab",
"forge",
"bitbucket",
"codeberg",
)
forge_phrases = (
"pull request",
"merge request",
"pull requests",
"merge requests",
)
scored: list[tuple[int, str]] = []
for key, p in profiles.items():
if p.mcp_servers is None or (isinstance(p.mcp_servers, list) and len(p.mcp_servers) > 0):
logger.info(f"Tool router selected profile '{key}' (heuristic)")
return key
key_l = key.lower()
desc_l = (p.description or "").lower()
blob_parts = [key_l, desc_l]
mcp = p.mcp_servers
if isinstance(mcp, list):
blob_parts.extend(s.lower() for s in mcp)
blob = " ".join(blob_parts)
score = sum(1 for h in forge_hints if h in blob)
if any(ph in desc_l for ph in forge_phrases):
score += 2
elif "issue" in desc_l or "issues" in desc_l or "repository" in desc_l:
score += 1
if score > 0:
scored.append((score, key))
if scored:
chosen = max(scored, key=lambda t: (t[0], t[1]))[1]
logger.info(f"Tool router selected profile '{chosen}' (forge-related heuristic)")
return chosen
# No forge-shaped profile: use LLM router below instead of arbitrary MCP profile.
lines = []
for name, p in profiles.items():

View File

@ -9,10 +9,26 @@ from nanobot.agent.tools.base import Tool
def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
"""Resolve path and optionally enforce directory restriction."""
resolved = Path(path).expanduser().resolve()
if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())):
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
"""Resolve path and optionally enforce directory restriction.
When ``allowed_dir`` is set, **relative** paths are resolved under that root
(not under the process CWD). Otherwise ``list_dir({"path": "workspace"})``
in Docker resolves to e.g. ``/app/workspace`` and incorrectly fails as
"outside allowed directory /workspace".
"""
p = Path(path).expanduser()
if allowed_dir:
root = allowed_dir.resolve()
if p.is_absolute():
resolved = p.resolve()
else:
resolved = (root / p).resolve()
if not resolved.is_relative_to(root):
raise PermissionError(
f"Path {path} is outside allowed directory {allowed_dir}"
)
else:
resolved = p.resolve()
return resolved

View File

@ -196,8 +196,6 @@ class WebFetchTool(Tool):
self.max_chars = max_chars
async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str:
from readability import Document
max_chars = maxChars or self.max_chars
# Validate URL before fetching
@ -206,6 +204,8 @@ class WebFetchTool(Tool):
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url})
try:
from readability import Document
async with httpx.AsyncClient(
follow_redirects=True,
max_redirects=MAX_REDIRECTS,

View File

@ -82,14 +82,98 @@ class CustomProvider(LLMProvider):
"email",
]
# Check for standard format: {"name": "...", "parameters": {...}}
has_standard_format = '"name"' in content and '"parameters"' in content
has_standard_format = '"name"' in content and (
'"parameters"' in content or '"arguments"' in content or '"params"' in content
)
# Check for calendar tool format: {"action": "...", ...}
has_calendar_format = '"action"' in content and ("calendar" in content.lower() or any(action in content for action in ["list_events", "create_event", "update_event", "delete_event"]))
has_calendar_format = '"action"' in content and (
"calendar" in content.lower()
or any(action in content for action in [
"list_events", "create_event", "update_event",
"delete_event", "delete_events", "check_availability"
])
)
# Some backends will return *only* a JSON object as the entire message content.
# If it looks like a JSON object, attempt parsing even if our heuristics missed it.
looks_like_json_object = stripped.startswith("{") and stripped.endswith("}")
def _normalize_parameters(tool_obj: dict[str, Any]) -> dict[str, Any]:
params = (
tool_obj.get("parameters")
if "parameters" in tool_obj
else tool_obj.get("arguments")
if "arguments" in tool_obj
else tool_obj.get("params")
if "params" in tool_obj
else tool_obj.get("input")
if "input" in tool_obj
else {}
)
if isinstance(params, dict):
return params
return {"raw": str(params)}
def _append_tool_call_from_obj(tool_obj: Any) -> bool:
if not isinstance(tool_obj, dict):
return False
# Calendar shortcut format: {"action":"list_events", ...}
if "action" in tool_obj:
action = tool_obj.get("action")
if action and action in [
"list_events", "create_event", "update_event",
"delete_event", "delete_events", "check_availability"
]:
tool_calls.append(ToolCallRequest(
id=f"call_{len(tool_calls)}",
name="calendar",
arguments=tool_obj,
))
return True
# Standard format: {"name":"tool","parameters":{...}}
if "name" in tool_obj and isinstance(tool_obj["name"], str):
tool_name = tool_obj["name"]
if tool_name in valid_tools or tool_name.startswith("mcp_"):
tool_calls.append(ToolCallRequest(
id=f"call_{len(tool_calls)}",
name=tool_name,
arguments=_normalize_parameters(tool_obj),
))
return True
return False
# Fast path: parse the entire content as JSON when possible.
# This enables multi-tool payloads like:
# [{"name":"web_search",...},{"name":"web_fetch",...}]
if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object):
try:
payload = json_repair.loads(stripped)
parsed_any = False
if isinstance(payload, list):
for item in payload:
parsed_any = _append_tool_call_from_obj(item) or parsed_any
elif isinstance(payload, dict):
# OpenAI-like wrappers: {"tool_calls":[...]}
if isinstance(payload.get("tool_calls"), list):
for item in payload["tool_calls"]:
if isinstance(item, dict) and isinstance(item.get("function"), dict):
fn = item["function"]
parsed_any = _append_tool_call_from_obj({
"name": fn.get("name"),
"arguments": fn.get("arguments", {}),
}) or parsed_any
else:
parsed_any = _append_tool_call_from_obj(item) or parsed_any
else:
parsed_any = _append_tool_call_from_obj(payload) or parsed_any
if parsed_any:
content = ""
except Exception:
pass
if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object):
import re
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...}
@ -137,33 +221,7 @@ class CustomProvider(LLMProvider):
json_str = content[json_start:json_end]
tool_obj = json_repair.loads(json_str)
# Handle calendar tool format: {"action": "...", ...}
if isinstance(tool_obj, dict) and "action" in tool_obj:
# This is a calendar tool call in JSON format
action = tool_obj.get("action")
if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]:
# Convert to calendar tool call format
tool_calls.append(ToolCallRequest(
id=f"call_{len(tool_calls)}",
name="calendar",
arguments=tool_obj # Pass the whole object as arguments
))
# Remove the tool call from content
content = content[:json_start] + content[json_end:].strip()
start_pos = json_start # Stay at same position since we removed text
continue
# Handle standard format: {"name": "...", "parameters": {...}}
if (isinstance(tool_obj, dict) and
"name" in tool_obj and
"parameters" in tool_obj and
isinstance(tool_obj["name"], str) and
(tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_"))):
tool_calls.append(ToolCallRequest(
id=f"call_{len(tool_calls)}",
name=tool_obj["name"],
arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])}
))
if _append_tool_call_from_obj(tool_obj):
# Remove the tool call from content
content = content[:json_start] + content[json_end:].strip()
start_pos = json_start # Stay at same position since we removed text
@ -177,24 +235,7 @@ class CustomProvider(LLMProvider):
if not tool_calls and looks_like_json_object:
try:
tool_obj = json_repair.loads(stripped)
if isinstance(tool_obj, dict) and "action" in tool_obj:
action = tool_obj.get("action")
if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]:
tool_calls.append(ToolCallRequest(
id="call_0",
name="calendar",
arguments=tool_obj,
))
content = ""
if isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj:
if isinstance(tool_obj["name"], str) and (
tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_")
):
tool_calls.append(ToolCallRequest(
id="call_0",
name=tool_obj["name"],
arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])},
))
if _append_tool_call_from_obj(tool_obj):
content = ""
except Exception:
pass

View File

@ -45,8 +45,13 @@ class Session:
"""Get recent messages in LLM format, preserving tool metadata."""
out: list[dict[str, Any]] = []
for m in self.messages[-max_messages:]:
entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")}
for k in ("tool_calls", "tool_call_id", "name"):
role = m["role"]
entry: dict[str, Any] = {
"role": role,
"content": m.get("content") if m.get("content") is not None else "",
}
# Omit tools_used: session metadata for humans/logs, not for the LLM API.
for k in ("tool_calls", "tool_call_id", "name", "reasoning_content"):
if k in m:
entry[k] = m[k]
out.append(entry)

View File

@ -28,6 +28,7 @@ dependencies = [
"oauth-cli-kit>=0.1.1",
"loguru>=0.7.0",
"readability-lxml>=0.8.0",
"lxml-html-clean>=0.4.0",
"rich>=13.0.0",
"croniter>=2.0.0",
"dingtalk-stream>=0.4.0",

View File

@ -9,11 +9,16 @@ MCP_DIR="${REPO_ROOT}/mcp-servers"
usage() {
cat <<'EOF'
usage:
./scripts/setup-mcp-servers.sh gitea
./scripts/setup-mcp-servers.sh <target>
targets:
gitea Clone and build gitea-mcp (Go)
proxmox-mcp-plus Clone ProxmoxMCP-Plus and pip install -e (prefers ./venv/bin/python)
notes:
- clones into ./mcp-servers/<name>
- builds artifacts needed to run the MCP server locally
- for proxmox-mcp-plus: uses REPO/venv/bin/python if it exists, else python3 (or set NANOBOT_PYTHON)
- no second .venv under the Proxmox clone
EOF
}
@ -92,6 +97,69 @@ EOF
echo "done: built ${MCP_DIR}/gitea-mcp/gitea-mcp"
}
need_python_min() {
local want_major="$1"
local want_minor="$2"
local v
v="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)"
local major minor
major="$(echo "${v}" | cut -d. -f1)"
minor="$(echo "${v}" | cut -d. -f2)"
if [[ -z "${major}" || -z "${minor}" ]]; then
echo "error: could not read Python 3 version" >&2
return 1
fi
if (( major < want_major )) || { (( major == want_major )) && (( minor < want_minor )); }; then
echo "error: Python ${want_major}.${want_minor}+ required; found ${v}" >&2
return 1
fi
return 0
}
setup_proxmox_mcp_plus() {
need_cmd git
need_cmd python3
if ! need_python_min 3 11; then
cat <<'EOF' >&2
proxmox-mcp-plus requires Python 3.11 or newer (see upstream pyproject requires-python).
EOF
exit 2
fi
mkdir -p "${MCP_DIR}"
if [[ ! -d "${MCP_DIR}/proxmox-mcp-plus/.git" ]]; then
git clone https://github.com/RekklesNA/ProxmoxMCP-Plus.git "${MCP_DIR}/proxmox-mcp-plus"
else
echo "info: proxmox-mcp-plus already cloned, skipping clone"
fi
if [[ -n "${NANOBOT_PYTHON:-}" ]]; then
py="${NANOBOT_PYTHON}"
elif [[ -x "${REPO_ROOT}/venv/bin/python" ]]; then
py="${REPO_ROOT}/venv/bin/python"
else
py="python3"
fi
echo "info: installing ProxmoxMCP-Plus with: ${py}"
if [[ -n "${VIRTUAL_ENV:-}" ]]; then
echo "info: active VIRTUAL_ENV=${VIRTUAL_ENV} (interpreter should match ${py} for consistency)"
fi
"${py}" -m pip install -e "${MCP_DIR}/proxmox-mcp-plus"
if [[ ! -f "${MCP_DIR}/proxmox-mcp-plus/proxmox-config/config.json" ]]; then
cp "${MCP_DIR}/proxmox-mcp-plus/proxmox-config/config.example.json" \
"${MCP_DIR}/proxmox-mcp-plus/proxmox-config/config.json"
echo "warn: created proxmox-config/config.json from example; edit host and API token before use"
fi
echo "done: ProxmoxMCP-Plus installed editable with: ${py} -m pip install -e ..."
echo "info: point nanobot mcpServers.proxmox command at this interpreter (e.g. $(command -v "${py}" 2>/dev/null || echo "${py}"))"
}
main() {
if [[ "${#}" -ne 1 ]]; then
usage
@ -100,6 +168,7 @@ main() {
case "$1" in
gitea) setup_gitea ;;
proxmox-mcp-plus) setup_proxmox_mcp_plus ;;
-h|--help|help) usage ;;
*)
echo "error: unknown target '$1'" >&2

View File

@ -1,23 +1 @@
# Long-term Memory
This file stores important information that should persist across sessions.
## User Information
(Important facts about the user)
## Preferences
(User preferences learned over time)
## Project Context
(Information about ongoing projects)
## Important Notes
(Things to remember)
---
*This file is automatically updated by nanobot when important information should be remembered.*
{'User Information': {'agent_type': 'memory consolidation', 'response_format': 'valid JSON'}, 'Preferences': {'response_type': 'JSON', 'context': 'conversation to process'}, 'Location': {'workspace_location': '/mnt/data/nanobot/workspace/'}, 'Project Context': {'project_name': 'Nanobot Configuration Update', 'goal': 'Resolve directory permission issue with Nanobot'}, 'Tools/Services Used': ['list_dir', 'Gitea API', 'read_emails'], 'Code Snippets': {'update_HEARTBEAT_md': 'code snippet to update HEARTBEAT.md file with new task'}}