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:
parent
4b808f9a30
commit
cb7bbaf6e5
@ -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 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`:
|
Add MCP servers to your `config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -766,7 +768,7 @@ Two transport modes are supported:
|
|||||||
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
||||||
| **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) |
|
| **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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -81,7 +81,7 @@ nanobot/
|
|||||||
├── mcp-servers/ # <-- NEW: local MCP server clones
|
├── mcp-servers/ # <-- NEW: local MCP server clones
|
||||||
│ ├── gitea-mcp/ # git clone from gitea.com/gitea/gitea-mcp
|
│ ├── gitea-mcp/ # git clone from gitea.com/gitea/gitea-mcp
|
||||||
│ ├── google-calendar-mcp/ # git clone from github.com/nspady/google-calendar-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
|
│ └── fetch-browser/ # git clone from github.com/TheSethRose/Fetch-Browser
|
||||||
├── nanobot/
|
├── nanobot/
|
||||||
├── docs/
|
├── docs/
|
||||||
@ -195,10 +195,10 @@ These are the 4 MCP servers we plan to integrate in the immediate next phase. Ea
|
|||||||
|
|
||||||
| Field | Detail |
|
| Field | Detail |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Upstream** | `github.com/antonio-mello-ai/mcp-proxmox` (Python, pip-installable, MIT) |
|
| **Upstream** | [RekklesNA/ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus.git) (Python 3.11+, MIT) |
|
||||||
| **Transport** | Stdio via `python -m mcp_proxmox` |
|
| **Transport** | Stdio via `python -m proxmox_mcp.server` and `PROXMOX_MCP_CONFIG` |
|
||||||
| **Auth** | Proxmox API token (user `nanobot@pam!mcp-token` + secret) |
|
| **Auth** | Proxmox API token (in `proxmox-config/config.json`; see upstream example) |
|
||||||
| **Complexity** | **Medium** — requires network route to Proxmox cluster API, API token creation on Proxmox, and careful permission scoping |
|
| **Complexity** | **Medium** — Proxmox API reachability, token scoping; **many tools** → use `tools.toolProfiles` per MCP |
|
||||||
| **New capability** | Homelab infrastructure visibility and management from chat |
|
| **New capability** | Homelab infrastructure visibility and management from chat |
|
||||||
| **Target agents** | `@ilia` only (infrastructure admin; never exposed to `@family` or `@wife`) |
|
| **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
|
#### Technical notes
|
||||||
|
|
||||||
- **Build**: `pip install -e ./mcp-servers/mcp-proxmox/` into nanobot's venv, or use a dedicated venv.
|
- **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/mcp-proxmox/`
|
- **Local clone path**: `mcp-servers/proxmox-mcp-plus/`
|
||||||
- **Proxmox setup**:
|
- **Proxmox setup**:
|
||||||
1. Create API token: Datacenter → Permissions → API Tokens → Add (`nanobot@pam`, token ID `mcp-token`).
|
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).
|
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**:
|
- **Config entry**:
|
||||||
```jsonc
|
```jsonc
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"command": "python",
|
"command": "/mnt/data/nanobot/venv/bin/python",
|
||||||
"args": ["-m", "mcp_proxmox"],
|
"args": ["-m", "proxmox_mcp.server"],
|
||||||
"env": {
|
"env": {
|
||||||
"PROXMOX_HOST": "https://10.0.30.1:8006",
|
"PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json"
|
||||||
"PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token",
|
|
||||||
"PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET",
|
|
||||||
"PROXMOX_VERIFY_SSL": "false"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- **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`.
|
- **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
|
git clone https://github.com/nspady/google-calendar-mcp.git
|
||||||
cd google-calendar-mcp && npm install && npm run build && cd ..
|
cd google-calendar-mcp && npm install && npm run build && cd ..
|
||||||
|
|
||||||
# Proxmox MCP (Python)
|
# Proxmox MCP (Python 3.11+; from repo root)
|
||||||
git clone https://github.com/antonio-mello-ai/mcp-proxmox.git
|
cd .. && ./scripts/setup-mcp-servers.sh proxmox-mcp-plus && cd mcp-servers
|
||||||
cd mcp-proxmox && pip install -e . && cd ..
|
|
||||||
|
|
||||||
# Fetch Browser (TypeScript)
|
# 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 ..
|
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
|
### 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`** (@ilia — registers all MCPs; **one profile active per message**):
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
|
"defaultToolProfile": "default",
|
||||||
|
"toolRouting": { "enabled": true },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] },
|
"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" } },
|
"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": {} }
|
"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:
|
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)
|
- **Go** (for Gitea MCP binary — or copy pre-built binary)
|
||||||
- **Node.js 18+** (for Calendar MCP and Fetch Browser)
|
- **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)
|
- **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.
|
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 | 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-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). |
|
||||||
|
|||||||
@ -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 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,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):
|
2) Calendar shortcut (allowed only for the built-in `calendar` tool):
|
||||||
{{"action":"list_events", ...}}
|
{{"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 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.
|
||||||
|
|
||||||
### MCP quick mappings (use these when the intent matches)
|
### 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 **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 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 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
|
## Current Time
|
||||||
{now} ({tz})
|
{now} ({tz})
|
||||||
@ -117,7 +133,7 @@ Your workspace is at: {workspace_path}
|
|||||||
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
||||||
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
- 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 user’s question, no hijacking.
|
**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.
|
- 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.
|
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.
|
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.
|
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).
|
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)
|
system_prompt = self.build_system_prompt(skill_names)
|
||||||
if channel and chat_id:
|
if channel and chat_id:
|
||||||
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {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})
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
|
||||||
# History
|
# History
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""Agent loop: the core processing engine."""
|
"""Agent loop: the core processing engine."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
@ -274,6 +275,151 @@ class AgentLoop:
|
|||||||
return "\n".join(parts).strip()
|
return "\n".join(parts).strip()
|
||||||
return ""
|
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:
|
async def _pick_tool_profile(self, user_text: str) -> str:
|
||||||
"""Resolve profile key when tools.toolProfiles is configured."""
|
"""Resolve profile key when tools.toolProfiles is configured."""
|
||||||
if not self._tool_profiles:
|
if not self._tool_profiles:
|
||||||
@ -305,8 +451,12 @@ class AgentLoop:
|
|||||||
on_progress: Optional callback to push intermediate content to the user.
|
on_progress: Optional callback to push intermediate content to the user.
|
||||||
|
|
||||||
Returns:
|
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
|
messages = initial_messages
|
||||||
iteration = 0
|
iteration = 0
|
||||||
final_content = None
|
final_content = None
|
||||||
@ -368,10 +518,10 @@ class AgentLoop:
|
|||||||
logger.debug(f"LLM provider returned response, has_tool_calls={response.has_tool_calls}")
|
logger.debug(f"LLM provider returned response, has_tool_calls={response.has_tool_calls}")
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("LLM provider call timed out after 120 seconds")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"LLM provider error: {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 response.has_tool_calls:
|
||||||
if on_progress:
|
if on_progress:
|
||||||
@ -437,12 +587,20 @@ class AgentLoop:
|
|||||||
]
|
]
|
||||||
final_content = None
|
final_content = None
|
||||||
continue
|
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
|
break
|
||||||
|
|
||||||
if final_content is None and iteration >= self.max_iterations:
|
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}")
|
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:
|
async def run(self) -> None:
|
||||||
"""Run the agent loop, processing messages from the bus."""
|
"""Run the agent loop, processing messages from the bus."""
|
||||||
@ -548,9 +706,18 @@ class AgentLoop:
|
|||||||
asyncio.create_task(_consolidate_with_timeout())
|
asyncio.create_task(_consolidate_with_timeout())
|
||||||
|
|
||||||
self._set_tool_context(msg.channel, msg.chat_id)
|
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(
|
initial_messages = self.context.build_messages(
|
||||||
history=session.get_history(max_messages=self.memory_window),
|
history=history,
|
||||||
current_message=msg.content,
|
current_message=hinted_message,
|
||||||
media=msg.media if msg.media else None,
|
media=msg.media if msg.media else None,
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
@ -565,19 +732,24 @@ class AgentLoop:
|
|||||||
metadata=msg.metadata or {},
|
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,
|
initial_messages, on_progress=on_progress or _bus_progress,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
outbound_fallback = final_content
|
||||||
if final_content is None:
|
if final_content is None:
|
||||||
final_content = "I've completed processing but have no response to give."
|
final_content = "I've completed processing but have no response to give."
|
||||||
|
|
||||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||||
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
||||||
|
|
||||||
session.add_message("user", msg.content)
|
self._append_turn_to_session(
|
||||||
session.add_message("assistant", final_content,
|
session,
|
||||||
tools_used=tools_used if tools_used else None)
|
msg.content,
|
||||||
|
turn_tail,
|
||||||
|
tools_used,
|
||||||
|
final_for_fallback=outbound_fallback,
|
||||||
|
)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@ -615,13 +787,20 @@ class AgentLoop:
|
|||||||
channel=origin_channel,
|
channel=origin_channel,
|
||||||
chat_id=origin_chat_id,
|
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:
|
if final_content is None:
|
||||||
final_content = "Background task completed."
|
final_content = "Background task completed."
|
||||||
|
|
||||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
self._append_turn_to_session(
|
||||||
session.add_message("assistant", final_content)
|
session,
|
||||||
|
sys_user,
|
||||||
|
turn_tail,
|
||||||
|
tools_used,
|
||||||
|
final_for_fallback=outbound_fallback,
|
||||||
|
)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
|
|||||||
@ -27,9 +27,36 @@ async def route_tool_profile(
|
|||||||
if not profiles:
|
if not profiles:
|
||||||
return default_profile
|
return default_profile
|
||||||
|
|
||||||
# Heuristic fast-path: if the request clearly needs a dev/forge MCP (PRs, issues, repos),
|
# Heuristic fast-paths: pick a narrow tool profile without spending an LLM call.
|
||||||
# prefer an MCP-enabled profile without spending an LLM call.
|
|
||||||
msg_l = (user_message or "").lower()
|
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(
|
needs_forge = any(
|
||||||
k in msg_l
|
k in msg_l
|
||||||
for k in [
|
for k in [
|
||||||
@ -53,15 +80,47 @@ async def route_tool_profile(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
if needs_forge:
|
if needs_forge:
|
||||||
# Prefer an explicit "*mcp*" profile key if present, else any profile that enables MCP servers.
|
for candidate in ("gitea", "gitea_mcp", "forge"):
|
||||||
for key in profiles.keys():
|
if candidate in profiles:
|
||||||
if "mcp" in key.lower():
|
logger.info(f"Tool router selected profile '{candidate}' (forge heuristic)")
|
||||||
logger.info(f"Tool router selected profile '{key}' (heuristic)")
|
return candidate
|
||||||
return key
|
# 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():
|
for key, p in profiles.items():
|
||||||
if p.mcp_servers is None or (isinstance(p.mcp_servers, list) and len(p.mcp_servers) > 0):
|
key_l = key.lower()
|
||||||
logger.info(f"Tool router selected profile '{key}' (heuristic)")
|
desc_l = (p.description or "").lower()
|
||||||
return key
|
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 = []
|
lines = []
|
||||||
for name, p in profiles.items():
|
for name, p in profiles.items():
|
||||||
|
|||||||
@ -9,10 +9,26 @@ from nanobot.agent.tools.base import Tool
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
|
def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
|
||||||
"""Resolve path and optionally enforce directory restriction."""
|
"""Resolve path and optionally enforce directory restriction.
|
||||||
resolved = Path(path).expanduser().resolve()
|
|
||||||
if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())):
|
When ``allowed_dir`` is set, **relative** paths are resolved under that root
|
||||||
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
(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
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -196,8 +196,6 @@ class WebFetchTool(Tool):
|
|||||||
self.max_chars = max_chars
|
self.max_chars = max_chars
|
||||||
|
|
||||||
async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str:
|
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
|
max_chars = maxChars or self.max_chars
|
||||||
|
|
||||||
# Validate URL before fetching
|
# Validate URL before fetching
|
||||||
@ -206,6 +204,8 @@ class WebFetchTool(Tool):
|
|||||||
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url})
|
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from readability import Document
|
||||||
|
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
max_redirects=MAX_REDIRECTS,
|
max_redirects=MAX_REDIRECTS,
|
||||||
|
|||||||
@ -82,14 +82,98 @@ class CustomProvider(LLMProvider):
|
|||||||
"email",
|
"email",
|
||||||
]
|
]
|
||||||
# Check for standard format: {"name": "...", "parameters": {...}}
|
# 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": "...", ...}
|
# 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.
|
# 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.
|
# If it looks like a JSON object, attempt parsing even if our heuristics missed it.
|
||||||
looks_like_json_object = stripped.startswith("{") and stripped.endswith("}")
|
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):
|
if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object):
|
||||||
import re
|
import re
|
||||||
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...}
|
# 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]
|
json_str = content[json_start:json_end]
|
||||||
tool_obj = json_repair.loads(json_str)
|
tool_obj = json_repair.loads(json_str)
|
||||||
|
|
||||||
# Handle calendar tool format: {"action": "...", ...}
|
if _append_tool_call_from_obj(tool_obj):
|
||||||
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
|
# Remove the tool call from content
|
||||||
content = content[:json_start] + content[json_end:].strip()
|
content = content[:json_start] + content[json_end:].strip()
|
||||||
start_pos = json_start # Stay at same position since we removed text
|
start_pos = json_start # Stay at same position since we removed text
|
||||||
@ -177,25 +235,8 @@ class CustomProvider(LLMProvider):
|
|||||||
if not tool_calls and looks_like_json_object:
|
if not tool_calls and looks_like_json_object:
|
||||||
try:
|
try:
|
||||||
tool_obj = json_repair.loads(stripped)
|
tool_obj = json_repair.loads(stripped)
|
||||||
if isinstance(tool_obj, dict) and "action" in tool_obj:
|
if _append_tool_call_from_obj(tool_obj):
|
||||||
action = tool_obj.get("action")
|
content = ""
|
||||||
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@ -45,8 +45,13 @@ class Session:
|
|||||||
"""Get recent messages in LLM format, preserving tool metadata."""
|
"""Get recent messages in LLM format, preserving tool metadata."""
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for m in self.messages[-max_messages:]:
|
for m in self.messages[-max_messages:]:
|
||||||
entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")}
|
role = m["role"]
|
||||||
for k in ("tool_calls", "tool_call_id", "name"):
|
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:
|
if k in m:
|
||||||
entry[k] = m[k]
|
entry[k] = m[k]
|
||||||
out.append(entry)
|
out.append(entry)
|
||||||
|
|||||||
@ -28,6 +28,7 @@ dependencies = [
|
|||||||
"oauth-cli-kit>=0.1.1",
|
"oauth-cli-kit>=0.1.1",
|
||||||
"loguru>=0.7.0",
|
"loguru>=0.7.0",
|
||||||
"readability-lxml>=0.8.0",
|
"readability-lxml>=0.8.0",
|
||||||
|
"lxml-html-clean>=0.4.0",
|
||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
"croniter>=2.0.0",
|
"croniter>=2.0.0",
|
||||||
"dingtalk-stream>=0.4.0",
|
"dingtalk-stream>=0.4.0",
|
||||||
|
|||||||
@ -9,11 +9,16 @@ MCP_DIR="${REPO_ROOT}/mcp-servers"
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage:
|
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:
|
notes:
|
||||||
- clones into ./mcp-servers/<name>
|
- 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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +97,69 @@ EOF
|
|||||||
echo "done: built ${MCP_DIR}/gitea-mcp/gitea-mcp"
|
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() {
|
main() {
|
||||||
if [[ "${#}" -ne 1 ]]; then
|
if [[ "${#}" -ne 1 ]]; then
|
||||||
usage
|
usage
|
||||||
@ -100,6 +168,7 @@ main() {
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
gitea) setup_gitea ;;
|
gitea) setup_gitea ;;
|
||||||
|
proxmox-mcp-plus) setup_proxmox_mcp_plus ;;
|
||||||
-h|--help|help) usage ;;
|
-h|--help|help) usage ;;
|
||||||
*)
|
*)
|
||||||
echo "error: unknown target '$1'" >&2
|
echo "error: unknown target '$1'" >&2
|
||||||
|
|||||||
@ -1,23 +1 @@
|
|||||||
# Long-term Memory
|
{'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'}}
|
||||||
|
|
||||||
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