Compare commits
1 Commits
docs/mcp-l
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
| b4a0427494 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,8 +5,6 @@
|
||||
dist/
|
||||
build/
|
||||
docs/
|
||||
!docs/*.md
|
||||
!docs/**/*.md
|
||||
*.egg-info/
|
||||
*.egg
|
||||
*.pyc
|
||||
|
||||
@ -95,16 +95,6 @@ nanobot/
|
||||
- Environment variables (from Docker env files)
|
||||
- Config file: `/root/.nanobot/config.json` (mounted from host)
|
||||
|
||||
### MCP servers + tool profiles (local LLM note)
|
||||
|
||||
If you’re using a local LLM provider with a **low tool limit** (often ~20 tools), MCP servers (which can register 30+ tools) can exceed the limit unless you use tool profiles carefully.
|
||||
|
||||
See `docs/mcp_local_clone_and_tool_profiles.md` for:
|
||||
- Local-clone MCP layout (`./mcp-servers` → `/app/mcp-servers`)
|
||||
- How MCP env vars like `$NANOBOT_GITLE_TOKEN` are expanded into server env
|
||||
- Tool-call JSON protocol for local providers
|
||||
- Profile routing behavior and when MCP servers disconnect
|
||||
|
||||
---
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
@ -744,8 +744,6 @@ 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
|
||||
@ -768,7 +766,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 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.
|
||||
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
│ ├── proxmox-mcp-plus/ # git clone from github.com/RekklesNA/ProxmoxMCP-Plus
|
||||
│ ├── mcp-proxmox/ # git clone from github.com/antonio-mello-ai/mcp-proxmox
|
||||
│ └── 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** | [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 |
|
||||
| **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 |
|
||||
| **New capability** | Homelab infrastructure visibility and management from chat |
|
||||
| **Target agents** | `@ilia` only (infrastructure admin; never exposed to `@family` or `@wife`) |
|
||||
|
||||
@ -211,24 +211,26 @@ These are the 4 MCP servers we plan to integrate in the immediate next phase. Ea
|
||||
|
||||
#### Technical notes
|
||||
|
||||
- **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/`
|
||||
- **Build**: `pip install -e ./mcp-servers/mcp-proxmox/` into nanobot's venv, or use a dedicated venv.
|
||||
- **Local clone path**: `mcp-servers/mcp-proxmox/`
|
||||
- **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. Copy `proxmox-config/config.example.json` to `config.json` and set host + token fields.
|
||||
3. Store token secret in `~/.nanobot/config.json` env or in a `.env` file.
|
||||
- **Config entry**:
|
||||
```jsonc
|
||||
"proxmox": {
|
||||
"command": "/mnt/data/nanobot/venv/bin/python",
|
||||
"args": ["-m", "proxmox_mcp.server"],
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_proxmox"],
|
||||
"env": {
|
||||
"PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **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).
|
||||
- **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`
|
||||
- **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`.
|
||||
|
||||
---
|
||||
@ -409,12 +411,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 3.11+; from repo root)
|
||||
cd .. && ./scripts/setup-mcp-servers.sh proxmox-mcp-plus && cd mcp-servers
|
||||
# Proxmox MCP (Python)
|
||||
git clone https://github.com/antonio-mello-ai/mcp-proxmox.git
|
||||
cd mcp-proxmox && pip install -e . && cd ..
|
||||
|
||||
# Fetch Browser (TypeScript)
|
||||
git clone https://github.com/zcaceres/fetch-mcp.git fetch-browser
|
||||
# or https://github.com/ChromeDevTools/chrome-devtools-mcp.git
|
||||
git clone https://github.com/TheSethRose/Fetch-Browser.git fetch-browser
|
||||
cd fetch-browser && npm install && npm run build && cd ..
|
||||
```
|
||||
|
||||
@ -422,53 +424,19 @@ 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`. **`@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).
|
||||
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.
|
||||
|
||||
**`~/.nanobot-user1/config.json`** (@ilia — registers all MCPs; **one profile active per message**):
|
||||
**`~/.nanobot-user1/config.json`** (@ilia — all MCP servers):
|
||||
|
||||
```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": ["-t", "stdio", "--host", "http://10.0.30.169:3000"], "env": { "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" } },
|
||||
"gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": [], "env": { "GITEA_URL": "http://10.0.30.169:3000", "GITEA_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": "/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"
|
||||
}
|
||||
},
|
||||
"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" } },
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -559,7 +527,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 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
|
||||
- **Python pip deps** (for Proxmox MCP — install into the same venv or a sidecar)
|
||||
- **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.
|
||||
@ -603,4 +571,3 @@ 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). |
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
# MCP local clones + tool profiles (local LLM friendly)
|
||||
|
||||
This documents the MCP/tool-profile work done to make MCP servers usable with local LLM providers that have **low tool limits** (e.g. ~20 tools) and/or unreliable native function-calling.
|
||||
|
||||
## What we changed
|
||||
|
||||
- **Local-clone policy for MCP servers**
|
||||
- MCP servers are cloned/built locally under `./mcp-servers/` (not installed from npm/PyPI at runtime).
|
||||
- Repo gitignore keeps the clones out of git while keeping `mcp-servers/README.md` and `.gitkeep`.
|
||||
- Added `scripts/setup-mcp-servers.sh` to clone/build the Gitea MCP server (`mcp-servers/gitea-mcp/`) into a runnable binary.
|
||||
|
||||
- **Docker mounts for local MCP servers**
|
||||
- Multi-bot compose files mount `./mcp-servers` into containers at `/app/mcp-servers:ro`.
|
||||
- This makes config entries like `/app/mcp-servers/gitea-mcp/gitea-mcp` work inside the container.
|
||||
|
||||
- **MCP env var expansion**
|
||||
- `nanobot/agent/tools/mcp.py` expands `$VARS` in MCP server `env` using the container environment, so configs can safely reference secrets without duplicating them:
|
||||
- Example: `"GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN"`
|
||||
|
||||
- **Local-provider tool calling reliability**
|
||||
- `nanobot/agent/context.py` now includes a strict JSON tool-call protocol for providers that don’t do native function calling.
|
||||
- `nanobot/providers/custom_provider.py` was improved to parse tool calls when the model returns:
|
||||
- Embedded JSON tool calls in content, or
|
||||
- A message that is *only* a JSON object (`{"name": "...", "parameters": {...}}`)
|
||||
|
||||
- **Tool profile routing improvements**
|
||||
- `nanobot/agent/tool_routing.py` includes a heuristic fast-path to pick an MCP-capable profile (e.g. `workspace_mcp`) when the user intent includes PRs/issues/repos/Gitea terms.
|
||||
- The LLM router still applies for general cases; this heuristic prevents obvious “forge” intents from being routed to a no-MCP profile.
|
||||
|
||||
- **LLM empty-response retry**
|
||||
- `nanobot/agent/loop.py` retries once if the provider returns an empty final message, nudging the model to either call a tool or respond with text.
|
||||
|
||||
## Where MCP servers live (host vs container)
|
||||
|
||||
- **Host repo path**: `./mcp-servers/`
|
||||
- Example: `./mcp-servers/gitea-mcp/`
|
||||
- **Inside the nanobot container**: `/app/mcp-servers/` (mounted read-only)
|
||||
- Example: `/app/mcp-servers/gitea-mcp/gitea-mcp`
|
||||
|
||||
## Minimal Gitea MCP setup
|
||||
|
||||
1. Build the local server (host):
|
||||
|
||||
```bash
|
||||
./scripts/setup-mcp-servers.sh gitea
|
||||
```
|
||||
|
||||
2. Add to per-bot config (host) `~/.nanobot-user1/config.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"command": "/app/mcp-servers/gitea-mcp/gitea-mcp",
|
||||
"args": ["-t", "stdio", "--host", "http://10.0.30.169:3000", "-r"],
|
||||
"env": {
|
||||
"GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Ensure your compose file mounts the local MCP servers directory:
|
||||
- `./mcp-servers:/app/mcp-servers:ro`
|
||||
|
||||
## Tool profiles: what they do (and what they don’t)
|
||||
|
||||
- **Built-in tools** are registered once at gateway startup and do not “shut down”; profiles only control **exposure to the LLM per turn**.
|
||||
- **MCP tools/servers** are connected lazily and can be **connected/disconnected per turn** based on the selected profile.
|
||||
|
||||
### When MCP servers disconnect
|
||||
|
||||
- **On a later message**, if a new profile is selected whose `mcpServers` set does not include a previously-connected server, nanobot disconnects that MCP server.
|
||||
- On **gateway shutdown**, nanobot disconnects all MCP servers.
|
||||
- `/new` **does not** disconnect MCP servers; it only clears session history and triggers memory consolidation.
|
||||
|
||||
## Common gotchas
|
||||
|
||||
- **Router picked `workspace` (no MCP)**
|
||||
- If the router selects a profile with `"mcpServers": []`, MCP tools are hidden even if the servers are configured.
|
||||
- Either improve routing, or (if acceptable) allowlist the specific MCP server in that profile (`"mcpServers": ["gitea"]`).
|
||||
|
||||
- **Tool count limits on local models**
|
||||
- A single MCP server can register 30+ tools (Gitea MCP: ~30), which can exceed local providers’ tool limits.
|
||||
- If you must stay under ~20 tools, prefer:
|
||||
- Narrow profiles, or
|
||||
- A “dispatcher tool” approach (one tool per MCP server) instead of registering every MCP tool individually.
|
||||
|
||||
- **Repo owner/repo mismatch**
|
||||
- Errors like `GetUserByName` commonly mean the `owner` string doesn’t exist or the token cannot see it.
|
||||
- Resolve the canonical `owner/repo` first (search/list repos), then call list PRs with the correct pair.
|
||||
|
||||
@ -32,112 +32,3 @@ 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 profile’s `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 profile’s `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.
|
||||
|
||||
|
||||
@ -98,28 +98,12 @@ 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", ...}}
|
||||
|
||||
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 themselves—unless 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.
|
||||
After a tool result is returned, respond normally in plain text unless the user asks for another tool action.
|
||||
|
||||
### 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})
|
||||
@ -133,7 +117,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. 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.
|
||||
**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.
|
||||
|
||||
**Answering after tools:** When a tool already returned what the user needs, base your reply **only on that tool output**—same topic as the user’s 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.
|
||||
@ -152,8 +136,6 @@ 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 function—use 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).
|
||||
@ -203,13 +185,6 @@ 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
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"""Agent loop: the core processing engine."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from contextlib import AsyncExitStack
|
||||
@ -275,151 +274,6 @@ 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:
|
||||
@ -451,12 +305,8 @@ class AgentLoop:
|
||||
on_progress: Optional callback to push intermediate content to the user.
|
||||
|
||||
Returns:
|
||||
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.
|
||||
Tuple of (final_content, list_of_tools_used).
|
||||
"""
|
||||
n0 = len(initial_messages)
|
||||
messages = initial_messages
|
||||
iteration = 0
|
||||
final_content = None
|
||||
@ -518,10 +368,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:
|
||||
@ -587,20 +437,12 @@ 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}")
|
||||
|
||||
turn_tail = copy.deepcopy(messages[n0:])
|
||||
return final_content, tools_used, turn_tail
|
||||
return final_content, tools_used
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run the agent loop, processing messages from the bus."""
|
||||
@ -706,18 +548,9 @@ 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=history,
|
||||
current_message=hinted_message,
|
||||
history=session.get_history(max_messages=self.memory_window),
|
||||
current_message=msg.content,
|
||||
media=msg.media if msg.media else None,
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
@ -732,24 +565,19 @@ class AgentLoop:
|
||||
metadata=msg.metadata or {},
|
||||
))
|
||||
|
||||
final_content, tools_used, turn_tail = await self._run_agent_loop(
|
||||
final_content, tools_used = 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}")
|
||||
|
||||
self._append_turn_to_session(
|
||||
session,
|
||||
msg.content,
|
||||
turn_tail,
|
||||
tools_used,
|
||||
final_for_fallback=outbound_fallback,
|
||||
)
|
||||
session.add_message("user", msg.content)
|
||||
session.add_message("assistant", final_content,
|
||||
tools_used=tools_used if tools_used else None)
|
||||
self.sessions.save(session)
|
||||
|
||||
return OutboundMessage(
|
||||
@ -787,20 +615,13 @@ class AgentLoop:
|
||||
channel=origin_channel,
|
||||
chat_id=origin_chat_id,
|
||||
)
|
||||
final_content, tools_used, turn_tail = await self._run_agent_loop(initial_messages)
|
||||
final_content, _ = 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."
|
||||
|
||||
self._append_turn_to_session(
|
||||
session,
|
||||
sys_user,
|
||||
turn_tail,
|
||||
tools_used,
|
||||
final_for_fallback=outbound_fallback,
|
||||
)
|
||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
||||
session.add_message("assistant", final_content)
|
||||
self.sessions.save(session)
|
||||
|
||||
return OutboundMessage(
|
||||
|
||||
@ -27,36 +27,9 @@ async def route_tool_profile(
|
||||
if not profiles:
|
||||
return default_profile
|
||||
|
||||
# Heuristic fast-paths: pick a narrow tool profile without spending an LLM call.
|
||||
# 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.
|
||||
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 [
|
||||
@ -80,47 +53,15 @@ async def route_tool_profile(
|
||||
]
|
||||
)
|
||||
if needs_forge:
|
||||
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]] = []
|
||||
# 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 key, p in profiles.items():
|
||||
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.
|
||||
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
|
||||
|
||||
lines = []
|
||||
for name, p in profiles.items():
|
||||
|
||||
@ -9,26 +9,10 @@ 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.
|
||||
|
||||
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()
|
||||
"""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}")
|
||||
return resolved
|
||||
|
||||
|
||||
|
||||
@ -196,6 +196,8 @@ 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
|
||||
@ -204,8 +206,6 @@ 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,
|
||||
|
||||
@ -82,98 +82,14 @@ class CustomProvider(LLMProvider):
|
||||
"email",
|
||||
]
|
||||
# Check for standard format: {"name": "...", "parameters": {...}}
|
||||
has_standard_format = '"name"' in content and (
|
||||
'"parameters"' in content or '"arguments"' in content or '"params"' in content
|
||||
)
|
||||
has_standard_format = '"name"' in content and '"parameters"' 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", "delete_events", "check_availability"
|
||||
])
|
||||
)
|
||||
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"]))
|
||||
|
||||
# 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", ...}
|
||||
@ -221,7 +137,33 @@ class CustomProvider(LLMProvider):
|
||||
json_str = content[json_start:json_end]
|
||||
tool_obj = json_repair.loads(json_str)
|
||||
|
||||
if _append_tool_call_from_obj(tool_obj):
|
||||
# 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"])}
|
||||
))
|
||||
# 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
|
||||
@ -235,8 +177,25 @@ class CustomProvider(LLMProvider):
|
||||
if not tool_calls and looks_like_json_object:
|
||||
try:
|
||||
tool_obj = json_repair.loads(stripped)
|
||||
if _append_tool_call_from_obj(tool_obj):
|
||||
content = ""
|
||||
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"])},
|
||||
))
|
||||
content = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@ -45,13 +45,8 @@ class Session:
|
||||
"""Get recent messages in LLM format, preserving tool metadata."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in self.messages[-max_messages:]:
|
||||
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"):
|
||||
entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")}
|
||||
for k in ("tool_calls", "tool_call_id", "name"):
|
||||
if k in m:
|
||||
entry[k] = m[k]
|
||||
out.append(entry)
|
||||
|
||||
@ -28,7 +28,6 @@ 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",
|
||||
|
||||
@ -9,16 +9,11 @@ MCP_DIR="${REPO_ROOT}/mcp-servers"
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage:
|
||||
./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)
|
||||
./scripts/setup-mcp-servers.sh gitea
|
||||
|
||||
notes:
|
||||
- clones into ./mcp-servers/<name>
|
||||
- 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
|
||||
- builds artifacts needed to run the MCP server locally
|
||||
EOF
|
||||
}
|
||||
|
||||
@ -97,69 +92,6 @@ 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
|
||||
@ -168,7 +100,6 @@ main() {
|
||||
|
||||
case "$1" in
|
||||
gitea) setup_gitea ;;
|
||||
proxmox-mcp-plus) setup_proxmox_mcp_plus ;;
|
||||
-h|--help|help) usage ;;
|
||||
*)
|
||||
echo "error: unknown target '$1'" >&2
|
||||
|
||||
@ -1 +1,23 @@
|
||||
{'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'}}
|
||||
# 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.*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user