Compare commits
No commits in common. "feature/cleanup-providers-llama-only" and "feature/web-search-and-cron-improvements" have entirely different histories.
feature/cl
...
feature/we
@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, develop, feature/** ]
|
||||
branches: [ main, master, develop ]
|
||||
pull_request:
|
||||
# Trigger on all pull requests regardless of target branch
|
||||
branches: [ main, master, develop ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@ -53,10 +53,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Install nanobot with all dependencies and dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
# Verify key dependencies are installed
|
||||
pip list | grep -E "(pytest|ruff|pydantic|typer|litellm)"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,6 +1,5 @@
|
||||
.assets
|
||||
.env
|
||||
.env.*
|
||||
*.pyc
|
||||
dist/
|
||||
build/
|
||||
@ -22,8 +21,3 @@ poetry.lock
|
||||
.pytest_cache/
|
||||
botpy.log
|
||||
tests/
|
||||
|
||||
# Local-cloned MCP servers (kept out of git; clone/build locally)
|
||||
mcp-servers/*
|
||||
!mcp-servers/README.md
|
||||
!mcp-servers/.gitkeep
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
# Developing Nanobot with Docker
|
||||
|
||||
## Current Setup (Production)
|
||||
|
||||
**`docker-compose.multi.env.yml`** - Production mode:
|
||||
- Code is **copied** into Docker image during build
|
||||
- Changes to source code **NOT** picked up automatically
|
||||
- Need to rebuild image: `docker compose -f docker-compose.multi.env.yml build`
|
||||
|
||||
## Development Setup
|
||||
|
||||
**`docker-compose.multi.dev.yml`** - Development mode:
|
||||
- Source code is **mounted** as volume
|
||||
- Changes to `nanobot/` directory **picked up automatically**
|
||||
- Just restart container (no rebuild needed)
|
||||
|
||||
## How It Works
|
||||
|
||||
### Production Mode (Current)
|
||||
```bash
|
||||
# 1. Build image (copies code)
|
||||
docker compose -f docker-compose.multi.env.yml build
|
||||
|
||||
# 2. Run container
|
||||
docker compose -f docker-compose.multi.env.yml up -d
|
||||
|
||||
# 3. Make code changes...
|
||||
# 4. Changes NOT visible - need to rebuild:
|
||||
docker compose -f docker-compose.multi.env.yml build
|
||||
docker compose -f docker-compose.multi.env.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
### Development Mode (Recommended for Development)
|
||||
```bash
|
||||
# 1. Build image once (for dependencies)
|
||||
docker compose -f docker-compose.multi.dev.yml build
|
||||
|
||||
# 2. Run container
|
||||
docker compose -f docker-compose.multi.dev.yml up -d
|
||||
|
||||
# 3. Make code changes in venv...
|
||||
# 4. Changes visible immediately - just restart:
|
||||
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Option 1: Develop in Venv, Test in Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# Terminal 1: Develop in venv
|
||||
source venv/bin/activate
|
||||
# Edit code, test locally if needed
|
||||
nano nanobot/channels/telegram.py
|
||||
|
||||
# Terminal 2: Run Docker in dev mode
|
||||
docker compose -f docker-compose.multi.dev.yml up -d nanobot-user1
|
||||
|
||||
# After making changes, restart container:
|
||||
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
|
||||
|
||||
# Watch logs:
|
||||
docker logs -f nanobot-user1-dev
|
||||
```
|
||||
|
||||
### Option 2: Rebuild After Changes (Current)
|
||||
|
||||
```bash
|
||||
# Make changes in venv
|
||||
source venv/bin/activate
|
||||
nano nanobot/channels/telegram.py
|
||||
|
||||
# Rebuild Docker image
|
||||
docker compose -f docker-compose.multi.env.yml build nanobot-user1
|
||||
|
||||
# Recreate container
|
||||
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Development Mode (`docker-compose.multi.dev.yml`)
|
||||
- ✅ Changes picked up automatically
|
||||
- ✅ Faster iteration (no rebuild needed)
|
||||
- ⚠️ Mounts source code (may have slight performance impact)
|
||||
- ⚠️ Python needs to reload modules (restart container)
|
||||
|
||||
### Production Mode (`docker-compose.multi.env.yml`)
|
||||
- ✅ Code baked into image (more stable)
|
||||
- ✅ No performance impact from mounts
|
||||
- ❌ Need rebuild for every change
|
||||
- ✅ Better for production deployments
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Development workflow
|
||||
docker compose -f docker-compose.multi.dev.yml up -d nanobot-user1
|
||||
# ... make changes ...
|
||||
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
|
||||
|
||||
# Production workflow
|
||||
docker compose -f docker-compose.multi.env.yml build nanobot-user1
|
||||
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
|
||||
```
|
||||
|
||||
## Which Should You Use?
|
||||
|
||||
- **Developing code**: Use `docker-compose.multi.dev.yml`
|
||||
- **Production/staging**: Use `docker-compose.multi.env.yml`
|
||||
- **Quick testing**: Use venv directly on host
|
||||
|
||||
|
||||
@ -1,647 +0,0 @@
|
||||
# Docker Multi-Bot Setup Guide
|
||||
|
||||
Complete guide for running multiple nanobot instances with Docker, each with its own Telegram bot.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Architecture Overview](#architecture-overview)
|
||||
3. [Setup Instructions](#setup-instructions)
|
||||
4. [Running Bots](#running-bots)
|
||||
5. [Configuration Management](#configuration-management)
|
||||
6. [Development Workflow](#development-workflow)
|
||||
7. [Troubleshooting](#troubleshooting)
|
||||
8. [Reference](#reference)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Start Only User1 Bot
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml up -d nanobot-user1
|
||||
```
|
||||
|
||||
### Start All Bots
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml up -d
|
||||
```
|
||||
|
||||
### Stop All Bots
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml down
|
||||
```
|
||||
|
||||
### Stop Specific Bot
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml stop nanobot-user1
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker logs -f nanobot-user1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### How It Works
|
||||
|
||||
Each bot runs in its own Docker container with:
|
||||
- **Separate config directory**: `~/.nanobot-user1`, `~/.nanobot-user2`, etc.
|
||||
- **Shared env file**: `.env.shared` (common settings)
|
||||
- **Bot-specific env file**: `.env.user1`, `.env.user2`, etc. (overrides)
|
||||
- **Isolated environment**: No conflicts between bots
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
nanobot/
|
||||
├── .env.shared # Shared settings (API keys, model, etc.)
|
||||
├── .env.user1 # Bot 1 (@ilia) overrides
|
||||
├── .env.user2 # Bot 2 (@family) overrides
|
||||
├── .env.user3 # Bot 3 (@wife) overrides
|
||||
├── agent_workspaces/ # Templates copied by scripts/init-agent-workspaces.sh
|
||||
├── scripts/init-agent-workspaces.sh
|
||||
├── docker-compose.multi.env.yml # Production compose file
|
||||
├── docker-compose.multi.dev.yml # Development compose file
|
||||
│
|
||||
├── ~/.nanobot/workspaces/
|
||||
│ ├── ilia/ # Mounted as /workspace for user1 — AGENTS.md, memory/, …
|
||||
│ ├── family/ # user2
|
||||
│ └── wife/ # user3
|
||||
├── ~/.nanobot-user1/
|
||||
│ └── config.json # Bot 1 channel config (Telegram token, allowFrom)
|
||||
├── ~/.nanobot-user2/
|
||||
│ └── config.json # Bot 2 channel config
|
||||
└── ~/.nanobot-user3/
|
||||
└── config.json # Bot 3 channel config
|
||||
```
|
||||
|
||||
`./workspace` in the repo remains for **single-bot** `docker-compose.yml` only; multi-bot uses `~/.nanobot/workspaces/*` per container.
|
||||
|
||||
### Configuration Loading
|
||||
|
||||
1. **Docker Compose** loads environment files:
|
||||
- First: `.env.shared` (shared settings)
|
||||
- Second: `.env.userX` (bot-specific overrides)
|
||||
- Later files override earlier ones
|
||||
|
||||
2. **Container** mounts config directory:
|
||||
- Host: `~/.nanobot-user1` → Container: `/root/.nanobot`
|
||||
|
||||
3. **Nanobot** loads:
|
||||
- Environment variables (from Docker env files)
|
||||
- Config file: `/root/.nanobot/config.json` (mounted from host)
|
||||
|
||||
---
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Step 0: Per-agent workspaces (personalities + isolated memory)
|
||||
|
||||
Multi-bot compose mounts **separate** workspace directories so each bot has its own `AGENTS.md`, `SOUL.md`, `USER.md`, and `memory/` (no shared `./workspace`).
|
||||
|
||||
On the host, from the repo root:
|
||||
|
||||
```bash
|
||||
./scripts/init-agent-workspaces.sh
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
```
|
||||
~/.nanobot/workspaces/
|
||||
ilia/ # nanobot-user1 — dev / infra persona
|
||||
family/ # nanobot-user2 — household persona
|
||||
wife/ # nanobot-user3 — personal assistant persona
|
||||
```
|
||||
|
||||
Templates live in-repo under `agent_workspaces/`. Re-run the script anytime: it **skips** files that already exist. Adjust ownership if Docker runs as root:
|
||||
|
||||
```bash
|
||||
sudo chown -R "$(whoami):$(whoami)" ~/.nanobot/workspaces
|
||||
```
|
||||
|
||||
### Step 1: Create Environment Files
|
||||
|
||||
Run the setup script:
|
||||
```bash
|
||||
./env-files-setup.sh
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `.env.shared` - Shared settings
|
||||
- `.env.user1`, `.env.user2`, `.env.user3` - Bot-specific overrides
|
||||
|
||||
### Step 2: Edit `.env.shared`
|
||||
|
||||
Add your shared settings:
|
||||
```bash
|
||||
nano .env.shared
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
# Provider Settings
|
||||
NANOBOT_PROVIDERS__CUSTOM__API_KEY=no-key
|
||||
NANOBOT_PROVIDERS__CUSTOM__API_BASE=http://172.17.0.1:11434/v1
|
||||
|
||||
# Agent Settings
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=llama3.1:8b
|
||||
NANOBOT_AGENTS__DEFAULTS__WORKSPACE=/mnt/data/nanobot
|
||||
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
|
||||
```
|
||||
|
||||
**Important**: Use `172.17.0.1` (Docker bridge gateway) instead of `localhost` so containers can reach Ollama on the host.
|
||||
|
||||
### Step 3: Edit Bot-Specific Env Files
|
||||
|
||||
Edit `.env.user1`, `.env.user2`, etc. with bot-specific settings:
|
||||
```bash
|
||||
nano .env.user1
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
# Telegram Bot Token
|
||||
NANOBOT_CHANNELS__TELEGRAM__ENABLED=true
|
||||
NANOBOT_CHANNELS__TELEGRAM__TOKEN=your_bot_token_here
|
||||
|
||||
# Email Credentials (if different per bot)
|
||||
NANOBOT_CHANNELS__EMAIL__IMAP_USERNAME=bot1@example.com
|
||||
NANOBOT_CHANNELS__EMAIL__IMAP_PASSWORD=password
|
||||
```
|
||||
|
||||
### Step 4: Create Config Files
|
||||
|
||||
Run the config creation script:
|
||||
```bash
|
||||
./create-bot-configs.sh
|
||||
```
|
||||
|
||||
Or manually create `~/.nanobot-user1/config.json`:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["YOUR_TELEGRAM_USERNAME"]
|
||||
},
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["email@example.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: `allowFrom` arrays must be in `config.json`, NOT in env files.
|
||||
|
||||
### Step 5: Build Docker Image
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Bots
|
||||
|
||||
### Start Commands
|
||||
|
||||
```bash
|
||||
# Start only user1
|
||||
docker compose -f docker-compose.multi.env.yml up -d nanobot-user1
|
||||
|
||||
# Start only user2
|
||||
docker compose -f docker-compose.multi.env.yml up -d nanobot-user2
|
||||
|
||||
# Start only user3
|
||||
docker compose -f docker-compose.multi.env.yml up -d nanobot-user3
|
||||
|
||||
# Start all bots
|
||||
docker compose -f docker-compose.multi.env.yml up -d
|
||||
```
|
||||
|
||||
### Stop Commands
|
||||
|
||||
```bash
|
||||
# Stop only user1
|
||||
docker compose -f docker-compose.multi.env.yml stop nanobot-user1
|
||||
|
||||
# Stop only user2
|
||||
docker compose -f docker-compose.multi.env.yml stop nanobot-user2
|
||||
|
||||
# Stop only user3
|
||||
docker compose -f docker-compose.multi.env.yml stop nanobot-user3
|
||||
|
||||
# Stop all bots
|
||||
docker compose -f docker-compose.multi.env.yml down
|
||||
```
|
||||
|
||||
### Restart Commands
|
||||
|
||||
```bash
|
||||
# Restart only user1
|
||||
docker compose -f docker-compose.multi.env.yml restart nanobot-user1
|
||||
|
||||
# Restart all bots
|
||||
docker compose -f docker-compose.multi.env.yml restart
|
||||
```
|
||||
|
||||
### Status Commands
|
||||
|
||||
```bash
|
||||
# Check what's running
|
||||
docker compose -f docker-compose.multi.env.yml ps
|
||||
|
||||
# View logs for user1
|
||||
docker logs -f nanobot-user1
|
||||
|
||||
# View logs for all bots
|
||||
docker compose -f docker-compose.multi.env.yml logs -f
|
||||
|
||||
# View logs for specific bot
|
||||
docker compose -f docker-compose.multi.env.yml logs -f nanobot-user1
|
||||
```
|
||||
|
||||
### Remove Commands
|
||||
|
||||
```bash
|
||||
# Stop and remove user1 container
|
||||
docker compose -f docker-compose.multi.env.yml stop nanobot-user1
|
||||
docker rm nanobot-user1
|
||||
|
||||
# Stop and remove all containers
|
||||
docker compose -f docker-compose.multi.env.yml down
|
||||
|
||||
# Remove containers and volumes (keeps config files)
|
||||
docker compose -f docker-compose.multi.env.yml down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Updating Shared Settings
|
||||
|
||||
Edit `.env.shared`:
|
||||
```bash
|
||||
nano .env.shared
|
||||
```
|
||||
|
||||
Restart affected containers:
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml restart
|
||||
```
|
||||
|
||||
### Updating Bot-Specific Settings
|
||||
|
||||
Edit `.env.userX`:
|
||||
```bash
|
||||
nano .env.user1
|
||||
```
|
||||
|
||||
Restart that specific bot:
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml restart nanobot-user1
|
||||
```
|
||||
|
||||
### Updating Config Files
|
||||
|
||||
Edit config file:
|
||||
```bash
|
||||
nano ~/.nanobot-user1/config.json
|
||||
```
|
||||
|
||||
Restart container:
|
||||
```bash
|
||||
docker restart nanobot-user1
|
||||
```
|
||||
|
||||
### Adding Email to allowFrom
|
||||
|
||||
```bash
|
||||
# Add email to user1's allowFrom
|
||||
jq '.channels.email.allowFrom += ["newemail@example.com"]' \
|
||||
~/.nanobot-user1/config.json > ~/.nanobot-user1/config.json.tmp && \
|
||||
mv ~/.nanobot-user1/config.json.tmp ~/.nanobot-user1/config.json
|
||||
|
||||
# Restart container
|
||||
docker restart nanobot-user1
|
||||
```
|
||||
|
||||
### Environment Variable Format
|
||||
|
||||
Nanobot uses Pydantic's `BaseSettings` with:
|
||||
- Prefix: `NANOBOT_`
|
||||
- Nested delimiter: `__` (double underscore)
|
||||
|
||||
Examples:
|
||||
- `NANOBOT_PROVIDERS__CUSTOM__API_KEY` → `providers.custom.apiKey`
|
||||
- `NANOBOT_CHANNELS__TELEGRAM__TOKEN` → `channels.telegram.token`
|
||||
- `NANOBOT_AGENTS__DEFAULTS__MODEL` → `agents.defaults.model`
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Option 1: Development Mode (Recommended)
|
||||
|
||||
Use `docker-compose.multi.dev.yml` which mounts source code:
|
||||
|
||||
```bash
|
||||
# Start in development mode
|
||||
docker compose -f docker-compose.multi.dev.yml up -d nanobot-user1
|
||||
|
||||
# Make changes in venv
|
||||
source venv/bin/activate
|
||||
nano nanobot/channels/telegram.py
|
||||
|
||||
# Restart container (picks up changes)
|
||||
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
|
||||
|
||||
# View logs
|
||||
docker logs -f nanobot-user1-dev
|
||||
```
|
||||
|
||||
### Option 2: Rebuild After Changes
|
||||
|
||||
```bash
|
||||
# Make changes
|
||||
source venv/bin/activate
|
||||
nano nanobot/channels/telegram.py
|
||||
|
||||
# Rebuild image
|
||||
docker compose -f docker-compose.multi.env.yml build nanobot-user1
|
||||
|
||||
# Recreate container
|
||||
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
|
||||
```
|
||||
|
||||
### Option 3: Run on Host (Not Docker)
|
||||
|
||||
```bash
|
||||
# Activate venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Run directly
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
**Note**: Host command uses:
|
||||
- Config: `~/.nanobot/config.json` (original)
|
||||
- .env: `.env` in current directory (NOT `.env.shared`)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot Not Responding
|
||||
|
||||
1. **Check if container is running**:
|
||||
```bash
|
||||
docker ps | grep nanobot-user1
|
||||
```
|
||||
|
||||
2. **Check logs**:
|
||||
```bash
|
||||
docker logs nanobot-user1 --tail 50
|
||||
```
|
||||
|
||||
3. **Verify Telegram token**:
|
||||
```bash
|
||||
grep TELEGRAM__TOKEN .env.user1
|
||||
```
|
||||
|
||||
4. **Check allowFrom**:
|
||||
```bash
|
||||
cat ~/.nanobot-user1/config.json | jq '.channels.telegram.allowFrom'
|
||||
```
|
||||
|
||||
### Connection Error (Ollama)
|
||||
|
||||
**Problem**: "Connection error" when bot tries to use LLM
|
||||
|
||||
**Solution**:
|
||||
1. Check Ollama is running:
|
||||
```bash
|
||||
curl http://localhost:11434/api/tags
|
||||
```
|
||||
|
||||
2. Verify API_BASE in `.env.shared`:
|
||||
```bash
|
||||
grep API_BASE .env.shared
|
||||
```
|
||||
Should be: `http://172.17.0.1:11434/v1` (NOT `localhost`)
|
||||
|
||||
3. Restart container:
|
||||
```bash
|
||||
docker restart nanobot-user1
|
||||
```
|
||||
|
||||
### Config Not Loading
|
||||
|
||||
1. **Check volume mount**:
|
||||
```bash
|
||||
docker inspect nanobot-user1 | grep -A 5 Mounts
|
||||
```
|
||||
|
||||
2. **Verify config exists**:
|
||||
```bash
|
||||
ls -lh ~/.nanobot-user1/config.json
|
||||
```
|
||||
|
||||
3. **Check inside container**:
|
||||
```bash
|
||||
docker exec nanobot-user1 cat /root/.nanobot/config.json
|
||||
```
|
||||
|
||||
### Environment Variables Not Applied
|
||||
|
||||
1. **Check env files are loaded**:
|
||||
```bash
|
||||
docker exec nanobot-user1 env | grep NANOBOT
|
||||
```
|
||||
|
||||
2. **Recreate container** (env files loaded at creation):
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
If port conflicts:
|
||||
```bash
|
||||
# Check what's using the port
|
||||
sudo lsof -i :18790
|
||||
|
||||
# Stop conflicting container
|
||||
docker stop <container_name>
|
||||
|
||||
# Or change port in docker-compose.multi.env.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
### Docker Compose Files
|
||||
|
||||
| File | Purpose | Use Case | Env Loading |
|
||||
|------|---------|----------|-------------|
|
||||
| `docker-compose.yml` | Single-bot baseline | One gateway + optional CLI, simplest setup | No `env_file`; uses mounted `~/.nanobot/config.json` and container environment |
|
||||
| `docker-compose.multi.yml` | Multi-bot baseline | Multiple bots with separate config dirs, minimal env indirection | No `env_file`; each bot uses mounted `~/.nanobot-userX/config.json` and container environment |
|
||||
| `docker-compose.multi.env.yml` | Multi-bot production | Stable multi-bot deployments with shared + per-bot overrides | Loads `.env.shared` first, then `.env.userX` (later file overrides earlier) |
|
||||
| `docker-compose.multi.dev.yml` | Multi-bot development | Active code development with source mounted into containers | Same env behavior as `multi.env` (`.env.shared` + `.env.userX`) |
|
||||
|
||||
### Container Names
|
||||
|
||||
- `nanobot-user1` - Bot 1 container (production)
|
||||
- `nanobot-user1-dev` - Bot 1 container (development)
|
||||
- `nanobot-user2` - Bot 2 container
|
||||
- `nanobot-user3` - Bot 3 container
|
||||
|
||||
### Ports
|
||||
|
||||
- User 1: `18790` (host) → `18790` (container)
|
||||
- User 2: `18791` (host) → `18790` (container)
|
||||
- User 3: `18792` (host) → `18790` (container)
|
||||
|
||||
### Config Locations
|
||||
|
||||
| Location | Used By | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `~/.nanobot/config.json` | Host `nanobot gateway` | Original config (not used by Docker) |
|
||||
| `~/.nanobot-user1/config.json` | Docker user1 | Bot 1 config (mounted into container) |
|
||||
| `~/.nanobot-user2/config.json` | Docker user2 | Bot 2 config |
|
||||
| `~/.nanobot-user3/config.json` | Docker user3 | Bot 3 config |
|
||||
|
||||
### Environment Files
|
||||
|
||||
| File | Loaded By | Purpose |
|
||||
|------|-----------|---------|
|
||||
| `.env.shared` | All Docker containers | Shared settings (API keys, model, etc.) |
|
||||
| `.env.user1` | Docker user1 only | Bot 1 specific overrides |
|
||||
| `.env.user2` | Docker user2 only | Bot 2 specific overrides |
|
||||
| `.env.user3` | Docker user3 only | Bot 3 specific overrides |
|
||||
| `.env` | Host `nanobot gateway` | Host environment (not used by Docker) |
|
||||
|
||||
### Quick Command Reference
|
||||
|
||||
```bash
|
||||
# === START ===
|
||||
docker compose -f docker-compose.multi.env.yml up -d nanobot-user1 # Start user1
|
||||
docker compose -f docker-compose.multi.env.yml up -d # Start all
|
||||
|
||||
# === STOP ===
|
||||
docker compose -f docker-compose.multi.env.yml stop nanobot-user1 # Stop user1
|
||||
docker compose -f docker-compose.multi.env.yml down # Stop all
|
||||
|
||||
# === RESTART ===
|
||||
docker compose -f docker-compose.multi.env.yml restart nanobot-user1 # Restart user1
|
||||
docker compose -f docker-compose.multi.env.yml restart # Restart all
|
||||
|
||||
# === LOGS ===
|
||||
docker logs -f nanobot-user1 # View logs
|
||||
docker compose -f docker-compose.multi.env.yml logs -f nanobot-user1 # View logs
|
||||
|
||||
# === STATUS ===
|
||||
docker compose -f docker-compose.multi.env.yml ps # List containers
|
||||
docker ps | grep nanobot # List containers
|
||||
|
||||
# === REBUILD ===
|
||||
docker compose -f docker-compose.multi.env.yml build nanobot-user1 # Rebuild user1
|
||||
docker compose -f docker-compose.multi.env.yml build # Rebuild all
|
||||
|
||||
# === RECREATE (after config changes) ===
|
||||
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a New Bot (User4)
|
||||
|
||||
1. Create config directory:
|
||||
```bash
|
||||
mkdir -p ~/.nanobot-user4
|
||||
```
|
||||
|
||||
2. Create config file:
|
||||
```bash
|
||||
cat > ~/.nanobot-user4/config.json << 'EOF'
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["USERNAME"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
3. Create env file:
|
||||
```bash
|
||||
cp .env.user1 .env.user4
|
||||
nano .env.user4 # Edit with bot-specific settings
|
||||
```
|
||||
|
||||
4. Add to `docker-compose.multi.env.yml`:
|
||||
```yaml
|
||||
nanobot-user4:
|
||||
# ... (copy from nanobot-user1, change port to 18793)
|
||||
```
|
||||
|
||||
5. Start:
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml up -d nanobot-user4
|
||||
```
|
||||
|
||||
### Update Ollama API Base
|
||||
|
||||
If Ollama IP changes:
|
||||
```bash
|
||||
# Edit .env.shared
|
||||
sed -i 's|http://172.17.0.1:11434|http://NEW_IP:11434|g' .env.shared
|
||||
|
||||
# Restart all containers
|
||||
docker compose -f docker-compose.multi.env.yml restart
|
||||
```
|
||||
|
||||
### Backup Configurations
|
||||
|
||||
```bash
|
||||
# Backup all configs
|
||||
tar -czf nanobot-configs-backup-$(date +%Y%m%d).tar.gz \
|
||||
~/.nanobot-user* \
|
||||
.env.shared .env.user*
|
||||
|
||||
# Restore
|
||||
tar -xzf nanobot-configs-backup-YYYYMMDD.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Original config** (`~/.nanobot/config.json`) is NOT used by Docker containers
|
||||
- **Host venv** is NOT needed to run Docker commands (Docker builds its own environment)
|
||||
- **Environment variables** override config file settings
|
||||
- **Arrays** (like `allowFrom`) must be in `config.json`, NOT in env files
|
||||
- **Ollama API_BASE** must use `172.17.0.1` (Docker bridge) not `localhost`
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- `ENV_FILES_GUIDE.md` - Detailed env file management
|
||||
- `DEVELOPMENT_WITH_DOCKER.md` - Development workflow details
|
||||
- `SETUP_SUMMARY.md` - Initial setup summary
|
||||
- `VERIFY_DOCKER_SETUP.md` - Verification checklist
|
||||
|
||||
|
||||
@ -1,241 +0,0 @@
|
||||
# Using Separate Env Files Per Container
|
||||
|
||||
This guide shows you how to use separate `.env` files for each bot container, making it easy to manage both shared and bot-specific settings.
|
||||
|
||||
## How It Works
|
||||
|
||||
Docker Compose loads `env_file` entries in order. Later files override earlier ones:
|
||||
|
||||
1. **`.env.shared`** - Loaded first, contains common settings
|
||||
2. **`.env.user1`, `.env.user2`, `.env.user3`** - Loaded after, can override shared settings
|
||||
|
||||
This means:
|
||||
- ✅ Shared settings go in `.env.shared` (update once)
|
||||
- ✅ Bot-specific overrides go in `.env.user1`, `.env.user2`, etc. (only if needed)
|
||||
- ✅ Easy to edit (plain text files, no JSON)
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### Step 1: Create Env Files
|
||||
|
||||
Run the setup script:
|
||||
|
||||
```bash
|
||||
chmod +x env-files-setup.sh
|
||||
./env-files-setup.sh
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `.env.shared` - Shared settings for all bots
|
||||
- `.env.user1` - Overrides for bot 1 (optional)
|
||||
- `.env.user2` - Overrides for bot 2 (optional)
|
||||
- `.env.user3` - Overrides for bot 3 (optional)
|
||||
|
||||
### Step 2: Edit `.env.shared`
|
||||
|
||||
Edit `.env.shared` with your shared settings:
|
||||
|
||||
```bash
|
||||
# Shared configuration for all nanobot instances
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-your-actual-key
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
|
||||
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
|
||||
NANOBOT_AGENTS__DEFAULTS__MAX_TOKENS=8192
|
||||
```
|
||||
|
||||
### Step 3: Edit Bot-Specific Files (Optional)
|
||||
|
||||
If a bot needs different settings, edit its `.env.userX` file:
|
||||
|
||||
**`.env.user1`** (example - override model for bot 1):
|
||||
```bash
|
||||
# Override model for this bot only
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
**`.env.user2`** (example - override temperature):
|
||||
```bash
|
||||
# Override temperature for this bot only
|
||||
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.9
|
||||
```
|
||||
|
||||
**`.env.user3`** (example - leave empty to use all shared settings):
|
||||
```bash
|
||||
# This bot uses all settings from .env.shared
|
||||
# No overrides needed
|
||||
```
|
||||
|
||||
### Step 4: Create Minimal Config Files
|
||||
|
||||
Each bot still needs a minimal `config.json` with bot-specific channel settings:
|
||||
|
||||
**`~/.nanobot-user1/config.json`**:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "BOT_TOKEN_FOR_USER1",
|
||||
"allowFrom": ["USER1_TELEGRAM_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`~/.nanobot-user2/config.json`**:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "BOT_TOKEN_FOR_USER2",
|
||||
"allowFrom": ["USER2_TELEGRAM_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Run with Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml up -d
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
nanobot/
|
||||
├── .env.shared ← Shared settings (API keys, model, etc.)
|
||||
├── .env.user1 ← Bot 1 overrides (optional)
|
||||
├── .env.user2 ← Bot 2 overrides (optional)
|
||||
├── .env.user3 ← Bot 3 overrides (optional)
|
||||
├── docker-compose.multi.env.yml
|
||||
│
|
||||
└── ~/.nanobot-user1/
|
||||
└── config.json ← Bot 1 channel config (Telegram token, user ID)
|
||||
└── ~/.nanobot-user2/
|
||||
└── config.json ← Bot 2 channel config
|
||||
└── ~/.nanobot-user3/
|
||||
└── config.json ← Bot 3 channel config
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: All Bots Use Same Settings
|
||||
|
||||
**`.env.shared`**:
|
||||
```bash
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
|
||||
```
|
||||
|
||||
**`.env.user1`**, **`.env.user2`**, **`.env.user3`**:
|
||||
```bash
|
||||
# Empty - all bots use shared settings
|
||||
```
|
||||
|
||||
### Example 2: One Bot Uses Different Model
|
||||
|
||||
**`.env.shared`**:
|
||||
```bash
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
|
||||
```
|
||||
|
||||
**`.env.user1`**:
|
||||
```bash
|
||||
# Bot 1 uses a different model
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
**`.env.user2`**, **`.env.user3`**:
|
||||
```bash
|
||||
# Empty - use shared model
|
||||
```
|
||||
|
||||
### Example 3: One Bot Uses Different API Key
|
||||
|
||||
**`.env.shared`**:
|
||||
```bash
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-shared-key
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
|
||||
```
|
||||
|
||||
**`.env.user2`**:
|
||||
```bash
|
||||
# Bot 2 uses its own API key
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-user2-key
|
||||
```
|
||||
|
||||
## Updating Settings
|
||||
|
||||
### Update Shared Settings
|
||||
|
||||
Edit `.env.shared` and restart all containers:
|
||||
|
||||
```bash
|
||||
# Edit shared settings
|
||||
nano .env.shared
|
||||
|
||||
# Restart all bots
|
||||
docker compose -f docker-compose.multi.env.yml restart
|
||||
```
|
||||
|
||||
### Update Bot-Specific Settings
|
||||
|
||||
Edit the specific `.env.userX` file and restart that bot:
|
||||
|
||||
```bash
|
||||
# Edit bot 1's settings
|
||||
nano .env.user1
|
||||
|
||||
# Restart only bot 1
|
||||
docker restart nanobot-user1
|
||||
```
|
||||
|
||||
## Environment Variable Format
|
||||
|
||||
Nanobot uses Pydantic's `BaseSettings` with:
|
||||
- Prefix: `NANOBOT_`
|
||||
- Nested delimiter: `__` (double underscore)
|
||||
|
||||
Examples:
|
||||
- `NANOBOT_PROVIDERS__OPENROUTER__API_KEY` → `providers.openrouter.apiKey`
|
||||
- `NANOBOT_AGENTS__DEFAULTS__MODEL` → `agents.defaults.model`
|
||||
- `NANOBOT_AGENTS__DEFAULTS__TEMPERATURE` → `agents.defaults.temperature`
|
||||
|
||||
## Advantages
|
||||
|
||||
✅ **Easy to edit** - Plain text files, no JSON syntax
|
||||
✅ **Clear separation** - Shared vs bot-specific settings
|
||||
✅ **Flexible** - Override only what you need
|
||||
✅ **Version control friendly** - Can commit `.env.shared`, ignore `.env.userX` if they contain secrets
|
||||
✅ **No config.json editing** - Only edit env files for most changes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Settings Not Applied
|
||||
|
||||
1. Check if env files exist:
|
||||
```bash
|
||||
ls -la .env.*
|
||||
```
|
||||
|
||||
2. Check what's loaded in container:
|
||||
```bash
|
||||
docker exec nanobot-user1 env | grep NANOBOT
|
||||
```
|
||||
|
||||
3. Restart container after changes:
|
||||
```bash
|
||||
docker restart nanobot-user1
|
||||
```
|
||||
|
||||
### Override Not Working
|
||||
|
||||
Remember: Later files override earlier ones. If `.env.user1` has a setting but it's not applied, check:
|
||||
- Is the variable name correct? (use `__` not `.`)
|
||||
- Did you restart the container?
|
||||
- Is the setting in `.env.shared` overriding it? (remove from `.env.shared` if you want bot-specific only)
|
||||
|
||||
|
||||
@ -1,222 +0,0 @@
|
||||
# Managing Multiple Bot Configs Efficiently
|
||||
|
||||
Yes, with Docker you'll need to manage multiple config files, but here are strategies to make it easier:
|
||||
|
||||
## The Challenge
|
||||
|
||||
When you have 3 bots, you have 3 config files:
|
||||
- `~/.nanobot-user1/config.json`
|
||||
- `~/.nanobot-user2/config.json`
|
||||
- `~/.nanobot-user3/config.json`
|
||||
|
||||
If you need to change something like:
|
||||
- API key (shared across all bots)
|
||||
- Model settings (might be shared)
|
||||
- Tool configurations (might be shared)
|
||||
|
||||
You'd normally need to edit all 3 files.
|
||||
|
||||
## Solution 1: Use Environment Variables for Shared Settings
|
||||
|
||||
Nanobot supports environment variables! You can set shared settings via environment variables in Docker.
|
||||
|
||||
### Update docker-compose.multi.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nanobot-user1:
|
||||
# ... existing config ...
|
||||
environment:
|
||||
# Shared settings - set once, applies to all
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY: "sk-or-v1-xxx"
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL: "anthropic/claude-opus-4-5"
|
||||
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE: "0.7"
|
||||
# Bot-specific settings still in config.json
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
|
||||
nanobot-user2:
|
||||
# ... same environment variables ...
|
||||
environment:
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY: "sk-or-v1-xxx"
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL: "anthropic/claude-opus-4-5"
|
||||
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE: "0.7"
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
```
|
||||
|
||||
**Better yet**, use a shared `.env` file:
|
||||
|
||||
### Create `.env.shared`:
|
||||
|
||||
```bash
|
||||
# Shared settings for all bots
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
|
||||
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
|
||||
NANOBOT_AGENTS__DEFAULTS__MAX_TOKENS=8192
|
||||
```
|
||||
|
||||
### Update docker-compose.multi.yml:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nanobot-user1:
|
||||
env_file:
|
||||
- .env.shared # Load shared settings
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
# ... rest of config ...
|
||||
|
||||
nanobot-user2:
|
||||
env_file:
|
||||
- .env.shared # Same shared settings
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
# ... rest of config ...
|
||||
```
|
||||
|
||||
Now you only update `.env.shared` for shared settings!
|
||||
|
||||
## Solution 2: Minimal Configs + Environment Variables
|
||||
|
||||
Keep only bot-specific settings in config files:
|
||||
|
||||
**`~/.nanobot-user1/config.json`** (minimal):
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "BOT_TOKEN_1",
|
||||
"allowFrom": ["USER_ID_1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`~/.nanobot-user2/config.json`** (minimal):
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "BOT_TOKEN_2",
|
||||
"allowFrom": ["USER_ID_2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Everything else comes from `.env.shared`!
|
||||
|
||||
## Solution 3: Use the Update Script
|
||||
|
||||
I've created `update-multi-configs.sh` to batch-update configs:
|
||||
|
||||
```bash
|
||||
# Update API key in all configs
|
||||
./update-multi-configs.sh update-api-key openrouter "sk-or-v1-new-key"
|
||||
|
||||
# Update model in all configs
|
||||
./update-multi-configs.sh update-model "anthropic/claude-opus-4-5"
|
||||
|
||||
# Update any setting
|
||||
./update-multi-configs.sh update-setting "agents.defaults.temperature" "0.8"
|
||||
```
|
||||
|
||||
Requires `jq` to be installed:
|
||||
```bash
|
||||
sudo apt install jq # Linux
|
||||
brew install jq # macOS
|
||||
```
|
||||
|
||||
## Solution 4: Base Config Template
|
||||
|
||||
Create a template and only override what's different:
|
||||
|
||||
**`~/.nanobot-base/config.json`** (template):
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"temperature": 0.7
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "REPLACE_WITH_BOT_TOKEN",
|
||||
"allowFrom": ["REPLACE_WITH_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then create bot-specific configs by copying and modifying:
|
||||
|
||||
```bash
|
||||
cp ~/.nanobot-base/config.json ~/.nanobot-user1/config.json
|
||||
# Edit only the telegram token and user ID
|
||||
|
||||
cp ~/.nanobot-base/config.json ~/.nanobot-user2/config.json
|
||||
# Edit only the telegram token and user ID
|
||||
```
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
**Use Solution 1 (Environment Variables)** - it's the cleanest:
|
||||
|
||||
1. Create `.env.shared` with all shared settings
|
||||
2. Keep minimal configs with only bot-specific settings (Telegram tokens/user IDs)
|
||||
3. Update `.env.shared` when you need to change shared settings
|
||||
4. Restart containers to apply changes
|
||||
|
||||
This way:
|
||||
- ✅ Shared settings: Update once in `.env.shared`
|
||||
- ✅ Bot-specific settings: Only in each config.json
|
||||
- ✅ Easy to manage and maintain
|
||||
|
||||
## Example: Complete Setup
|
||||
|
||||
**`.env.shared`**:
|
||||
```bash
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
|
||||
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
|
||||
NANOBOT_AGENTS__DEFAULTS__MAX_TOKENS=8192
|
||||
```
|
||||
|
||||
**`~/.nanobot-user1/config.json`**:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "1234567890:ABC...",
|
||||
"allowFrom": ["123456789"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`docker-compose.multi.yml`**:
|
||||
```yaml
|
||||
services:
|
||||
nanobot-user1:
|
||||
env_file:
|
||||
- .env.shared
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
# ... rest ...
|
||||
```
|
||||
|
||||
Now when you need to change the API key or model, just edit `.env.shared` and restart!
|
||||
|
||||
|
||||
@ -1,261 +0,0 @@
|
||||
# Running Multiple Nanobot Gateways with Docker
|
||||
|
||||
This guide shows you how to run multiple nanobot gateway instances, each with its own Telegram bot.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Setup Config Directories
|
||||
|
||||
Run the setup script:
|
||||
|
||||
```bash
|
||||
./multi-bot-setup.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
# Create directories
|
||||
mkdir -p ~/.nanobot-user1
|
||||
mkdir -p ~/.nanobot-user2
|
||||
mkdir -p ~/.nanobot-user3
|
||||
|
||||
# Copy your base config (if you have one)
|
||||
cp ~/.nanobot/config.json ~/.nanobot-user1/config.json
|
||||
cp ~/.nanobot/config.json ~/.nanobot-user2/config.json
|
||||
cp ~/.nanobot/config.json ~/.nanobot-user3/config.json
|
||||
```
|
||||
|
||||
### Step 2: Configure Each Bot
|
||||
|
||||
Edit each config file with different Telegram bot tokens:
|
||||
|
||||
**`~/.nanobot-user1/config.json`:**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allowFrom": ["123456789"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`~/.nanobot-user2/config.json`:**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "9876543210:XYZabcDEFghiJKLmnopQRSuvwx",
|
||||
"allowFrom": ["987654321"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`~/.nanobot-user3/config.json`:**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "5551234567:LMNopqRSTuvwXYZabcdefGHIjkl",
|
||||
"allowFrom": ["555123456"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Run with Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Build the image (first time only)
|
||||
docker compose -f docker-compose.multi.yml build
|
||||
|
||||
# Start all bots
|
||||
docker compose -f docker-compose.multi.yml up -d
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.multi.yml logs -f
|
||||
|
||||
# Stop all bots
|
||||
docker compose -f docker-compose.multi.yml down
|
||||
|
||||
# Start/stop individual bots
|
||||
docker compose -f docker-compose.multi.yml start nanobot-user1
|
||||
docker compose -f docker-compose.multi.yml stop nanobot-user2
|
||||
```
|
||||
|
||||
### Step 4: Run with Docker Run Commands (Alternative)
|
||||
|
||||
If you prefer `docker run` commands:
|
||||
|
||||
```bash
|
||||
# Build the image first
|
||||
docker build -t nanobot .
|
||||
|
||||
# Run bot 1
|
||||
docker run -d \
|
||||
--name nanobot-user1 \
|
||||
-v ~/.nanobot-user1:/root/.nanobot \
|
||||
-p 18790:18790 \
|
||||
--restart unless-stopped \
|
||||
nanobot gateway
|
||||
|
||||
# Run bot 2
|
||||
docker run -d \
|
||||
--name nanobot-user2 \
|
||||
-v ~/.nanobot-user2:/root/.nanobot \
|
||||
-p 18791:18790 \
|
||||
--restart unless-stopped \
|
||||
nanobot gateway
|
||||
|
||||
# Run bot 3
|
||||
docker run -d \
|
||||
--name nanobot-user3 \
|
||||
-v ~/.nanobot-user3:/root/.nanobot \
|
||||
-p 18792:18790 \
|
||||
--restart unless-stopped \
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Managing Containers
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# All bots
|
||||
docker compose -f docker-compose.multi.yml logs -f
|
||||
|
||||
# Specific bot
|
||||
docker logs -f nanobot-user1
|
||||
|
||||
# Or with docker-compose
|
||||
docker compose -f docker-compose.multi.yml logs -f nanobot-user1
|
||||
```
|
||||
|
||||
### Stop/Start Containers
|
||||
|
||||
```bash
|
||||
# Stop all
|
||||
docker compose -f docker-compose.multi.yml down
|
||||
|
||||
# Start all
|
||||
docker compose -f docker-compose.multi.yml up -d
|
||||
|
||||
# Restart specific bot
|
||||
docker restart nanobot-user1
|
||||
|
||||
# Stop specific bot
|
||||
docker stop nanobot-user1
|
||||
docker start nanobot-user1
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```bash
|
||||
# List all running containers
|
||||
docker ps | grep nanobot
|
||||
|
||||
# Check logs for errors
|
||||
docker logs nanobot-user1 --tail 50
|
||||
```
|
||||
|
||||
## Port Mapping
|
||||
|
||||
Each bot uses a different host port:
|
||||
- **User 1**: Port `18790` → Container port `18790`
|
||||
- **User 2**: Port `18791` → Container port `18790`
|
||||
- **User 3**: Port `18792` → Container port `18790`
|
||||
|
||||
The gateway port inside the container is always `18790`, but mapped to different host ports to avoid conflicts.
|
||||
|
||||
## Creating Telegram Bots
|
||||
|
||||
For each user, create a separate bot:
|
||||
|
||||
1. Open Telegram, search `@BotFather`
|
||||
2. Send `/newbot`
|
||||
3. Follow prompts to create a bot
|
||||
4. Copy the token
|
||||
5. Add it to the respective config file
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot not responding
|
||||
|
||||
```bash
|
||||
# Check if container is running
|
||||
docker ps | grep nanobot-user1
|
||||
|
||||
# Check logs for errors
|
||||
docker logs nanobot-user1
|
||||
|
||||
# Verify config is correct
|
||||
cat ~/.nanobot-user1/config.json | jq '.channels.telegram'
|
||||
```
|
||||
|
||||
### Port already in use
|
||||
|
||||
If you get port conflicts, change the port mappings in `docker-compose.multi.yml`:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "18890:18790" # Change 18790 to 18890
|
||||
```
|
||||
|
||||
### Config not loading
|
||||
|
||||
Make sure the volume mount is correct:
|
||||
```bash
|
||||
# Verify config exists
|
||||
ls -la ~/.nanobot-user1/config.json
|
||||
|
||||
# Check if it's readable
|
||||
docker exec nanobot-user1 cat /root/.nanobot/config.json
|
||||
```
|
||||
|
||||
## Adding More Bots
|
||||
|
||||
To add more bots:
|
||||
|
||||
1. Create new directory: `mkdir -p ~/.nanobot-user4`
|
||||
2. Copy config: `cp ~/.nanobot-user1/config.json ~/.nanobot-user4/config.json`
|
||||
3. Edit config with new bot token
|
||||
4. Add new service to `docker-compose.multi.yml` (copy existing service, change name and port)
|
||||
5. Run: `docker compose -f docker-compose.multi.yml up -d nanobot-user4`
|
||||
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
# Quick Reference Card
|
||||
|
||||
## 🚀 Most Common Commands
|
||||
|
||||
### Start/Stop
|
||||
|
||||
```bash
|
||||
# Start user1 only
|
||||
docker compose -f docker-compose.multi.env.yml up -d nanobot-user1
|
||||
|
||||
# Start all bots
|
||||
docker compose -f docker-compose.multi.env.yml up -d
|
||||
|
||||
# Stop user1 only
|
||||
docker compose -f docker-compose.multi.env.yml stop nanobot-user1
|
||||
|
||||
# Stop all bots
|
||||
docker compose -f docker-compose.multi.env.yml down
|
||||
|
||||
# Restart user1
|
||||
docker compose -f docker-compose.multi.env.yml restart nanobot-user1
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# View logs (follow)
|
||||
docker logs -f nanobot-user1
|
||||
|
||||
# View last 50 lines
|
||||
docker logs --tail 50 nanobot-user1
|
||||
```
|
||||
|
||||
### Status
|
||||
|
||||
```bash
|
||||
# Check what's running
|
||||
docker compose -f docker-compose.multi.env.yml ps
|
||||
|
||||
# Check specific container
|
||||
docker ps | grep nanobot-user1
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Edit shared settings
|
||||
nano .env.shared
|
||||
docker compose -f docker-compose.multi.env.yml restart
|
||||
|
||||
# Edit bot-specific settings
|
||||
nano .env.user1
|
||||
docker restart nanobot-user1
|
||||
|
||||
# Edit config file
|
||||
nano ~/.nanobot-user1/config.json
|
||||
docker restart nanobot-user1
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Use dev mode (mounts source code)
|
||||
docker compose -f docker-compose.multi.dev.yml up -d nanobot-user1
|
||||
|
||||
# After code changes
|
||||
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
|
||||
```
|
||||
|
||||
## 📁 File Locations
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Shared settings | `.env.shared` |
|
||||
| Bot 1 settings | `.env.user1` |
|
||||
| Bot 1 config | `~/.nanobot-user1/config.json` |
|
||||
| Production compose | `docker-compose.multi.env.yml` |
|
||||
| Dev compose | `docker-compose.multi.dev.yml` |
|
||||
|
||||
## 🧭 Which Compose File?
|
||||
|
||||
| File | Best For | Uses env files? |
|
||||
|------|----------|-----------------|
|
||||
| `docker-compose.yml` | Single bot (`nanobot-gateway` + optional `nanobot-cli`) | No |
|
||||
| `docker-compose.multi.yml` | Multi-bot with per-user config directories | No |
|
||||
| `docker-compose.multi.env.yml` | Multi-bot with shared + per-user env overrides (recommended) | Yes: `.env.shared` then `.env.userX` |
|
||||
| `docker-compose.multi.dev.yml` | Same as above, but with source code mounted for development | Yes: `.env.shared` then `.env.userX` |
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
```bash
|
||||
# Bot not responding?
|
||||
docker logs nanobot-user1 --tail 50
|
||||
|
||||
# Connection error?
|
||||
grep API_BASE .env.shared # Should be 172.17.0.1:11434
|
||||
|
||||
# Config not loading?
|
||||
docker exec nanobot-user1 cat /root/.nanobot/config.json
|
||||
```
|
||||
|
||||
## 📖 Full Documentation
|
||||
|
||||
See `DOCKER_MULTI_BOT_GUIDE.md` for complete guide.
|
||||
|
||||
|
||||
11
README.md
11
README.md
@ -742,8 +742,6 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
|
||||
|
||||
nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external tool servers and use them as native agent tools.
|
||||
|
||||
For a full Gmail MCP walkthrough (config + OAuth + verification), see [`docs/gmail_mcp_setup.md`](docs/gmail_mcp_setup.md).
|
||||
|
||||
Add MCP servers to your `config.json`:
|
||||
|
||||
```json
|
||||
@ -829,15 +827,6 @@ vim ~/.nanobot/config.json # add API keys
|
||||
docker compose up -d nanobot-gateway # start gateway
|
||||
```
|
||||
|
||||
#### Which Compose File To Use?
|
||||
|
||||
| File | Scenario | Notes |
|
||||
|------|----------|-------|
|
||||
| `docker-compose.yml` | Single-bot local usage | One gateway + optional CLI, mounts `~/.nanobot` |
|
||||
| `docker-compose.multi.yml` | Multi-bot with separate per-user config directories | No `env_file`; use `~/.nanobot-userX/config.json` per bot |
|
||||
| `docker-compose.multi.env.yml` | Multi-bot with shared and per-user environment overrides | Loads `.env.shared` then `.env.userX` (recommended for multi-bot ops) |
|
||||
| `docker-compose.multi.dev.yml` | Multi-bot development | Same env layering as `multi.env`, plus source mount for live code iteration |
|
||||
|
||||
```bash
|
||||
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
|
||||
docker compose logs -f nanobot-gateway # view logs
|
||||
|
||||
116
SETUP_SUMMARY.md
116
SETUP_SUMMARY.md
@ -1,116 +0,0 @@
|
||||
# Multi-Bot Docker Setup Summary
|
||||
|
||||
## ✅ Setup Complete
|
||||
|
||||
### Files Created
|
||||
|
||||
1. **Environment Files:**
|
||||
- `.env.shared` - Shared settings (providers, agents, tools, gateway)
|
||||
- `.env.user1` - Bot 1 specific settings (Telegram token, email credentials)
|
||||
- `.env.user2` - Bot 2 specific settings (placeholder)
|
||||
- `.env.user3` - Bot 3 specific settings (placeholder)
|
||||
|
||||
2. **Config Files:**
|
||||
- `~/.nanobot-user1/config.json` - Bot 1 channel config (allowFrom arrays)
|
||||
- `~/.nanobot-user2/config.json` - Bot 2 channel config (placeholder)
|
||||
- `~/.nanobot-user3/config.json` - Bot 3 channel config (placeholder)
|
||||
|
||||
3. **Docker Compose:**
|
||||
- `docker-compose.multi.env.yml` - Multi-bot Docker Compose configuration
|
||||
|
||||
## 🔍 How It Works
|
||||
|
||||
### Configuration Loading Order
|
||||
|
||||
1. **Docker Compose loads environment files:**
|
||||
- First: `.env.shared` (shared settings)
|
||||
- Second: `.env.user1` (bot-specific overrides)
|
||||
- Later files override earlier ones ✅
|
||||
|
||||
2. **Container starts with volume mount:**
|
||||
- Host: `~/.nanobot-user1`
|
||||
- Container: `/root/.nanobot`
|
||||
- This maps your config directory into the container ✅
|
||||
|
||||
3. **Nanobot loads configuration:**
|
||||
- **Environment variables**: Automatically loaded by Pydantic `BaseSettings`
|
||||
- Format: `NANOBOT_CHANNELS__TELEGRAM__TOKEN=...`
|
||||
- These come from Docker environment (loaded from `.env.shared` + `.env.user1`)
|
||||
|
||||
- **Config file**: Loaded from `/root/.nanobot/config.json`
|
||||
- Inside container: `/root/.nanobot/config.json`
|
||||
- On host: `~/.nanobot-user1/config.json` (mounted)
|
||||
- Contains: `allowFrom` arrays (can't be in env vars)
|
||||
|
||||
### Important: Original Config is NOT Used
|
||||
|
||||
**Your original config** (`~/.nanobot/config.json`) is **NOT** used by Docker containers.
|
||||
|
||||
Each container uses:
|
||||
- Its own env files (`.env.shared` + `.env.userX`)
|
||||
- Its own config directory (`~/.nanobot-userX`)
|
||||
|
||||
This means:
|
||||
- ✅ Original config stays untouched
|
||||
- ✅ Each bot has isolated configuration
|
||||
- ✅ No conflicts between bots
|
||||
|
||||
## 📋 Current Configuration
|
||||
|
||||
### Bot 1 (nanobot-user1)
|
||||
|
||||
**Environment Variables** (from `.env.shared` + `.env.user1`):
|
||||
- Provider: Custom/Ollama (`http://localhost:11434/v1`)
|
||||
- Model: `llama3.1:8b`
|
||||
- Workspace: `/mnt/data/nanobot`
|
||||
- Telegram: Enabled with token
|
||||
- Email: Enabled with credentials
|
||||
|
||||
**Config File** (`~/.nanobot-user1/config.json`):
|
||||
- Telegram `allowFrom`: `["TADec2023"]`
|
||||
- Email `allowFrom`: `["adayear2025@gmail.com"]`
|
||||
|
||||
## 🚀 Running the Bots
|
||||
|
||||
```bash
|
||||
# Build Docker image (first time)
|
||||
docker compose -f docker-compose.multi.env.yml build
|
||||
|
||||
# Start all bots
|
||||
docker compose -f docker-compose.multi.env.yml up -d
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.multi.env.yml logs -f
|
||||
|
||||
# Stop all bots
|
||||
docker compose -f docker-compose.multi.env.yml down
|
||||
|
||||
# Restart specific bot
|
||||
docker restart nanobot-user1
|
||||
```
|
||||
|
||||
## ✅ Verification Checklist
|
||||
|
||||
- [x] `.env.shared` created with shared settings
|
||||
- [x] `.env.user1` created with bot-specific settings
|
||||
- [x] `ALLOW_FROM` removed from env files (arrays belong in config.json)
|
||||
- [x] Config directories created (`~/.nanobot-user1`, etc.)
|
||||
- [x] Config files created with `allowFrom` arrays
|
||||
- [x] Docker Compose file configured correctly
|
||||
- [x] Volume mounts map host configs to container paths
|
||||
|
||||
## 🎯 Key Points
|
||||
|
||||
1. **Environment Variables** → For simple key-value settings (tokens, API keys, models)
|
||||
2. **Config Files** → For complex settings (arrays like `allowFrom`)
|
||||
3. **Docker Mounts** → Each container gets its own config directory
|
||||
4. **Original Config** → Not used by Docker containers (stays safe)
|
||||
|
||||
## 📝 Next Steps
|
||||
|
||||
1. Update `.env.user2` and `.env.user3` with bot-specific settings
|
||||
2. Update `~/.nanobot-user2/config.json` and `~/.nanobot-user3/config.json` with actual user IDs/emails
|
||||
3. Run `docker compose -f docker-compose.multi.env.yml up -d` to start all bots
|
||||
4. Check logs to verify each bot is using correct configuration
|
||||
|
||||
|
||||
@ -1,125 +0,0 @@
|
||||
# Verifying Docker Multi-Bot Setup
|
||||
|
||||
## ✅ Configuration Check
|
||||
|
||||
### 1. Environment Files
|
||||
|
||||
**`.env.shared`** - Contains:
|
||||
- ✅ Provider settings (custom/Ollama)
|
||||
- ✅ Agent defaults (model, workspace, temperature, etc.)
|
||||
- ✅ Tool settings
|
||||
- ✅ Gateway settings
|
||||
- ✅ Email IMAP/SMTP settings (shared)
|
||||
|
||||
**`.env.user1`** - Contains:
|
||||
- ✅ Telegram token (bot-specific)
|
||||
- ✅ Email credentials (bot-specific override)
|
||||
- ⚠️ **Issue**: `ALLOW_FROM` should NOT be in env files (arrays don't work in env vars)
|
||||
|
||||
### 2. Config Files
|
||||
|
||||
**`~/.nanobot-user1/config.json`** - Contains:
|
||||
- ✅ Telegram `allowFrom` array (correct location)
|
||||
- ✅ Email `allowFrom` array (correct location)
|
||||
|
||||
### 3. Docker Compose
|
||||
|
||||
**`docker-compose.multi.env.yml`** - Correctly configured:
|
||||
- ✅ Loads `.env.shared` first
|
||||
- ✅ Loads `.env.user1` second (overrides shared)
|
||||
- ✅ Mounts `~/.nanobot-user1:/root/.nanobot` (maps host config to container)
|
||||
|
||||
## 🔍 How Nanobot Loads Config in Docker
|
||||
|
||||
1. **Environment Variables** (from `.env.shared` and `.env.user1`):
|
||||
- Loaded by Docker Compose into container environment
|
||||
- Nanobot's Pydantic `BaseSettings` reads them automatically
|
||||
- Format: `NANOBOT_CHANNELS__TELEGRAM__TOKEN=...`
|
||||
|
||||
2. **Config File** (`config.json`):
|
||||
- Path inside container: `/root/.nanobot/config.json`
|
||||
- Maps to host: `~/.nanobot-user1/config.json`
|
||||
- Loaded by `load_config()` which calls `get_config_path()`
|
||||
- `get_config_path()` returns `Path.home() / ".nanobot" / "config.json"`
|
||||
- In Docker, `Path.home()` = `/root`, so it reads `/root/.nanobot/config.json`
|
||||
- This is mounted from `~/.nanobot-user1/config.json` on host ✅
|
||||
|
||||
## ⚠️ Issues Found
|
||||
|
||||
### Issue 1: ALLOW_FROM in env file
|
||||
**Problem**: `.env.user1` has:
|
||||
```bash
|
||||
NANOBOT_CHANNELS__TELEGRAM__ALLOW_FROM=["TADec2023"]
|
||||
NANOBOT_CHANNELS__EMAIL__ALLOW_FROM=["adayear2025@gmail.com"]
|
||||
```
|
||||
|
||||
**Why it's wrong**: Environment variables can't handle JSON arrays. These will be treated as strings, not arrays.
|
||||
|
||||
**Fix**: Remove these from `.env.user1` - they're already correctly in `config.json`:
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"allowFrom": ["TADec2023"]
|
||||
},
|
||||
"email": {
|
||||
"allowFrom": ["adayear2025@gmail.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Issue 2: Duplicate email settings
|
||||
**Problem**: Email IMAP/SMTP settings are in both `.env.shared` and `.env.user1`
|
||||
|
||||
**Recommendation**:
|
||||
- If all bots use same email account → Keep only in `.env.shared`
|
||||
- If each bot uses different email → Keep only in `.env.userX` files
|
||||
|
||||
## ✅ Verification Steps
|
||||
|
||||
1. **Check env files don't have ALLOW_FROM**:
|
||||
```bash
|
||||
grep ALLOW_FROM .env.user1
|
||||
# Should return nothing or be removed
|
||||
```
|
||||
|
||||
2. **Check config files have allowFrom**:
|
||||
```bash
|
||||
cat ~/.nanobot-user1/config.json | jq '.channels.telegram.allowFrom'
|
||||
# Should show: ["TADec2023"]
|
||||
```
|
||||
|
||||
3. **Verify Docker mounts**:
|
||||
```bash
|
||||
docker compose -f docker-compose.multi.env.yml config | grep -A 5 "nanobot-user1"
|
||||
# Should show volume mount: ~/.nanobot-user1:/root/.nanobot
|
||||
```
|
||||
|
||||
4. **Test config loading in container**:
|
||||
```bash
|
||||
docker run --rm -v ~/.nanobot-user1:/root/.nanobot \
|
||||
-e NANOBOT_CHANNELS__TELEGRAM__TOKEN=test \
|
||||
nanobot gateway --help
|
||||
```
|
||||
|
||||
## 🎯 Summary
|
||||
|
||||
**What's Correct:**
|
||||
- ✅ Docker Compose loads env files correctly
|
||||
- ✅ Config files are in correct locations
|
||||
- ✅ Volume mounts map host configs to container paths
|
||||
- ✅ Nanobot will read from `/root/.nanobot/config.json` inside container
|
||||
|
||||
**What Needs Fixing:**
|
||||
- ⚠️ Remove `ALLOW_FROM` from `.env.user1` (keep only in config.json)
|
||||
- ⚠️ Decide: Email settings in `.env.shared` OR `.env.userX` (not both)
|
||||
|
||||
**How It Works:**
|
||||
1. Docker Compose loads `.env.shared` → sets environment variables
|
||||
2. Docker Compose loads `.env.user1` → overrides with bot-specific vars
|
||||
3. Container starts → mounts `~/.nanobot-user1` to `/root/.nanobot`
|
||||
4. Nanobot starts → reads env vars (from Docker) + config.json (from mount)
|
||||
5. Result: Bot uses combined settings from env vars + config.json ✅
|
||||
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
# Agent workspace skeletons
|
||||
|
||||
These directories are **templates** for per-agent workspaces on the host:
|
||||
|
||||
`~/.nanobot/workspaces/ilia/`
|
||||
`~/.nanobot/workspaces/family/`
|
||||
`~/.nanobot/workspaces/wife/`
|
||||
|
||||
Each contains bootstrap files (`AGENTS.md`, `USER.md`, `SOUL.md`) and `memory/` (`MEMORY.md`, `HISTORY.md`) loaded by nanobot’s `ContextBuilder` and `MemoryStore`.
|
||||
|
||||
## Initialise on the host
|
||||
|
||||
From the repo root (after clone):
|
||||
|
||||
```bash
|
||||
chmod +x scripts/init-agent-workspaces.sh
|
||||
./scripts/init-agent-workspaces.sh
|
||||
```
|
||||
|
||||
Override destination root (default `$HOME/.nanobot`):
|
||||
|
||||
```bash
|
||||
NANOBOT_HOME=/path/to/.nanobot ./scripts/init-agent-workspaces.sh
|
||||
```
|
||||
|
||||
The script **does not overwrite** existing files so you can safely re-run after editing.
|
||||
|
||||
## Docker
|
||||
|
||||
Multi-bot compose mounts each path into `/workspace` in the matching container. See `DOCKER_MULTI_BOT_GUIDE.md`.
|
||||
@ -1,14 +0,0 @@
|
||||
# @family — Agent instructions
|
||||
|
||||
You are the **family** assistant: shared calendar, household coordination, and kid- or home-related questions.
|
||||
|
||||
## Scope
|
||||
- Schedules, reminders, and “what’s this week” style questions.
|
||||
- Simple web lookups (school, activities, recipes) when tools allow.
|
||||
- Warm, inclusive language for all family members.
|
||||
|
||||
## Out of scope
|
||||
- Production servers, SSH, Proxmox, or source-code repositories unless explicitly asked by an adult and tools are available.
|
||||
|
||||
## Tone
|
||||
Friendly, organized, patient. Offer clear summaries and next steps.
|
||||
@ -1,7 +0,0 @@
|
||||
# Personality — @family
|
||||
|
||||
**Voice:** Warm, clear, and reassuring. Good with busy parents and kids’ contexts.
|
||||
|
||||
**Values:** Inclusivity, clarity on dates/times, respect for privacy between family members where relevant.
|
||||
|
||||
**Avoid:** Cold or corporate tone; assumption that everyone shares one email account.
|
||||
@ -1,8 +0,0 @@
|
||||
# User profile — Family
|
||||
|
||||
This workspace represents the **household** (not one individual). List members, ages if relevant, schools, and recurring commitments.
|
||||
|
||||
## Edit this file
|
||||
- Family members and how you refer to them.
|
||||
- Default calendar names or shared inboxes (if any).
|
||||
- Anything the agent should know for scheduling and coordination.
|
||||
@ -1,3 +0,0 @@
|
||||
# Event log — Family
|
||||
|
||||
Append-only style log for this household agent.
|
||||
@ -1,5 +0,0 @@
|
||||
# Long-term memory — Family
|
||||
|
||||
Household-level facts (recurring events, preferences, school names). **Do not store secrets** (passwords, full IDs).
|
||||
|
||||
_Empty placeholder — add bullet facts here over time._
|
||||
@ -1,14 +0,0 @@
|
||||
# @ilia — Agent instructions
|
||||
|
||||
You are the personal assistant for **Ilia**. You focus on development, homelab infrastructure, code review, and technical research.
|
||||
|
||||
## Scope
|
||||
- Software development (Gitea, PRs, issues, shell, git) and clear technical explanations.
|
||||
- Homelab / Proxmox / networking when those tools are available.
|
||||
- Email and calendar for Ilia’s accounts when configured.
|
||||
|
||||
## Tone
|
||||
Concise, accurate, and direct. Prefer actionable steps over long preambles.
|
||||
|
||||
## Tools
|
||||
Use nanobot tools as configured for this instance. Do not assume tools that are not in your tool list.
|
||||
@ -1,7 +0,0 @@
|
||||
# Personality — @ilia
|
||||
|
||||
**Voice:** Technical, calm, efficient. Short paragraphs. No fluff.
|
||||
|
||||
**Values:** Correctness, security-minded defaults, reproducible steps.
|
||||
|
||||
**Avoid:** Unnecessary apologies, over-explaining basic concepts unless asked.
|
||||
@ -1,7 +0,0 @@
|
||||
# User profile — Ilia
|
||||
|
||||
**Name:** Ilia
|
||||
**Role:** Primary operator of this nanobot stack; dev and infra.
|
||||
|
||||
## Edit this file
|
||||
Add preferences, timezone, important contacts, repos, and anything this agent should remember about *you* (not generic assistant behavior — that belongs in `SOUL.md` / `AGENTS.md`).
|
||||
@ -1,3 +0,0 @@
|
||||
# Event log — Ilia
|
||||
|
||||
Append-only style log. Search with grep when recalling past events.
|
||||
@ -1,5 +0,0 @@
|
||||
# Long-term memory — Ilia
|
||||
|
||||
Facts and preferences worth keeping across sessions. The agent may update this file when you confirm something should be remembered.
|
||||
|
||||
_Empty placeholder — add bullet facts here over time._
|
||||
@ -1,11 +0,0 @@
|
||||
# @wife — Agent instructions
|
||||
|
||||
You are the personal assistant for **Ilia’s wife**. Focus on her calendar, email (when connected), daily tasks, and practical lookups.
|
||||
|
||||
## Scope
|
||||
- Scheduling, reminders, messages, and life-admin tasks.
|
||||
- Summaries of mail or web pages when tools allow.
|
||||
- Respectful, private handling of personal topics.
|
||||
|
||||
## Tone
|
||||
Supportive and efficient. Match the user’s formality preferences over time.
|
||||
@ -1,7 +0,0 @@
|
||||
# Personality — @wife
|
||||
|
||||
**Voice:** Friendly, attentive, and tactful.
|
||||
|
||||
**Values:** Privacy, accuracy on appointments and commitments, gentle reminders.
|
||||
|
||||
**Avoid:** Dismissive or overly technical jargon unless the user prefers it.
|
||||
@ -1,6 +0,0 @@
|
||||
# User profile — Wife
|
||||
|
||||
**Name:** _(preferred name / how to address her)_
|
||||
|
||||
## Edit this file
|
||||
Add preferences, timezone, health or routine notes *you are comfortable storing in plain text*, and communication preferences.
|
||||
@ -1,3 +0,0 @@
|
||||
# Event log — Wife
|
||||
|
||||
Append-only style log. Search with grep when recalling past events.
|
||||
@ -1,5 +0,0 @@
|
||||
# Long-term memory — Wife
|
||||
|
||||
Facts and preferences worth keeping across sessions. The agent may update when you confirm.
|
||||
|
||||
_Empty placeholder — add bullet facts here over time._
|
||||
@ -1,76 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Create minimal config.json files for each bot
|
||||
# These only contain channel-specific settings that can't be in env vars
|
||||
|
||||
set -e
|
||||
|
||||
echo "Creating bot config files..."
|
||||
|
||||
# Bot 1 config
|
||||
cat > ~/.nanobot-user1/config.json << 'EOF'
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["TADec2023"]
|
||||
},
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["adayear2025@gmail.com"]
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Bot 2 config (placeholder - update with actual values)
|
||||
cat > ~/.nanobot-user2/config.json << 'EOF'
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"allowFrom": []
|
||||
},
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"allowFrom": []
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Bot 3 config (placeholder - update with actual values)
|
||||
cat > ~/.nanobot-user3/config.json << 'EOF'
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"allowFrom": []
|
||||
},
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"allowFrom": []
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✓ Created config files:"
|
||||
echo " - ~/.nanobot-user1/config.json"
|
||||
echo " - ~/.nanobot-user2/config.json"
|
||||
echo " - ~/.nanobot-user3/config.json"
|
||||
echo ""
|
||||
echo "Note: Telegram tokens come from .env.userX files"
|
||||
echo " Update allowFrom arrays with actual user IDs/emails"
|
||||
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
# user1=@ilia, user2=@family, user3=@wife — workspaces ~/.nanobot/workspaces/{ilia,family,wife}
|
||||
# Development version - mounts source code for live updates
|
||||
# Use this when developing nanobot code
|
||||
# Changes to nanobot/ directory will be picked up automatically (may need container restart)
|
||||
|
||||
services:
|
||||
nanobot-user1:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user1-dev
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user1
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/ilia:/workspace
|
||||
# Mount source code for development (changes picked up immediately)
|
||||
- ./nanobot:/app/nanobot:ro # Read-only mount (safer)
|
||||
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
# Or use this for read-write (if you edit inside container):
|
||||
# - ./nanobot:/app/nanobot
|
||||
ports:
|
||||
- "18790:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
nanobot-user2:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user2-dev
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user2
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/family:/workspace
|
||||
- ./nanobot:/app/nanobot:ro
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18791:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
nanobot-user3:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user3-dev
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user3
|
||||
volumes:
|
||||
- ~/.nanobot-user3:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/wife:/workspace
|
||||
- ./nanobot:/app/nanobot:ro
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18792:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
# Multi-bot: user1 = @ilia, user2 = @family, user3 = @wife (see ~/.nanobot/workspaces/* and scripts/init-agent-workspaces.sh).
|
||||
# Using separate env files per container:
|
||||
# - .env.shared: Common settings (API keys, model, etc.) - loaded first
|
||||
# - .env.user1, .env.user2, .env.user3: Bot-specific overrides - loaded after
|
||||
# Later files override earlier ones, so bot-specific settings take precedence
|
||||
|
||||
services:
|
||||
nanobot-user1:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user1
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared # Shared settings (loaded first)
|
||||
- .env.user1 # Bot-specific overrides (loaded second, overrides shared)
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/ilia:/workspace
|
||||
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18790:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
nanobot-user2:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user2
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared # Shared settings (loaded first)
|
||||
- .env.user2 # Bot-specific overrides (loaded second, overrides shared)
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/family:/workspace
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18791:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
nanobot-user3:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user3
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared # Shared settings (loaded first)
|
||||
- .env.user3 # Bot-specific overrides (loaded second, overrides shared)
|
||||
volumes:
|
||||
- ~/.nanobot-user3:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/wife:/workspace
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18792:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
# Multi-bot: nanobot-user1 = @ilia, user2 = @family, user3 = @wife.
|
||||
# Each container uses ~/.nanobot/workspaces/<name>/ → /workspace (run scripts/init-agent-workspaces.sh first).
|
||||
|
||||
services:
|
||||
nanobot-user1:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user1
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user1
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
# @ilia — isolated workspace + memory (host: ~/.nanobot/workspaces/ilia)
|
||||
- ~/.nanobot/workspaces/ilia:/workspace
|
||||
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18790:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
nanobot-user2:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user2
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user2
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
# @family — isolated workspace + memory
|
||||
- ~/.nanobot/workspaces/family:/workspace
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18791:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
nanobot-user3:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanobot-user3
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.shared
|
||||
- .env.user3
|
||||
volumes:
|
||||
- ~/.nanobot-user3:/root/.nanobot
|
||||
# @wife — isolated workspace + memory
|
||||
- ~/.nanobot/workspaces/wife:/workspace
|
||||
- ./mcp-servers:/app/mcp-servers:ro
|
||||
ports:
|
||||
- "18792:18790"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
@ -4,8 +4,6 @@ x-common-config: &common-config
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ~/.nanobot:/root/.nanobot
|
||||
# Host repo ./workspace → /workspace in container. Set agents.defaults.workspace to /workspace.
|
||||
- ./workspace:/workspace
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
|
||||
@ -1,573 +0,0 @@
|
||||
# MCP Integrations & Skills Backlog
|
||||
|
||||
> **Living document** — update this file as items are implemented, reprioritized, or new candidates emerge.
|
||||
>
|
||||
> Last updated: 2026-03-30
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Current State](#current-state)
|
||||
2. [Security: Local-Clone Policy](#security-local-clone-policy)
|
||||
3. [Shortlist — Next Phase](#shortlist--next-phase)
|
||||
4. [Backlog — Later](#backlog--later)
|
||||
5. [Skill Catalog](#skill-catalog)
|
||||
6. [Phase 1 Priorities](#phase-1-priorities)
|
||||
7. [Implementation Notes](#implementation-notes)
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
| Category | What we have today |
|
||||
|---|---|
|
||||
| **Built-in tools** | `filesystem` (read/write/edit/list), `exec` (shell), `web` (search + fetch), `message`, `spawn`, `cron`, `email` (IMAP), `calendar` (Google Calendar via built-in tool) |
|
||||
| **MCP servers** | 1 connected — Gmail MCP (`@gongrzhe/server-gmail-autoauth-mcp`, stdio/npx). See [docs/gmail_mcp_setup.md](gmail_mcp_setup.md). |
|
||||
| **Skills** | 10 bundled in `nanobot/skills/`: `github`, `gitea`, `calendar`, `cron`, `weather`, `summarize`, `tmux`, `clawhub`, `skill-creator`, `memory` |
|
||||
| **Agent architecture** | 3 named agents, each running as a **separate Docker container** with its own workspace, personality, and memory (Option B). See below. |
|
||||
| **Config schema** | `tools.mcpServers` → `MCPServerConfig` (stdio or HTTP), `tools.toolProfiles` → `ToolProfileConfig` can further filter tools within a single agent. See `nanobot/config/schema.py`. |
|
||||
|
||||
### Agent Workspaces
|
||||
|
||||
Each agent is a separate nanobot instance (Docker container) with an isolated workspace under `~/.nanobot/workspaces/`. The workspace contains bootstrap files (`AGENTS.md`, `SOUL.md`, `USER.md`) that define the agent's personality and instructions, plus a `memory/` directory for long-term memory that is private to that agent.
|
||||
|
||||
```
|
||||
~/.nanobot/workspaces/
|
||||
├── ilia/ # @ilia — personal dev, infra, research
|
||||
│ ├── AGENTS.md # Dev/infra-focused instructions
|
||||
│ ├── USER.md # Ilia's profile, preferences
|
||||
│ ├── SOUL.md # Personality: technical, concise
|
||||
│ └── memory/
|
||||
│ └── MEMORY.md
|
||||
├── family/ # @family — shared household agent
|
||||
│ ├── AGENTS.md # Family scheduling, coordination
|
||||
│ ├── USER.md # Family members, kids' info
|
||||
│ ├── SOUL.md # Personality: warm, organized
|
||||
│ └── memory/
|
||||
│ └── MEMORY.md
|
||||
└── wife/ # @wife — personal assistant for wife
|
||||
├── AGENTS.md # Personal tasks, calendar, email
|
||||
├── USER.md # Wife's profile, preferences
|
||||
├── SOUL.md # Personality: friendly, helpful
|
||||
└── memory/
|
||||
└── MEMORY.md
|
||||
```
|
||||
|
||||
Each container mounts its workspace and its own `config.json` (with agent-specific MCP servers, channels, and `allowFrom` lists). Compose service names are `nanobot-user1` … `user3`.
|
||||
|
||||
| Service | Persona | Config dir | Workspace (host → `/workspace`) | Typical channels |
|
||||
|---|---|---|---|---|
|
||||
| `nanobot-user1` | @ilia | `~/.nanobot-user1/` | `~/.nanobot/workspaces/ilia` | Telegram, email (Ilia) |
|
||||
| `nanobot-user2` | @family | `~/.nanobot-user2/` | `~/.nanobot/workspaces/family` | Family Telegram |
|
||||
| `nanobot-user3` | @wife | `~/.nanobot-user3/` | `~/.nanobot/workspaces/wife` | Telegram, email (wife) |
|
||||
|
||||
_Use `scripts/init-agent-workspaces.sh` to create the three workspace trees under `~/.nanobot/workspaces/`._
|
||||
|
||||
---
|
||||
|
||||
## Security: Local-Clone Policy
|
||||
|
||||
All new MCP servers are **cloned locally** into the repository rather than fetched at runtime from npm/PyPI registries. This gives us:
|
||||
|
||||
- **Audit control** — we can review every line before running it.
|
||||
- **Reproducibility** — pinned commits, no surprise upstream updates.
|
||||
- **Air-gap friendliness** — works on isolated networks after initial clone.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
nanobot/
|
||||
├── mcp-servers/ # <-- NEW: local MCP server clones
|
||||
│ ├── gitea-mcp/ # git clone from gitea.com/gitea/gitea-mcp
|
||||
│ ├── google-calendar-mcp/ # git clone from github.com/nspady/google-calendar-mcp
|
||||
│ ├── mcp-proxmox/ # git clone from github.com/antonio-mello-ai/mcp-proxmox
|
||||
│ └── fetch-browser/ # git clone from github.com/TheSethRose/Fetch-Browser
|
||||
├── nanobot/
|
||||
├── docs/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Config pattern (local stdio)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"command": "./mcp-servers/gitea-mcp/gitea-mcp",
|
||||
"args": ["--token", "$NANOBOT_GITLE_TOKEN", "--url", "http://10.0.30.169:3000"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each server's README in `mcp-servers/<name>/` documents build steps, required env vars, and update procedure.
|
||||
|
||||
---
|
||||
|
||||
## Shortlist — Next Phase
|
||||
|
||||
These are the 4 MCP servers we plan to integrate in the immediate next phase. Each entry is detailed enough to create implementation tickets directly.
|
||||
|
||||
---
|
||||
|
||||
### S1. Gitea MCP
|
||||
|
||||
| Field | Detail |
|
||||
|---|---|
|
||||
| **Upstream** | `gitea.com/gitea/gitea-mcp` (official, Go, v1.0.2, 56 stars, Apache-2.0) |
|
||||
| **Transport** | Stdio (recommended) or SSE |
|
||||
| **Auth** | Gitea personal-access token — reuse existing `$NANOBOT_GITLE_TOKEN` |
|
||||
| **Complexity** | **Low** — token and network route to `http://10.0.30.169:3000` already exist |
|
||||
| **Replaces** | Current curl-based `gitea` skill and hardcoded API commands in `AGENTS.md` |
|
||||
| **Target agents** | `@ilia` only (dev tooling; not exposed to `@family` or `@wife`) |
|
||||
|
||||
#### User stories
|
||||
|
||||
- **US-G1**: As `@ilia`, I can say "list open PRs on nanobot" and get a formatted summary without writing curl commands.
|
||||
- **US-G2**: As `@ilia`, I can say "search code for `MCPServerConfig`" and the agent returns matching files and lines from Gitea.
|
||||
- **US-G3**: As `@ilia`, I can say "create an issue titled 'Add Proxmox MCP' with label `enhancement`" and the agent creates it in Gitea.
|
||||
- **US-G4**: As `@ilia`, I can say "show diff for PR #42" and get a readable summary of changes.
|
||||
|
||||
#### Technical notes
|
||||
|
||||
- **Build**: Go 1.24+. Clone, `go build`, produces single binary `gitea-mcp`.
|
||||
- **Local clone path**: `mcp-servers/gitea-mcp/`
|
||||
- **Config entry**:
|
||||
```jsonc
|
||||
"gitea": {
|
||||
"command": "./mcp-servers/gitea-mcp/gitea-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GITEA_URL": "http://10.0.30.169:3000",
|
||||
"GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Expected tool names**: `mcp_gitea_list_repos`, `mcp_gitea_search_code`, `mcp_gitea_create_issue`, `mcp_gitea_list_pulls`, etc.
|
||||
- **Safety**: Read operations are safe. Issue/PR creation and file writes should require user confirmation via tool-profile constraints.
|
||||
|
||||
---
|
||||
|
||||
### S2. Google Calendar MCP
|
||||
|
||||
| Field | Detail |
|
||||
|---|---|
|
||||
| **Upstream** | `github.com/nspady/google-calendar-mcp` (TypeScript, v2.6.1, 1071 stars, MIT) |
|
||||
| **Transport** | Stdio via `node` |
|
||||
| **Auth** | Google OAuth2 (same pattern as Gmail MCP — credentials in `~/.gmail-mcp/`) |
|
||||
| **Complexity** | **Medium** — OAuth flow is already a solved pattern from Gmail MCP setup; multi-calendar config adds small overhead |
|
||||
| **Complements** | Existing built-in `calendar` tool; MCP version adds multi-calendar, recurring events, and free/busy queries |
|
||||
| **Target agents** | All three — `@ilia`, `@family`, `@wife` (each with their own calendar scope) |
|
||||
|
||||
#### User stories
|
||||
|
||||
- **US-C1**: As `@family`, I can ask "what's on the family calendar this week?" and get a merged view of all family members' events.
|
||||
- **US-C2**: As `@ilia`, I can say "find a free 1-hour slot tomorrow afternoon" and the agent checks busy/free across my calendars.
|
||||
- **US-C3**: As `@family`, I can say "add 'Soccer practice' to the family calendar on Saturday at 10am" and it creates the event.
|
||||
- **US-C4**: As `@ilia`, I can say "reschedule my 2pm meeting to 4pm" and the agent updates the event after confirmation.
|
||||
- **US-C5**: As `@wife`, I can say "what do I have on Thursday?" and see only events on my personal calendar.
|
||||
|
||||
#### Technical notes
|
||||
|
||||
- **Build**: `npm install` in cloned repo, run via `node dist/index.js`.
|
||||
- **Local clone path**: `mcp-servers/google-calendar-mcp/`
|
||||
- **OAuth setup**: Same Google Cloud project as Gmail MCP. Enable Calendar API, reuse existing OAuth client. Token stored alongside Gmail tokens.
|
||||
- **Config entry**:
|
||||
```jsonc
|
||||
"google_calendar": {
|
||||
"command": "node",
|
||||
"args": ["./mcp-servers/google-calendar-mcp/dist/index.js"],
|
||||
"env": {
|
||||
"GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Expected tool names**: `mcp_google_calendar_list_events`, `mcp_google_calendar_create_event`, `mcp_google_calendar_freebusy`, `mcp_google_calendar_update_event`, `mcp_google_calendar_delete_event`
|
||||
- **Migration path**: Phase out built-in `calendar` tool once MCP version is validated. Keep both available during transition via tool profiles.
|
||||
|
||||
---
|
||||
|
||||
### S3. Proxmox MCP
|
||||
|
||||
| Field | Detail |
|
||||
|---|---|
|
||||
| **Upstream** | `github.com/antonio-mello-ai/mcp-proxmox` (Python, pip-installable, MIT) |
|
||||
| **Transport** | Stdio via `python -m mcp_proxmox` |
|
||||
| **Auth** | Proxmox API token (user `nanobot@pam!mcp-token` + secret) |
|
||||
| **Complexity** | **Medium** — requires network route to Proxmox cluster API, API token creation on Proxmox, and careful permission scoping |
|
||||
| **New capability** | Homelab infrastructure visibility and management from chat |
|
||||
| **Target agents** | `@ilia` only (infrastructure admin; never exposed to `@family` or `@wife`) |
|
||||
|
||||
#### User stories
|
||||
|
||||
- **US-P1**: As `@ilia`, I can say "show me the status of all VMs" and get a table of names, states, CPU, and RAM usage.
|
||||
- **US-P2**: As `@ilia`, I can say "how much storage is left on the cluster?" and get aggregate numbers.
|
||||
- **US-P3**: As `@ilia`, I can say "restart the dev-runner VM" and the agent does so after asking for confirmation.
|
||||
- **US-P4**: As `@ilia`, I can say "take a snapshot of the nanobot VM before I upgrade" and the agent creates a named snapshot.
|
||||
|
||||
#### Technical notes
|
||||
|
||||
- **Build**: `pip install -e ./mcp-servers/mcp-proxmox/` into nanobot's venv, or use a dedicated venv.
|
||||
- **Local clone path**: `mcp-servers/mcp-proxmox/`
|
||||
- **Proxmox setup**:
|
||||
1. Create API token: Datacenter → Permissions → API Tokens → Add (`nanobot@pam`, token ID `mcp-token`).
|
||||
2. Assign minimum roles: `PVEAuditor` for read-only, `PVEVMAdmin` for lifecycle ops (Phase 1 starts read-only).
|
||||
3. Store token secret in `~/.nanobot/config.json` env or in a `.env` file.
|
||||
- **Config entry**:
|
||||
```jsonc
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **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`.
|
||||
|
||||
---
|
||||
|
||||
### S4. Web Fetch / Scraping MCP
|
||||
|
||||
| Field | Detail |
|
||||
|---|---|
|
||||
| **Upstream** | `github.com/TheSethRose/Fetch-Browser` (TypeScript, headless Chromium, MIT) |
|
||||
| **Alt candidate** | `github.com/odgrim/mcp-fetch` (TypeScript, Puppeteer, simpler) |
|
||||
| **Transport** | Stdio via `node` |
|
||||
| **Auth** | None — no API keys required |
|
||||
| **Complexity** | **Low** — clone, `npm install`, run; headless Chromium bundled by Puppeteer/Playwright |
|
||||
| **Augments** | Built-in `web_fetch` tool (which does basic HTTP GET without JS rendering) |
|
||||
| **Target agents** | All three — `@ilia`, `@family`, `@wife` |
|
||||
|
||||
#### User stories
|
||||
|
||||
- **US-W1**: As `@ilia`, I can say "fetch the Proxmox release notes page and summarize what's new" and the agent renders the JS-heavy page and extracts content.
|
||||
- **US-W2**: As `@family`, I can say "get the lunch menu from the school website" and the agent scrapes the dynamically loaded content.
|
||||
- **US-W3**: As `@ilia`, I can say "grab the pricing table from this SaaS page" and get structured data back.
|
||||
- **US-W4**: As `@wife`, I can say "find me the best-rated recipe for lasagna" and the agent fetches and summarizes real recipe pages.
|
||||
|
||||
#### Technical notes
|
||||
|
||||
- **Build**: `npm install` in cloned repo.
|
||||
- **Local clone path**: `mcp-servers/fetch-browser/`
|
||||
- **Config entry**:
|
||||
```jsonc
|
||||
"web_scraper": {
|
||||
"command": "node",
|
||||
"args": ["./mcp-servers/fetch-browser/dist/index.js"],
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
- **Expected tool names**: `mcp_web_scraper_fetch_url`, `mcp_web_scraper_search_google`, `mcp_web_scraper_screenshot`
|
||||
- **Resource note**: Headless Chromium uses ~200–400 MB RAM per instance. Consider setting a process timeout or pool limit.
|
||||
- **Safety**: Read-only by nature. No write side-effects. Safe for both `@ilia` and `@family`.
|
||||
|
||||
---
|
||||
|
||||
## Backlog — Later
|
||||
|
||||
Items below are future candidates, not yet scheduled. Grouped by domain. Each includes a candidate upstream project where one exists.
|
||||
|
||||
### Family / Life
|
||||
|
||||
| # | Integration | Upstream candidate | Notes |
|
||||
|---|---|---|---|
|
||||
| B-F1 | **CalDAV MCP** | `github.com/dominik1001/caldav-mcp` (Python, v0.4.0) | Universal calendar protocol. Enables Nextcloud, iCloud, ownCloud calendars. Useful if family moves off Google. |
|
||||
| B-F2 | **Shared Todo / Household Tasks MCP** | `github.com/thijs-hakkenberg/mcp_todo` (Python, git-backed) | Git-backed collaborative task list with assignees, due dates, priorities, Kanban web UI, and Telegram bot. Good fit for family chores and grocery lists. |
|
||||
| B-F3 | **Microsoft To Do MCP** | `github.com/akkilesh-a/microsoft-todo-mcp-server-self-hosted` (TypeScript) | Self-hosted HTTP transport. 15 tools for full task CRUD. Only relevant if family adopts Microsoft ecosystem. |
|
||||
| B-F4 | **Home Assistant MCP** | TBD (community projects emerging) | Smart home control — lights, thermostat, locks, sensors. Requires Home Assistant instance on LAN. |
|
||||
| B-F5 | **Shared Documents MCP** | TBD (Nextcloud WebDAV or Google Drive MCP) | Access family shared documents, photos, notes from chat. |
|
||||
|
||||
### Research
|
||||
|
||||
| # | Integration | Upstream candidate | Notes |
|
||||
|---|---|---|---|
|
||||
| B-R1 | **PDF RAG MCP** | `github.com/wesleygriffin/pdfrag` (Python, ChromaDB + sentence-transformers) | Semantic search over PDF papers. OCR support for scanned docs. Persistent vector index. |
|
||||
| B-R2 | **Knowledge Base / Notes RAG MCP** | `github.com/alejandro-ao/RAG-MCP` (Python, FastMCP + ChromaDB) | Ingest markdown notes, docs, slides. Query with natural language. Supports LlamaParse for multi-format ETL. |
|
||||
| B-R3 | **Zotero / Reference Manager MCP** | TBD | If user manages academic references in Zotero. Would expose library search, citation export, PDF retrieval. |
|
||||
| B-R4 | **Arxiv / Semantic Scholar MCP** | TBD (API wrappers exist) | Direct paper search and metadata retrieval from academic APIs. |
|
||||
|
||||
### Dev / Infra
|
||||
|
||||
| # | Integration | Upstream candidate | Notes |
|
||||
|---|---|---|---|
|
||||
| B-D1 | **Filesystem MCP** | `github.com/mark3labs/mcp-filesystem-server` (Go, 622 stars) | Richer file ops than nanobot built-in (search, diff, metadata, copy trees). Useful for workspace automation. |
|
||||
| B-D2 | **Docker / Portainer MCP** | `github.com/AI-Engineerings-at/homelab-mcp-bundle` (includes Portainer) | Container lifecycle, image management, compose operations. |
|
||||
| B-D3 | **CI/CD Pipeline MCP** | TBD (Gitea Actions API or Drone) | Query pipeline status, trigger builds, view logs. Partially achievable through Gitea MCP's API. |
|
||||
| B-D4 | **Logs & Monitoring MCP** | `github.com/AI-Engineerings-at/homelab-mcp-bundle` (includes Grafana, Uptime Kuma) | Query Grafana dashboards, check uptime monitors, search Loki logs. |
|
||||
| B-D5 | **Backup Status MCP** | TBD (Proxmox Backup Server API or restic wrapper) | Check last backup timestamps, success/failure, storage usage. Could be a thin wrapper skill rather than full MCP. |
|
||||
| B-D6 | **Database MCP** | TBD (PostgreSQL / SQLite MCP servers exist) | Run read-only queries against app databases for debugging and reporting. |
|
||||
|
||||
---
|
||||
|
||||
## Skill Catalog
|
||||
|
||||
Skills are higher-level task patterns that compose one or more tools (built-in or MCP) into a reusable workflow. Each skill lives as a `SKILL.md` in `nanobot/skills/<name>/` and is loaded by the skills system.
|
||||
|
||||
Because agents are **separate containers with separate workspaces**, a skill is available to an agent only if (a) the skill file is present in that workspace's `skills/` dir or in the shared bundled skills, and (b) the MCP servers it depends on are configured in that agent's `config.json`.
|
||||
|
||||
### Legend
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| **Skill** | Natural-language trigger name |
|
||||
| **Description** | What the skill does |
|
||||
| **MCP deps** | Which MCP servers must be connected in the agent's config |
|
||||
| **Built-in deps** | Which nanobot built-in tools are also needed |
|
||||
| **Target agents** | Which agent containers should have this skill deployed (`@ilia`, `@family`, `@wife`) |
|
||||
| **Safety tier** | `read-only` / `write-confirm` (mutates after user confirmation) / `admin` (restricted + confirm) |
|
||||
|
||||
---
|
||||
|
||||
### Scheduling Skills
|
||||
|
||||
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|
||||
|---|---|---|---|---|---|
|
||||
| **Plan my week** | List events across all calendars for the next 7 days, highlight conflicts, suggest time blocks for focus work | Google Calendar MCP | — | `@ilia`, `@family`, `@wife` | read-only |
|
||||
| **Reschedule meeting** | Find a specific event, propose 3 alternative conflict-free times, update the event after user picks one | Google Calendar MCP | — | `@ilia`, `@wife` | write-confirm |
|
||||
| **Find conflict-free times** | Query free/busy across calendars for a given duration and date range, return available slots | Google Calendar MCP | — | `@ilia`, `@family`, `@wife` | read-only |
|
||||
|
||||
### Email Skills
|
||||
|
||||
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|
||||
|---|---|---|---|---|---|
|
||||
| **Triage inbox** | Fetch unread emails, categorize by urgency (action-required / FYI / low-priority), surface top action items | Gmail MCP | `read_emails` | `@ilia`, `@wife` | read-only |
|
||||
| **Draft replies** | For each action-required email, generate a draft reply. Present drafts for user approval before sending | Gmail MCP | — | `@ilia`, `@wife` | write-confirm |
|
||||
| **Summarize today's mail** | Produce a concise digest of all emails received today, grouped by sender or topic | Gmail MCP | `read_emails` | `@ilia`, `@family`, `@wife` | read-only |
|
||||
|
||||
### Research Skills
|
||||
|
||||
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|
||||
|---|---|---|---|---|---|
|
||||
| **Find relevant papers** | Web-search for papers on a given topic, fetch top results, return title + abstract + URL for each | Web Fetch MCP | `web_search` | `@ilia` | read-only |
|
||||
| **Summarize URL/PDF** | Fetch a URL (with JS rendering if needed) or read a local PDF, produce a structured summary | Web Fetch MCP | `read_file` | `@ilia`, `@family`, `@wife` | read-only |
|
||||
| **Generate experiment checklist** | Given a goal description, produce a structured checklist of steps, tools needed, and success criteria | — | — | `@ilia` | read-only |
|
||||
|
||||
### Infra Skills
|
||||
|
||||
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|
||||
|---|---|---|---|---|---|
|
||||
| **Show VM status** | List all VMs/containers across Proxmox nodes with state, CPU%, RAM%, and uptime | Proxmox MCP | — | `@ilia` | read-only |
|
||||
| **Restart non-critical service** | Stop and start a VM by name, but only if it is tagged `non-critical`. Refuse if tagged `critical`. Requires confirmation | Proxmox MCP | — | `@ilia` | admin |
|
||||
| **Summarize cluster resources** | Aggregate CPU, RAM, and storage usage across all Proxmox nodes, flag any node above 80% utilization | Proxmox MCP | — | `@ilia` | read-only |
|
||||
| **Pre-upgrade snapshot** | Before a maintenance window, create a named snapshot of specified VMs. Requires confirmation | Proxmox MCP | — | `@ilia` | admin |
|
||||
|
||||
### Dev Skills
|
||||
|
||||
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|
||||
|---|---|---|---|---|---|
|
||||
| **Summarize open PRs** | List all open PRs on the nanobot repo with title, author, age, review status, and CI state | Gitea MCP | — | `@ilia` | read-only |
|
||||
| **Triage Gitea issues** | Fetch open issues, group by label, suggest priority ordering based on age and activity | Gitea MCP | — | `@ilia` | read-only |
|
||||
| **Search codebase** | Search Gitea-hosted code for a symbol or string pattern, return matching files and line numbers | Gitea MCP | — | `@ilia` | read-only |
|
||||
| **Create issue from chat** | Turn a conversation excerpt into a well-formatted Gitea issue with title, description, and labels. Requires confirmation | Gitea MCP | — | `@ilia` | write-confirm |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 Priorities
|
||||
|
||||
These are the items we commit to implementing first, chosen for maximum daily value with manageable complexity.
|
||||
|
||||
### Phase 1 MCP Integrations
|
||||
|
||||
| Priority | MCP Server | Rationale |
|
||||
|---|---|---|
|
||||
| **P1** | **Gitea MCP** | Directly replaces fragile curl-based Gitea access scattered across `AGENTS.md` and the `gitea` skill. Token and network route already exist. Aligns with daily dev workflow — PRs, issues, code search are used every day. |
|
||||
| **P2** | **Google Calendar MCP** | Complements the existing built-in `calendar` tool with multi-calendar views and free/busy queries. OAuth is already a solved pattern from Gmail MCP. Deployed to all three agents — `@ilia` (work calendar), `@family` (shared family calendar), `@wife` (personal calendar). |
|
||||
| **P3** | **Proxmox MCP** | Homelab infrastructure is checked frequently but currently requires opening the Proxmox web UI. Starting with read-only (`PVEAuditor`) makes it safe to deploy immediately. Write ops follow in a later phase. |
|
||||
|
||||
### Phase 1 Skills
|
||||
|
||||
| Priority | Skill | MCP dep | Agents | Safety | Why first |
|
||||
|---|---|---|---|---|---|
|
||||
| **S1** | Summarize open PRs | Gitea MCP | `@ilia` | read-only | Used daily; validates Gitea MCP end-to-end |
|
||||
| **S2** | Plan my week | Google Calendar MCP | `@ilia`, `@family`, `@wife` | read-only | High value for every agent; validates Calendar MCP |
|
||||
| **S3** | Triage inbox | Gmail MCP (already live) | `@ilia`, `@wife` | read-only | Formalizes an existing ad-hoc pattern; no new MCP needed |
|
||||
| **S4** | Show VM status | Proxmox MCP | `@ilia` | read-only | Safe first infra skill; validates Proxmox MCP |
|
||||
| **S5** | Summarize today's mail | Gmail MCP (already live) | `@ilia`, `@family`, `@wife` | read-only | Daily value for all agents; no new MCP needed |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Local clone workflow
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
mkdir -p mcp-servers && cd mcp-servers
|
||||
|
||||
# Gitea MCP (Go)
|
||||
git clone https://gitea.com/gitea/gitea-mcp.git
|
||||
cd gitea-mcp && go build -o gitea-mcp . && cd ..
|
||||
|
||||
# Google Calendar MCP (TypeScript)
|
||||
git clone https://github.com/nspady/google-calendar-mcp.git
|
||||
cd google-calendar-mcp && npm install && npm run build && cd ..
|
||||
|
||||
# Proxmox MCP (Python)
|
||||
git clone https://github.com/antonio-mello-ai/mcp-proxmox.git
|
||||
cd mcp-proxmox && pip install -e . && cd ..
|
||||
|
||||
# Fetch Browser (TypeScript)
|
||||
git clone https://github.com/TheSethRose/Fetch-Browser.git fetch-browser
|
||||
cd fetch-browser && npm install && npm run build && cd ..
|
||||
```
|
||||
|
||||
To update a server: `cd mcp-servers/<name> && git pull && <rebuild>`. Pin to a known-good commit with `git checkout <sha>` for production stability.
|
||||
|
||||
### 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.
|
||||
|
||||
**`~/.nanobot-user1/config.json`** (@ilia — all MCP servers):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] },
|
||||
"gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": [], "env": { "GITEA_URL": "http://10.0.30.169:3000", "GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN" } },
|
||||
"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" } },
|
||||
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`~/.nanobot-user2/config.json`** (@family — scheduling + web only, no dev/infra):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
|
||||
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`~/.nanobot-user3/config.json`** (@wife — email + calendar + web, no dev/infra):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] },
|
||||
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
|
||||
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**MCP server allocation summary:**
|
||||
|
||||
| MCP Server | `@ilia` | `@family` | `@wife` |
|
||||
|---|---|---|---|
|
||||
| Gmail MCP | yes | -- | yes |
|
||||
| Gitea MCP | yes | -- | -- |
|
||||
| Google Calendar MCP | yes | yes | yes |
|
||||
| Proxmox MCP | yes | -- | -- |
|
||||
| Web Fetch MCP | yes | yes | yes |
|
||||
|
||||
Key points:
|
||||
- `@family` and `@wife` never see Gitea or Proxmox tools -- those MCP servers are simply absent from their configs.
|
||||
- `@family` has no email MCP (it's a shared household bot, not tied to one inbox). It still has the built-in `calendar` and `web` tools.
|
||||
- Each container spawns its own MCP server processes via stdio from the shared `mcp-servers/` directory (mounted read-only into all containers).
|
||||
|
||||
### Safety tiers
|
||||
|
||||
| Tier | Behavior | Implementation |
|
||||
|---|---|---|
|
||||
| **read-only** | Tool executes immediately, no confirmation prompt | Default for query/list/search operations |
|
||||
| **write-confirm** | Agent presents a summary of what it will do, waits for user "yes" before executing | Enforced in SKILL.md instructions: "Before calling `create_event`, show the user the details and ask for confirmation" |
|
||||
| **admin** | Same as write-confirm but tool is only available in the `@ilia` container | Enforced by omitting the MCP server from other agents' `config.json` + SKILL.md confirmation instructions |
|
||||
|
||||
With separate containers, the strongest security boundary is **not configuring an MCP server at all** in an agent's config. Proxmox and Gitea are never in `@family` or `@wife` configs, so those agents physically cannot call those tools.
|
||||
|
||||
Phase 1 deploys **only read-only skills**. Write skills (draft replies, reschedule meeting, create issue, restart VM) are Phase 2 once we validate the read paths.
|
||||
|
||||
### Skill file template
|
||||
|
||||
New skills follow the existing format in `nanobot/skills/`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: summarize-open-prs
|
||||
description: "List and summarize all open pull requests on the nanobot Gitea repo."
|
||||
metadata: {"nanobot":{"emoji":"📋","requires":{"mcp":["gitea"]}}}
|
||||
---
|
||||
|
||||
# Summarize Open PRs
|
||||
|
||||
## When to use
|
||||
User asks about open PRs, pending reviews, or code review status.
|
||||
|
||||
## Steps
|
||||
1. Call `mcp_gitea_list_pulls` with state=open.
|
||||
2. For each PR, extract: title, author, created date, review status, CI status.
|
||||
3. Format as a numbered list sorted by age (oldest first).
|
||||
4. Highlight PRs with no reviews or failing CI.
|
||||
|
||||
## Safety
|
||||
Read-only. No confirmation needed.
|
||||
```
|
||||
|
||||
### Docker considerations
|
||||
|
||||
All three containers (`nanobot-user1`, `nanobot-user2`, `nanobot-user3`) share the same Docker image. MCP server processes are spawned inside each container as needed. The Dockerfile must include:
|
||||
- **Go** (for Gitea MCP binary — or copy pre-built binary)
|
||||
- **Node.js 18+** (for Calendar MCP and Fetch Browser)
|
||||
- **Python pip deps** (for Proxmox MCP — install into the same venv or a sidecar)
|
||||
- **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.
|
||||
|
||||
**Volume mounts (per container)** — compose services remain `nanobot-user1` / `user2` / `user3`; they map to `@ilia` / `@family` / `@wife` workspaces.
|
||||
|
||||
```yaml
|
||||
nanobot-user1: # @ilia
|
||||
volumes:
|
||||
- ~/.nanobot-user1:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/ilia:/workspace
|
||||
# Optional: ./mcp-servers:/app/mcp-servers:ro
|
||||
|
||||
nanobot-user2: # @family
|
||||
volumes:
|
||||
- ~/.nanobot-user2:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/family:/workspace
|
||||
|
||||
nanobot-user3: # @wife
|
||||
volumes:
|
||||
- ~/.nanobot-user3:/root/.nanobot
|
||||
- ~/.nanobot/workspaces/wife:/workspace
|
||||
```
|
||||
|
||||
### Rollout sequence
|
||||
|
||||
```
|
||||
Week 1: Clone repos, build locally, verify each MCP server starts and lists tools
|
||||
Week 2: Wire Gitea MCP + "Summarize open PRs" skill, validate end-to-end
|
||||
Week 3: Wire Calendar MCP + "Plan my week" skill, formalize "Triage inbox" skill
|
||||
Week 4: Wire Proxmox MCP (read-only) + "Show VM status" skill
|
||||
Week 5: Add "Summarize today's mail" skill, integrate Web Fetch MCP
|
||||
Week 6: Retrospective, update this document, plan Phase 2 write-skills
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 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) |
|
||||
@ -1,70 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Setup script to create env files for multi-bot setup
|
||||
|
||||
set -e
|
||||
|
||||
echo "Setting up environment files for multi-bot configuration..."
|
||||
echo ""
|
||||
|
||||
# Create .env.shared if it doesn't exist
|
||||
if [ ! -f .env.shared ]; then
|
||||
cat > .env.shared << 'EOF'
|
||||
# Shared configuration for all nanobot instances
|
||||
# These settings apply to all bots unless overridden in .env.user1, .env.user2, etc.
|
||||
|
||||
# LLM Provider API Key (shared across all bots)
|
||||
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
|
||||
|
||||
# Default Model (shared across all bots)
|
||||
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
|
||||
|
||||
# Agent Settings (shared)
|
||||
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
|
||||
NANOBOT_AGENTS__DEFAULTS__MAX_TOKENS=8192
|
||||
NANOBOT_AGENTS__DEFAULTS__MAX_TOOL_ITERATIONS=20
|
||||
NANOBOT_AGENTS__DEFAULTS__MEMORY_WINDOW=50
|
||||
|
||||
# Tool Settings (shared)
|
||||
NANOBOT_TOOLS__RESTRICT_TO_WORKSPACE=true
|
||||
|
||||
# Gateway Settings (shared)
|
||||
NANOBOT_GATEWAY__PORT=18790
|
||||
NANOBOT_GATEWAY__HOST=0.0.0.0
|
||||
EOF
|
||||
echo "✓ Created .env.shared"
|
||||
else
|
||||
echo "⚠ .env.shared already exists, skipping..."
|
||||
fi
|
||||
|
||||
# Create bot-specific env files
|
||||
for i in 1 2 3; do
|
||||
env_file=".env.user${i}"
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cat > "$env_file" << EOF
|
||||
# Bot-specific configuration for user${i}
|
||||
# These settings override .env.shared for this bot only
|
||||
# Leave empty or comment out to use shared settings
|
||||
|
||||
# Example: Override model for this bot
|
||||
# NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-sonnet-4
|
||||
|
||||
# Example: Override temperature for this bot
|
||||
# NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.9
|
||||
|
||||
# Example: Use different API key for this bot
|
||||
# NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-different-key
|
||||
EOF
|
||||
echo "✓ Created $env_file"
|
||||
else
|
||||
echo "⚠ $env_file already exists, skipping..."
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done! Next steps:"
|
||||
echo "1. Edit .env.shared and add your API keys and shared settings"
|
||||
echo "2. Edit .env.user1, .env.user2, .env.user3 if you need bot-specific overrides"
|
||||
echo "3. Create minimal config.json files in ~/.nanobot-user1, ~/.nanobot-user2, etc."
|
||||
echo "4. Run: docker compose -f docker-compose.multi.env.yml up -d"
|
||||
|
||||
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
# Local MCP servers
|
||||
|
||||
This repo uses a **local-clone policy** for MCP servers: clone upstream repos into `./mcp-servers/` and run them from disk (instead of fetching from npm/PyPI at runtime).
|
||||
|
||||
## Gitea MCP
|
||||
|
||||
- **Upstream**: `https://gitea.com/gitea/gitea-mcp.git`
|
||||
- **Local path**: `mcp-servers/gitea-mcp/`
|
||||
- **Binary**: `mcp-servers/gitea-mcp/gitea-mcp`
|
||||
|
||||
Build it with:
|
||||
|
||||
```bash
|
||||
./scripts/setup-mcp-servers.sh gitea
|
||||
```
|
||||
|
||||
Then configure nanobot (example):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Setup script for multiple nanobot instances
|
||||
|
||||
# Create directories for each bot
|
||||
mkdir -p ~/.nanobot-user1
|
||||
mkdir -p ~/.nanobot-user2
|
||||
mkdir -p ~/.nanobot-user3
|
||||
|
||||
# Copy base config if it exists
|
||||
if [ -f ~/.nanobot/config.json ]; then
|
||||
cp ~/.nanobot/config.json ~/.nanobot-user1/config.json
|
||||
cp ~/.nanobot/config.json ~/.nanobot-user2/config.json
|
||||
cp ~/.nanobot/config.json ~/.nanobot-user3/config.json
|
||||
echo "✓ Copied base config to all directories"
|
||||
else
|
||||
echo "⚠ Base config not found. Creating minimal configs..."
|
||||
# Create minimal configs
|
||||
cat > ~/.nanobot-user1/config.json <<EOF
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "YOUR_API_KEY_HERE"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "BOT_TOKEN_FOR_USER1",
|
||||
"allowFrom": ["USER1_TELEGRAM_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cp ~/.nanobot-user1/config.json ~/.nanobot-user2/config.json
|
||||
cp ~/.nanobot-user1/config.json ~/.nanobot-user3/config.json
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Edit each config file (~/.nanobot-user1/config.json, etc.)"
|
||||
echo "2. Add different Telegram bot tokens and user IDs"
|
||||
echo "3. Run the Docker containers (see docker-compose.multi.yml)"
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""Agent core module."""
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
|
||||
@ -13,43 +13,43 @@ from nanobot.agent.skills import SkillsLoader
|
||||
class ContextBuilder:
|
||||
"""
|
||||
Builds the context (system prompt + messages) for the agent.
|
||||
|
||||
|
||||
Assembles bootstrap files, memory, skills, and conversation history
|
||||
into a coherent prompt for the LLM.
|
||||
"""
|
||||
|
||||
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
|
||||
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
self.workspace = workspace
|
||||
self.memory = MemoryStore(workspace)
|
||||
self.skills = SkillsLoader(workspace)
|
||||
|
||||
|
||||
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
||||
"""
|
||||
Build the system prompt from bootstrap files, memory, and skills.
|
||||
|
||||
|
||||
Args:
|
||||
skill_names: Optional list of skills to include.
|
||||
|
||||
|
||||
Returns:
|
||||
Complete system prompt.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
|
||||
# Core identity
|
||||
parts.append(self._get_identity())
|
||||
|
||||
|
||||
# Bootstrap files
|
||||
bootstrap = self._load_bootstrap_files()
|
||||
if bootstrap:
|
||||
parts.append(bootstrap)
|
||||
|
||||
|
||||
# Memory context
|
||||
memory = self.memory.get_memory_context()
|
||||
if memory:
|
||||
parts.append(f"# Memory\n\n{memory}")
|
||||
|
||||
|
||||
# Skills - progressive loading
|
||||
# 1. Always-loaded skills: include full content
|
||||
always_skills = self.skills.get_always_skills()
|
||||
@ -57,7 +57,7 @@ class ContextBuilder:
|
||||
always_content = self.skills.load_skills_for_context(always_skills)
|
||||
if always_content:
|
||||
parts.append(f"# Active Skills\n\n{always_content}")
|
||||
|
||||
|
||||
# 2. Available skills: only show summary (agent uses read_file to load)
|
||||
skills_summary = self.skills.build_skills_summary()
|
||||
if skills_summary:
|
||||
@ -67,19 +67,19 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
||||
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
|
||||
|
||||
{skills_summary}""")
|
||||
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
def _get_identity(self) -> str:
|
||||
"""Get the core identity section."""
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
import time as _time
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||
tz = _time.strftime("%Z") or "UTC"
|
||||
workspace_path = str(self.workspace.expanduser().resolve())
|
||||
system = platform.system()
|
||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||
|
||||
|
||||
return f"""# nanobot 🐈
|
||||
|
||||
You are nanobot, a helpful AI assistant. You have access to tools that allow you to:
|
||||
@ -89,22 +89,6 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
|
||||
- Send messages to users on chat channels
|
||||
- Spawn subagents for complex background tasks
|
||||
|
||||
## Tool calling (IMPORTANT)
|
||||
Some LLM backends may not support native function-calling. When you decide to use a tool, you MUST output a single JSON object in one of these formats (and no other surrounding text):
|
||||
|
||||
1) Standard tool call:
|
||||
{{"name":"<tool_name>","parameters":{{...}}}}
|
||||
|
||||
2) Calendar shortcut (allowed only for the built-in `calendar` tool):
|
||||
{{"action":"list_events", ...}}
|
||||
|
||||
After a tool result is returned, respond normally in plain text unless the user asks for another tool action.
|
||||
|
||||
### 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.
|
||||
|
||||
## Current Time
|
||||
{now} ({tz})
|
||||
|
||||
@ -117,12 +101,6 @@ Your workspace is at: {workspace_path}
|
||||
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
||||
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
||||
|
||||
**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. **`list_dir` takes one directory path**—no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory.
|
||||
|
||||
**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 **`read_emails`:** Answer **only** from the email text the tool returned (From, Subject, Date, attachments, downloaded paths, body as needed). Do **not** switch to unrelated topics (Git, Gitea, this repo, workspace docs, coding help, general chit-chat). Do **not** apologize at length or describe "what an email is". Match the question: e.g. “latest email” → sender + subject (+ date) in a few lines unless they asked for the full body.
|
||||
|
||||
## Gitea API (This Repository)
|
||||
**CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub.
|
||||
- Repository: `ilia/nanobot`
|
||||
@ -142,20 +120,20 @@ Always be helpful, accurate, and concise. Before calling tools, briefly tell the
|
||||
When remembering something important, write to {workspace_path}/memory/MEMORY.md
|
||||
To recall past events, grep {workspace_path}/memory/HISTORY.md
|
||||
|
||||
IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS use the read_emails tool. NEVER use exec() with mail/tail/awk commands or read_file() on /var/mail - those will not work. The read_emails tool is the only way to access emails. Once read_emails returns, your assistant reply must **only** satisfy that email question from the tool result—ignore Gitea/workspace/bootstrap content unless the user tied their question to it."""
|
||||
|
||||
IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS use the read_emails tool. NEVER use exec() with mail/tail/awk commands or read_file() on /var/mail - those will not work. The read_emails tool is the only way to access emails."""
|
||||
|
||||
def _load_bootstrap_files(self) -> str:
|
||||
"""Load all bootstrap files from workspace."""
|
||||
parts = []
|
||||
|
||||
|
||||
for filename in self.BOOTSTRAP_FILES:
|
||||
file_path = self.workspace / filename
|
||||
if file_path.exists():
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
parts.append(f"## {filename}\n\n{content}")
|
||||
|
||||
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
|
||||
|
||||
def build_messages(
|
||||
self,
|
||||
history: list[dict[str, Any]],
|
||||
@ -200,7 +178,7 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
||||
"""Build user message content with optional base64-encoded images."""
|
||||
if not media:
|
||||
return text
|
||||
|
||||
|
||||
images = []
|
||||
for path in media:
|
||||
p = Path(path)
|
||||
@ -209,11 +187,11 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
||||
continue
|
||||
b64 = base64.b64encode(p.read_bytes()).decode()
|
||||
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
|
||||
|
||||
|
||||
if not images:
|
||||
return text
|
||||
return images + [{"type": "text", "text": text}]
|
||||
|
||||
|
||||
def add_tool_result(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@ -223,13 +201,13 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Add a tool result to the message list.
|
||||
|
||||
|
||||
Args:
|
||||
messages: Current message list.
|
||||
tool_call_id: ID of the tool call.
|
||||
tool_name: Name of the tool.
|
||||
result: Tool execution result.
|
||||
|
||||
|
||||
Returns:
|
||||
Updated message list.
|
||||
"""
|
||||
@ -240,7 +218,7 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
||||
"content": result
|
||||
})
|
||||
return messages
|
||||
|
||||
|
||||
def add_assistant_message(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@ -250,13 +228,13 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Add an assistant message to the message list.
|
||||
|
||||
|
||||
Args:
|
||||
messages: Current message list.
|
||||
content: Message content.
|
||||
tool_calls: Optional tool calls.
|
||||
reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
|
||||
|
||||
|
||||
Returns:
|
||||
Updated message list.
|
||||
"""
|
||||
|
||||
@ -1,30 +1,28 @@
|
||||
"""Agent loop: the core processing engine."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from contextlib import AsyncExitStack
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
import json
|
||||
import json_repair
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ExecToolConfig, ToolRoutingConfig
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
@ -51,15 +49,14 @@ class AgentLoop:
|
||||
max_tokens: int = 4096,
|
||||
memory_window: int = 50,
|
||||
brave_api_key: str | None = None,
|
||||
exec_config: ExecToolConfig | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
exec_config: "ExecToolConfig | None" = None,
|
||||
cron_service: "CronService | None" = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
session_manager: SessionManager | None = None,
|
||||
mcp_servers: dict | None = None,
|
||||
tool_profiles: dict | None = None,
|
||||
default_tool_profile: str = "default",
|
||||
tool_routing: ToolRoutingConfig | None = None,
|
||||
):
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
from nanobot.cron.service import CronService
|
||||
self.bus = bus
|
||||
self.provider = provider
|
||||
self.workspace = workspace
|
||||
@ -87,16 +84,13 @@ class AgentLoop:
|
||||
exec_config=self.exec_config,
|
||||
restrict_to_workspace=restrict_to_workspace,
|
||||
)
|
||||
|
||||
|
||||
self._running = False
|
||||
self._mcp_servers = mcp_servers or {}
|
||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
||||
self._mcp_connected_servers: set[str] = set()
|
||||
self._tool_profiles: dict = tool_profiles or {}
|
||||
self._default_tool_profile = default_tool_profile
|
||||
self._tool_routing = tool_routing or ToolRoutingConfig()
|
||||
self._mcp_stack: AsyncExitStack | None = None
|
||||
self._mcp_connected = False
|
||||
self._register_default_tools()
|
||||
|
||||
|
||||
def _register_default_tools(self) -> None:
|
||||
"""Register the default set of tools."""
|
||||
# File tools (restrict to workspace if configured)
|
||||
@ -105,40 +99,37 @@ class AgentLoop:
|
||||
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
|
||||
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
||||
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
|
||||
|
||||
|
||||
# Shell tool
|
||||
self.tools.register(ExecTool(
|
||||
working_dir=str(self.workspace),
|
||||
timeout=self.exec_config.timeout,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
))
|
||||
|
||||
|
||||
# Web tools
|
||||
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||
self.tools.register(WebFetchTool())
|
||||
|
||||
|
||||
# Message tool
|
||||
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
|
||||
self.tools.register(message_tool)
|
||||
|
||||
|
||||
# Spawn tool (for subagents)
|
||||
spawn_tool = SpawnTool(manager=self.subagents)
|
||||
self.tools.register(spawn_tool)
|
||||
|
||||
|
||||
# Cron tool (for scheduling)
|
||||
if self.cron_service:
|
||||
self.tools.register(CronTool(self.cron_service))
|
||||
|
||||
|
||||
# Email tool (if email channel is configured)
|
||||
try:
|
||||
from nanobot.agent.tools.email import EmailTool
|
||||
from nanobot.config.loader import load_config
|
||||
config = load_config()
|
||||
if config.channels.email.enabled:
|
||||
email_tool = EmailTool(
|
||||
email_config=config.channels.email,
|
||||
workspace=self.workspace,
|
||||
)
|
||||
email_tool = EmailTool(email_config=config.channels.email)
|
||||
self.tools.register(email_tool)
|
||||
logger.info(f"Email tool '{email_tool.name}' registered successfully")
|
||||
else:
|
||||
@ -146,7 +137,7 @@ class AgentLoop:
|
||||
except Exception as e:
|
||||
logger.warning(f"Email tool not available: {e}")
|
||||
# Email tool not available or not configured - silently skip
|
||||
|
||||
|
||||
# Calendar tool (if calendar is configured)
|
||||
try:
|
||||
from nanobot.agent.tools.calendar import CalendarTool
|
||||
@ -161,70 +152,16 @@ class AgentLoop:
|
||||
except Exception as e:
|
||||
logger.warning(f"Calendar tool not available: {e}")
|
||||
# Calendar tool not available or not configured - silently skip
|
||||
|
||||
def _unregister_mcp_tools_for_server(self, server_key: str) -> None:
|
||||
"""Remove tools registered from one MCP server (prefix mcp_<key>_)."""
|
||||
prefix = f"mcp_{server_key}_"
|
||||
for name in list(self.tools.tool_names):
|
||||
if name.startswith(prefix):
|
||||
self.tools.unregister(name)
|
||||
|
||||
async def _disconnect_mcp_server(self, server_key: str) -> None:
|
||||
"""Close one MCP server and remove its tools (used when switching tool profiles)."""
|
||||
stack = self._mcp_stacks.pop(server_key, None)
|
||||
if stack is not None:
|
||||
try:
|
||||
await stack.aclose()
|
||||
except (RuntimeError, BaseExceptionGroup):
|
||||
pass
|
||||
self._unregister_mcp_tools_for_server(server_key)
|
||||
self._mcp_connected_servers.discard(server_key)
|
||||
logger.info(f"MCP server '{server_key}': disconnected")
|
||||
|
||||
async def _sync_mcp_to_profile_needs(self, needed_keys: list[str]) -> None:
|
||||
"""
|
||||
Ensure only MCP servers in needed_keys are connected: tear down extras, connect missing.
|
||||
|
||||
When tools.toolProfiles is empty, pass the full configured key list so all servers stay up.
|
||||
"""
|
||||
if not self._mcp_servers:
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
"""Connect to configured MCP servers (one-time, lazy)."""
|
||||
if self._mcp_connected or not self._mcp_servers:
|
||||
return
|
||||
needed = set(needed_keys)
|
||||
for key in list(self._mcp_connected_servers):
|
||||
if key not in needed:
|
||||
await self._disconnect_mcp_server(key)
|
||||
connect_order = [k for k in self._mcp_servers.keys() if k in needed]
|
||||
await self._ensure_mcp_servers_connected(connect_order)
|
||||
|
||||
async def _ensure_mcp_servers_connected(self, server_keys: list[str]) -> None:
|
||||
"""Lazily connect MCP servers (each gets its own AsyncExitStack for per-server teardown)."""
|
||||
if not self._mcp_servers or not server_keys:
|
||||
return
|
||||
pending = [
|
||||
k
|
||||
for k in server_keys
|
||||
if k in self._mcp_servers and k not in self._mcp_connected_servers
|
||||
]
|
||||
if not pending:
|
||||
return
|
||||
|
||||
from nanobot.agent.tools.mcp import connect_mcp_server
|
||||
|
||||
for key in pending:
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
try:
|
||||
await connect_mcp_server(
|
||||
key, self._mcp_servers[key], self.tools, stack
|
||||
)
|
||||
self._mcp_stacks[key] = stack
|
||||
self._mcp_connected_servers.add(key)
|
||||
except Exception as e:
|
||||
logger.error(f"MCP server '{key}': failed to connect: {e}")
|
||||
try:
|
||||
await stack.aclose()
|
||||
except (RuntimeError, BaseExceptionGroup):
|
||||
pass
|
||||
self._mcp_connected = True
|
||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||
self._mcp_stack = AsyncExitStack()
|
||||
await self._mcp_stack.__aenter__()
|
||||
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
|
||||
|
||||
def _set_tool_context(self, channel: str, chat_id: str) -> None:
|
||||
"""Update context for all tools that need routing info."""
|
||||
@ -257,41 +194,6 @@ class AgentLoop:
|
||||
return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")'
|
||||
return ", ".join(_fmt(tc) for tc in tool_calls)
|
||||
|
||||
@staticmethod
|
||||
def _extract_routing_text(messages: list[dict]) -> str:
|
||||
"""Last user message text (string or multimodal) for the tool-profile router."""
|
||||
for m in reversed(messages):
|
||||
if m.get("role") != "user":
|
||||
continue
|
||||
c = m.get("content")
|
||||
if isinstance(c, str):
|
||||
return c.strip()
|
||||
if isinstance(c, list):
|
||||
parts: list[str] = []
|
||||
for block in c:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(str(block.get("text") or ""))
|
||||
return "\n".join(parts).strip()
|
||||
return ""
|
||||
|
||||
async def _pick_tool_profile(self, user_text: str) -> str:
|
||||
"""Resolve profile key when tools.toolProfiles is configured."""
|
||||
if not self._tool_profiles:
|
||||
return self._default_tool_profile
|
||||
if self._tool_routing.enabled:
|
||||
from nanobot.agent.tool_routing import route_tool_profile
|
||||
|
||||
return await route_tool_profile(
|
||||
self.provider,
|
||||
model=self.model,
|
||||
user_message=user_text,
|
||||
profiles=self._tool_profiles,
|
||||
default_profile=self._default_tool_profile,
|
||||
temperature=self._tool_routing.router_temperature,
|
||||
max_tokens=self._tool_routing.router_max_tokens,
|
||||
)
|
||||
return self._default_tool_profile
|
||||
|
||||
async def _run_agent_loop(
|
||||
self,
|
||||
initial_messages: list[dict],
|
||||
@ -311,54 +213,16 @@ class AgentLoop:
|
||||
iteration = 0
|
||||
final_content = None
|
||||
tools_used: list[str] = []
|
||||
empty_final_retry_used = False
|
||||
|
||||
from nanobot.agent.tool_profiles import (
|
||||
compute_allowed_tool_names,
|
||||
mcp_keys_to_connect,
|
||||
)
|
||||
from nanobot.agent.tool_routing import is_tool_not_found_error
|
||||
|
||||
configured_mcp = list(self._mcp_servers.keys())
|
||||
tools_expanded = False
|
||||
allowed_names: set[str] | None = None
|
||||
|
||||
if self._tool_profiles:
|
||||
routing_text = self._extract_routing_text(initial_messages)
|
||||
profile_key = await self._pick_tool_profile(routing_text)
|
||||
prof = self._tool_profiles[profile_key]
|
||||
await self._sync_mcp_to_profile_needs(
|
||||
mcp_keys_to_connect(prof, configured_mcp)
|
||||
)
|
||||
always = set(self._tool_routing.always_include_tools)
|
||||
allowed_names = compute_allowed_tool_names(
|
||||
self.tools,
|
||||
prof,
|
||||
configured_mcp,
|
||||
always,
|
||||
)
|
||||
logger.info(
|
||||
f"Tool profile '{profile_key}': {len(allowed_names)}/{len(self.tools)} tools exposed"
|
||||
)
|
||||
else:
|
||||
await self._sync_mcp_to_profile_needs(configured_mcp)
|
||||
|
||||
tools_full = self.tools.get_definitions()
|
||||
|
||||
while iteration < self.max_iterations:
|
||||
iteration += 1
|
||||
logger.debug(f"Agent loop iteration {iteration}/{self.max_iterations}, calling LLM provider...")
|
||||
|
||||
if allowed_names is not None and not tools_expanded:
|
||||
tool_defs = self.tools.get_definitions_subset(allowed_names)
|
||||
else:
|
||||
tool_defs = tools_full
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
self.provider.chat(
|
||||
messages=messages,
|
||||
tools=tool_defs,
|
||||
tools=self.tools.get_definitions(),
|
||||
model=self.model,
|
||||
temperature=self.temperature,
|
||||
max_tokens=self.max_tokens,
|
||||
@ -367,7 +231,7 @@ 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")
|
||||
logger.error(f"LLM provider call timed out after 120 seconds")
|
||||
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}")
|
||||
@ -400,18 +264,6 @@ class AgentLoop:
|
||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
||||
logger.info(f"Tool result length: {len(result) if result else 0}, preview: {result[:200] if result else 'None'}")
|
||||
if (
|
||||
allowed_names is not None
|
||||
and self._tool_routing.expand_on_missing_tool
|
||||
and not tools_expanded
|
||||
and is_tool_not_found_error(result)
|
||||
):
|
||||
tools_expanded = True
|
||||
await self._sync_mcp_to_profile_needs(configured_mcp)
|
||||
tools_full = self.tools.get_definitions()
|
||||
logger.info(
|
||||
"Expanded tool set to full registry (missing tool after profile filter)"
|
||||
)
|
||||
messages = self.context.add_tool_result(
|
||||
messages, tool_call.id, tool_call.name, result
|
||||
)
|
||||
@ -419,34 +271,17 @@ class AgentLoop:
|
||||
else:
|
||||
final_content = self._strip_think(response.content)
|
||||
logger.info(f"Final response generated. Content length: {len(final_content) if final_content else 0}")
|
||||
# Some local OpenAI-compatible backends occasionally return an empty assistant message.
|
||||
# Retry once with an explicit nudge to either call a tool or answer in text.
|
||||
if (not final_content or not final_content.strip()) and not empty_final_retry_used:
|
||||
empty_final_retry_used = True
|
||||
logger.warning(
|
||||
"LLM returned empty final content; retrying once with a non-empty response nudge"
|
||||
)
|
||||
messages = messages + [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Your previous reply was empty. You MUST either (a) call an appropriate tool, "
|
||||
"or (b) respond with a short helpful text answer. Do not return an empty message."
|
||||
),
|
||||
}
|
||||
]
|
||||
final_content = None
|
||||
continue
|
||||
break
|
||||
|
||||
|
||||
if final_content is None and iteration >= self.max_iterations:
|
||||
logger.warning(f"Max iterations ({self.max_iterations}) reached without final response. Last tool calls: {tools_used[-3:] if len(tools_used) >= 3 else tools_used}")
|
||||
|
||||
|
||||
return final_content, tools_used
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run the agent loop, processing messages from the bus."""
|
||||
self._running = True
|
||||
await self._connect_mcp()
|
||||
logger.info("Agent loop started")
|
||||
|
||||
while self._running:
|
||||
@ -468,21 +303,21 @@ class AgentLoop:
|
||||
))
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
"""Close all MCP connections and drop MCP tools from the registry."""
|
||||
for key in list(
|
||||
set(self._mcp_stacks.keys()) | self._mcp_connected_servers
|
||||
):
|
||||
await self._disconnect_mcp_server(key)
|
||||
self._mcp_stacks.clear()
|
||||
self._mcp_connected_servers.clear()
|
||||
"""Close MCP connections."""
|
||||
if self._mcp_stack:
|
||||
try:
|
||||
await self._mcp_stack.aclose()
|
||||
except (RuntimeError, BaseExceptionGroup):
|
||||
pass # MCP SDK cancel scope cleanup is noisy but harmless
|
||||
self._mcp_stack = None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the agent loop."""
|
||||
self._running = False
|
||||
logger.info("Agent loop stopping")
|
||||
|
||||
|
||||
async def _process_message(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
@ -491,25 +326,25 @@ class AgentLoop:
|
||||
) -> OutboundMessage | None:
|
||||
"""
|
||||
Process a single inbound message.
|
||||
|
||||
|
||||
Args:
|
||||
msg: The inbound message to process.
|
||||
session_key: Override session key (used by process_direct).
|
||||
on_progress: Optional callback for intermediate output (defaults to bus publish).
|
||||
|
||||
|
||||
Returns:
|
||||
The response message, or None if no response needed.
|
||||
"""
|
||||
# System messages route back via chat_id ("channel:chat_id")
|
||||
if msg.channel == "system":
|
||||
return await self._process_system_message(msg)
|
||||
|
||||
|
||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
|
||||
|
||||
|
||||
key = session_key or msg.session_key
|
||||
session = self.sessions.get_or_create(key)
|
||||
|
||||
|
||||
# Handle slash commands
|
||||
cmd = msg.content.strip().lower()
|
||||
if cmd == "/new":
|
||||
@ -530,7 +365,7 @@ class AgentLoop:
|
||||
if cmd == "/help":
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
|
||||
|
||||
|
||||
# Skip memory consolidation for CLI mode to avoid blocking/hanging
|
||||
# Memory consolidation can be slow and CLI users want fast responses
|
||||
if len(session.messages) > self.memory_window and msg.channel != "cli":
|
||||
@ -571,31 +406,31 @@ class AgentLoop:
|
||||
|
||||
if final_content is None:
|
||||
final_content = "I've completed processing but have no response to give."
|
||||
|
||||
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
||||
|
||||
|
||||
session.add_message("user", msg.content)
|
||||
session.add_message("assistant", final_content,
|
||||
tools_used=tools_used if tools_used else None)
|
||||
self.sessions.save(session)
|
||||
|
||||
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=final_content,
|
||||
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
|
||||
)
|
||||
|
||||
|
||||
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
"""
|
||||
Process a system message (e.g., subagent announce).
|
||||
|
||||
|
||||
The chat_id field contains "original_channel:original_chat_id" to route
|
||||
the response back to the correct destination.
|
||||
"""
|
||||
logger.info(f"Processing system message from {msg.sender_id}")
|
||||
|
||||
|
||||
# Parse origin from chat_id (format: "channel:chat_id")
|
||||
if ":" in msg.chat_id:
|
||||
parts = msg.chat_id.split(":", 1)
|
||||
@ -605,7 +440,7 @@ class AgentLoop:
|
||||
# Fallback
|
||||
origin_channel = "cli"
|
||||
origin_chat_id = msg.chat_id
|
||||
|
||||
|
||||
session_key = f"{origin_channel}:{origin_chat_id}"
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
self._set_tool_context(origin_channel, origin_chat_id)
|
||||
@ -619,17 +454,17 @@ class AgentLoop:
|
||||
|
||||
if final_content is None:
|
||||
final_content = "Background task completed."
|
||||
|
||||
|
||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
||||
session.add_message("assistant", final_content)
|
||||
self.sessions.save(session)
|
||||
|
||||
|
||||
return OutboundMessage(
|
||||
channel=origin_channel,
|
||||
chat_id=origin_chat_id,
|
||||
content=final_content
|
||||
)
|
||||
|
||||
|
||||
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
|
||||
"""Consolidate old messages into MEMORY.md + HISTORY.md.
|
||||
|
||||
@ -735,23 +570,24 @@ Respond with ONLY valid JSON, no markdown fences."""
|
||||
) -> str:
|
||||
"""
|
||||
Process a message directly (for CLI or cron usage).
|
||||
|
||||
|
||||
Args:
|
||||
content: The message content.
|
||||
session_key: Session identifier (overrides channel:chat_id for session lookup).
|
||||
channel: Source channel (for tool context routing).
|
||||
chat_id: Source chat ID (for tool context routing).
|
||||
on_progress: Optional callback for intermediate output.
|
||||
|
||||
|
||||
Returns:
|
||||
The agent's response.
|
||||
"""
|
||||
await self._connect_mcp()
|
||||
msg = InboundMessage(
|
||||
channel=channel,
|
||||
sender_id="user",
|
||||
chat_id=chat_id,
|
||||
content=content
|
||||
)
|
||||
|
||||
|
||||
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
|
||||
return response.content if response else ""
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
"""Tool profile: compute which tools are visible to the LLM for a given config profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.schema import ToolProfileConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
|
||||
def mcp_server_for_tool(tool_name: str, mcp_server_keys: list[str]) -> str | None:
|
||||
"""
|
||||
Infer MCP server config key from nanobot's tool name pattern mcp_<serverKey>_<mcpToolName>.
|
||||
|
||||
Server keys are matched longest-first so names with underscores resolve unambiguously.
|
||||
"""
|
||||
prefix = "mcp_"
|
||||
if not tool_name.startswith(prefix):
|
||||
return None
|
||||
rest = tool_name[len(prefix) :]
|
||||
for key in sorted(mcp_server_keys, key=len, reverse=True):
|
||||
sep = f"{key}_"
|
||||
if rest.startswith(sep):
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def mcp_keys_to_connect(
|
||||
profile: ToolProfileConfig, configured_mcp_keys: list[str]
|
||||
) -> list[str]:
|
||||
"""
|
||||
Config keys for MCP servers to connect for this profile, in config order.
|
||||
|
||||
None on profile.mcp_servers means all configured servers; [] means none.
|
||||
Unknown keys in the profile list are logged and skipped.
|
||||
"""
|
||||
if not configured_mcp_keys:
|
||||
return []
|
||||
configured_set = set(configured_mcp_keys)
|
||||
if profile.mcp_servers is None:
|
||||
return list(configured_mcp_keys)
|
||||
out: list[str] = []
|
||||
for k in profile.mcp_servers:
|
||||
if k in configured_set:
|
||||
out.append(k)
|
||||
else:
|
||||
logger.warning(
|
||||
f"tools.toolProfiles entry references unknown MCP server {k!r}; "
|
||||
"not in tools.mcpServers keys"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def compute_allowed_tool_names(
|
||||
registry: ToolRegistry,
|
||||
profile: ToolProfileConfig,
|
||||
mcp_server_keys: list[str],
|
||||
always_include: set[str],
|
||||
) -> set[str]:
|
||||
"""Union of profile-filtered builtins + MCP tools + always-include (intersected with registered names)."""
|
||||
all_names = set(registry.tool_names)
|
||||
mcp_keys = list(mcp_server_keys)
|
||||
|
||||
builtins = {n for n in all_names if mcp_server_for_tool(n, mcp_keys) is None}
|
||||
|
||||
if profile.builtin_tools is None:
|
||||
allowed_builtins = set(builtins)
|
||||
else:
|
||||
allowed_builtins = set(profile.builtin_tools) & builtins
|
||||
|
||||
if profile.mcp_servers is None:
|
||||
allowed_mcp = {
|
||||
n for n in all_names if mcp_server_for_tool(n, mcp_keys) is not None
|
||||
}
|
||||
else:
|
||||
allow_srv = set(profile.mcp_servers)
|
||||
allowed_mcp = {
|
||||
n
|
||||
for n in all_names
|
||||
if (srv := mcp_server_for_tool(n, mcp_keys)) is not None and srv in allow_srv
|
||||
}
|
||||
|
||||
extras = always_include & all_names
|
||||
return allowed_builtins | allowed_mcp | extras
|
||||
@ -1,118 +0,0 @@
|
||||
"""LLM-based router: choose a tools.toolProfiles key from the user message."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json_repair
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.schema import ToolProfileConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
async def route_tool_profile(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
model: str,
|
||||
user_message: str,
|
||||
profiles: dict[str, ToolProfileConfig],
|
||||
default_profile: str,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 128,
|
||||
) -> str:
|
||||
"""
|
||||
Ask a small LLM call to return JSON {"profile": "<key>"}.
|
||||
|
||||
Falls back to default_profile on any failure or unknown key.
|
||||
"""
|
||||
if not profiles:
|
||||
return default_profile
|
||||
|
||||
# Heuristic fast-path: if the request clearly needs a dev/forge MCP (PRs, issues, repos),
|
||||
# prefer an MCP-enabled profile without spending an LLM call.
|
||||
msg_l = (user_message or "").lower()
|
||||
needs_forge = any(
|
||||
k in msg_l
|
||||
for k in [
|
||||
"pull request",
|
||||
"pull requests",
|
||||
"open pr",
|
||||
"open prs",
|
||||
" list prs",
|
||||
"pr ",
|
||||
"prs",
|
||||
"merge request",
|
||||
"issue",
|
||||
"issues",
|
||||
"gitea",
|
||||
"repo",
|
||||
"repository",
|
||||
"branches",
|
||||
"commits",
|
||||
"tags",
|
||||
"release",
|
||||
]
|
||||
)
|
||||
if needs_forge:
|
||||
# Prefer an explicit "*mcp*" profile key if present, else any profile that enables MCP servers.
|
||||
for key in profiles.keys():
|
||||
if "mcp" in key.lower():
|
||||
logger.info(f"Tool router selected profile '{key}' (heuristic)")
|
||||
return key
|
||||
for key, p in profiles.items():
|
||||
if p.mcp_servers is None or (isinstance(p.mcp_servers, list) and len(p.mcp_servers) > 0):
|
||||
logger.info(f"Tool router selected profile '{key}' (heuristic)")
|
||||
return key
|
||||
|
||||
lines = []
|
||||
for name, p in profiles.items():
|
||||
desc = (p.description or "").strip() or "(no description)"
|
||||
lines.append(f"- {name}: {desc}")
|
||||
catalog = "\n".join(lines)
|
||||
allowed = ", ".join(f'"{k}"' for k in profiles)
|
||||
|
||||
system = (
|
||||
"You are a tool-profile router. Pick exactly one profile key for the assistant's next turn. "
|
||||
"Respond with JSON only: {\"profile\": \"<key>\"} where <key> is one of: "
|
||||
f"{allowed}. "
|
||||
"Prefer narrower profiles when the request is clearly scoped (e.g. only read files). "
|
||||
"Use the broadest profile only when multiple unrelated capabilities are needed."
|
||||
)
|
||||
user = f"Available profiles:\n{catalog}\n\nUser message:\n{user_message.strip()[:8000]}"
|
||||
|
||||
try:
|
||||
response = await provider.chat(
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
tools=None,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
text = (response.content or "").strip()
|
||||
if not text:
|
||||
return default_profile
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
data = json_repair.loads(text)
|
||||
if not isinstance(data, dict):
|
||||
return default_profile
|
||||
name = data.get("profile")
|
||||
if isinstance(name, str) and name in profiles:
|
||||
logger.info(f"Tool router selected profile '{name}'")
|
||||
return name
|
||||
logger.warning(f"Tool router returned invalid profile {name!r}, using default")
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(f"Tool router JSON parse failed: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Tool router failed: {e}")
|
||||
|
||||
return default_profile
|
||||
|
||||
|
||||
def is_tool_not_found_error(result: str) -> bool:
|
||||
"""Detect registry execute() message for missing tools."""
|
||||
if not result:
|
||||
return False
|
||||
return result.startswith("Error: Tool '") and "' not found" in result
|
||||
@ -6,27 +6,17 @@ from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from google.auth.exceptions import RefreshError
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
# Scopes required for Google Calendar API
|
||||
SCOPES = ["https://www.googleapis.com/auth/calendar"]
|
||||
|
||||
# Appended to failures so the model does not invent meetings when the API/auth did not succeed.
|
||||
_NO_CALENDAR_DATA_FOR_MODEL = (
|
||||
"\n\n[NO_CALENDAR_DATA] Google Calendar was not read successfully. "
|
||||
"You MUST NOT infer, guess, or invent meetings, times, locations, or titles. "
|
||||
"Tell the user only that calendar could not be accessed and what they should fix "
|
||||
"(e.g. credentials_file, OAuth / calendar_token.json)."
|
||||
)
|
||||
|
||||
|
||||
class CalendarTool(Tool):
|
||||
"""Tool to interact with Google Calendar."""
|
||||
@ -48,9 +38,7 @@ class CalendarTool(Tool):
|
||||
"3. IMMEDIATELY call calendar(action='delete_events', event_ids=[...]) with the extracted IDs\n"
|
||||
"NEVER use placeholder values like 'ID1', '[get event ID...]', or 'list_events'.\n"
|
||||
"NEVER explain what you will do - just execute the tools immediately.\n"
|
||||
"When you call this tool, the system will execute it automatically. Do not show JSON in your response - just call the tool.\n\n"
|
||||
"If the tool result contains [NO_CALENDAR_DATA] or starts with 'Error:' and describes auth/API failure, "
|
||||
"you have zero reliable calendar information—report the problem only; never make up events."
|
||||
"When you call this tool, the system will execute it automatically. Do not show JSON in your response - just call the tool."
|
||||
)
|
||||
|
||||
def __init__(self, calendar_config: Any = None):
|
||||
@ -62,7 +50,6 @@ class CalendarTool(Tool):
|
||||
"""
|
||||
self._calendar_config = calendar_config
|
||||
self._service = None
|
||||
self._calendar_refresh_rejected = False
|
||||
|
||||
@property
|
||||
def config(self) -> Any:
|
||||
@ -86,7 +73,6 @@ class CalendarTool(Tool):
|
||||
|
||||
def _get_credentials(self) -> Credentials | None:
|
||||
"""Get valid user credentials from storage or OAuth flow."""
|
||||
self._calendar_refresh_rejected = False
|
||||
config = self.config
|
||||
|
||||
if not config.enabled:
|
||||
@ -120,10 +106,6 @@ class CalendarTool(Tool):
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except RefreshError as e:
|
||||
self._calendar_refresh_rejected = True
|
||||
logger.warning("Google Calendar OAuth refresh failed: {}", e)
|
||||
creds = None
|
||||
except Exception:
|
||||
creds = None
|
||||
|
||||
@ -179,34 +161,7 @@ class CalendarTool(Tool):
|
||||
coerced["action"] = "list_events" if value == "calendar" else value
|
||||
coerced.pop(key, None)
|
||||
break
|
||||
|
||||
# Models often omit `action` and pass only a range ("this week", "1 week") under a junk key.
|
||||
if not coerced.get("action"):
|
||||
_known_actions = {
|
||||
"list_events",
|
||||
"create_event",
|
||||
"delete_event",
|
||||
"delete_events",
|
||||
"update_event",
|
||||
"check_availability",
|
||||
"calendar",
|
||||
}
|
||||
_create_keys = {"title", "start_time", "end_time", "event_id", "event_ids", "description", "location", "attendees"}
|
||||
if len(coerced) == 1:
|
||||
only_k, only_v = next(iter(coerced.items()))
|
||||
if only_k == "time_min" and isinstance(only_v, str):
|
||||
coerced["action"] = "list_events"
|
||||
elif isinstance(only_v, str) and only_k not in _create_keys:
|
||||
if only_v in _known_actions:
|
||||
coerced["action"] = "list_events" if only_v == "calendar" else only_v
|
||||
coerced.pop(only_k, None)
|
||||
else:
|
||||
coerced = {"action": "list_events", "time_min": only_v}
|
||||
else:
|
||||
coerced["action"] = "list_events"
|
||||
else:
|
||||
coerced = {**coerced, "action": "list_events"}
|
||||
|
||||
|
||||
return coerced
|
||||
|
||||
@property
|
||||
@ -624,26 +579,15 @@ class CalendarTool(Tool):
|
||||
config = self.config
|
||||
|
||||
if not config.enabled:
|
||||
return (
|
||||
"Error: Calendar is not enabled. Set NANOBOT_TOOLS__CALENDAR__ENABLED=true"
|
||||
+ _NO_CALENDAR_DATA_FOR_MODEL
|
||||
)
|
||||
return "Error: Calendar is not enabled. Set NANOBOT_TOOLS__CALENDAR__ENABLED=true"
|
||||
|
||||
service = self._get_service()
|
||||
if not service:
|
||||
msg = (
|
||||
return (
|
||||
"Error: Could not authenticate with Google Calendar. "
|
||||
"Please ensure credentials_file is configured and valid. "
|
||||
"You may need to run OAuth flow once to authorize access."
|
||||
)
|
||||
if self._calendar_refresh_rejected:
|
||||
msg += (
|
||||
" Google rejected the saved refresh token (expired or revoked). "
|
||||
"Re-authorize on a machine with a browser and replace ~/.nanobot/calendar_token.json on the host, "
|
||||
"then restart the container. If the token file is mounted :ro, refreshes cannot be persisted—"
|
||||
"use rw or re-auth on the host when that happens."
|
||||
)
|
||||
return msg + _NO_CALENDAR_DATA_FOR_MODEL
|
||||
|
||||
calendar_id = config.calendar_id or "primary"
|
||||
|
||||
@ -735,9 +679,9 @@ class CalendarTool(Tool):
|
||||
else:
|
||||
return f"Error: Unknown action '{action}'. Use 'list_events', 'create_event', 'delete_event', 'delete_events', 'update_event', or 'check_availability'"
|
||||
except HttpError as e:
|
||||
return f"Error accessing Google Calendar API: {e}" + _NO_CALENDAR_DATA_FOR_MODEL
|
||||
return f"Error accessing Google Calendar API: {e}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}" + _NO_CALENDAR_DATA_FOR_MODEL
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
async def _list_events(
|
||||
self, service: Any, calendar_id: str, max_results: int, time_min: str | None
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import imaplib
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
from email import policy
|
||||
from email.header import decode_header, make_header
|
||||
@ -18,39 +17,16 @@ class EmailTool(Tool):
|
||||
"""Read emails from configured IMAP mailbox."""
|
||||
|
||||
name = "read_emails"
|
||||
description = (
|
||||
"USE THIS TOOL FOR OWNER-INITIATED MAILBOX QUERIES. When the user explicitly asks about their inbox "
|
||||
"(e.g. latest email, email sender, unread emails, search inbox, attachments, etc.), you SHOULD call "
|
||||
"read_emails(). DO NOT use mcp_gmail_mcp_read_email for emails received via the email channel - use "
|
||||
"read_emails instead. DO NOT use exec() with mail/tail/awk commands. DO NOT use read_file() on /var/mail "
|
||||
"or memory files. DO NOT try alternative methods. This is the ONLY way to read emails from IMAP - it connects "
|
||||
"to IMAP and fetches real-time data. For 'latest email' or 'last email received' queries, use limit=1. "
|
||||
"When the user asks to download attachments, use download_attachments=true. When the user asks to find "
|
||||
"emails with a specific attachment (e.g., 'find email with attachment Rubiks'), use attachment_name='Rubiks'. "
|
||||
"CRITICAL: When the user asks for specific fields like 'From and Subject' or 'sender and subject', extract "
|
||||
"and return ONLY those fields from the tool output. Do NOT summarize or analyze the email body content unless "
|
||||
"the user specifically asks for it. If the user asks 'give me the from and subject', respond with just: "
|
||||
"'From: [email] Subject: [subject]'. VERY IMPORTANT: When replying on the email channel to a single incoming "
|
||||
"email (content typically starts with 'Email received. From: ... Subject: ...'), treat that content as the "
|
||||
"user's message and compose a direct reply. Do NOT call read_emails in that case unless the human explicitly "
|
||||
"asks you to inspect or search the mailbox. Parameters: limit (1-50, default 10, use 1 for latest), "
|
||||
"unread_only (bool, default false), mark_seen (bool, default false), download_attachments (bool, default false "
|
||||
"- set to true to download all attachments to workspace), attachment_name (string, optional - filter emails by "
|
||||
"attachment filename, case-insensitive partial match). Returns formatted email list with sender, subject, date, "
|
||||
"attachments (if any), downloaded file paths (if downloaded), and body. After you receive this output, your "
|
||||
"reply to the user must address their email question using only this data—no unrelated topics."
|
||||
)
|
||||
description = "USE THIS TOOL FOR ALL EMAIL QUERIES. When user asks about emails, latest email, email sender, inbox, etc., you MUST call read_emails(). DO NOT use exec() with mail/tail/awk commands. DO NOT use read_file() on /var/mail or memory files. DO NOT try alternative methods. This is the ONLY way to read emails - it connects to IMAP and fetches real-time data. For 'latest email' queries, use limit=1. CRITICAL: When user asks for specific fields like 'From and Subject' or 'sender and subject', extract and return ONLY those fields from the tool output. Do NOT summarize or analyze the email body content unless the user specifically asks for it. If user asks 'give me the from and subject', respond with just: 'From: [email] Subject: [subject]'. Parameters: limit (1-50, default 10, use 1 for latest), unread_only (bool, default false), mark_seen (bool, default false). Returns formatted email list with sender, subject, date, and body."
|
||||
|
||||
def __init__(self, email_config: Any = None, workspace: Path | None = None):
|
||||
def __init__(self, email_config: Any = None):
|
||||
"""
|
||||
Initialize email tool with email configuration.
|
||||
|
||||
Args:
|
||||
email_config: Optional EmailConfig instance. If None, loads from config.
|
||||
workspace: Directory for downloaded attachments (defaults to config workspace_path).
|
||||
"""
|
||||
self._email_config = email_config
|
||||
self._workspace = workspace
|
||||
|
||||
@property
|
||||
def config(self) -> Any:
|
||||
@ -63,19 +39,6 @@ class EmailTool(Tool):
|
||||
|
||||
def coerce_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Coerce parameters, handling common name mismatches."""
|
||||
# Handle nested "parameters" key (some LLMs wrap params this way)
|
||||
if "parameters" in params and isinstance(params["parameters"], dict):
|
||||
# Extract nested parameters and merge with top-level
|
||||
nested = params.pop("parameters")
|
||||
params = {**params, **nested}
|
||||
|
||||
# Remove common non-parameter keys that LLMs sometimes include
|
||||
params = params.copy()
|
||||
params.pop("function", None)
|
||||
params.pop("functionName", None)
|
||||
params.pop("function_name", None)
|
||||
params.pop("action", None) # Some LLMs use "action" instead of function name
|
||||
|
||||
coerced = super().coerce_params(params)
|
||||
# Map 'count' to 'limit' if limit not present
|
||||
if 'count' in coerced and 'limit' not in coerced:
|
||||
@ -84,7 +47,7 @@ class EmailTool(Tool):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Remove unsupported parameters
|
||||
supported = {'limit', 'unread_only', 'mark_seen', 'download_attachments', 'attachment_name'}
|
||||
supported = {'limit', 'unread_only', 'mark_seen'}
|
||||
coerced = {k: v for k, v in coerced.items() if k in supported}
|
||||
return coerced
|
||||
|
||||
@ -95,9 +58,9 @@ class EmailTool(Tool):
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of emails to return. REQUIRED for 'latest email' queries - always use limit=1. For multiple emails use limit=5, limit=10, etc. (default: 100, max: 100)",
|
||||
"description": "Number of emails to return. REQUIRED for 'latest email' queries - always use limit=1. For multiple emails use limit=5, limit=10, etc. (default: 10, max: 50)",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"maximum": 50,
|
||||
},
|
||||
"unread_only": {
|
||||
"type": "boolean",
|
||||
@ -107,19 +70,11 @@ class EmailTool(Tool):
|
||||
"type": "boolean",
|
||||
"description": "If true, mark emails as read after fetching. If false, leave read/unread status unchanged (default: false)",
|
||||
},
|
||||
"download_attachments": {
|
||||
"type": "boolean",
|
||||
"description": "If true, download all attachments from the emails to the workspace directory (default: false)",
|
||||
},
|
||||
"attachment_name": {
|
||||
"type": "string",
|
||||
"description": "Optional filter: only return emails that have at least one attachment whose filename contains this string (case-insensitive). Example: 'Rubiks' will match emails with attachments like 'Rubiks_SolutionGuide.pdf'. If not provided, all emails are returned.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
async def execute(self, limit: int = 100, unread_only: bool = False, mark_seen: bool = False, download_attachments: bool = False, attachment_name: str | None = None, **kwargs: Any) -> str:
|
||||
async def execute(self, limit: int = 10, unread_only: bool = False, mark_seen: bool = False, **kwargs: Any) -> str:
|
||||
"""
|
||||
Read emails from IMAP mailbox.
|
||||
|
||||
@ -132,27 +87,6 @@ class EmailTool(Tool):
|
||||
Returns:
|
||||
Formatted string with email information
|
||||
"""
|
||||
# Convert limit to int if it's a string (from JSON parsing)
|
||||
if isinstance(limit, str):
|
||||
try:
|
||||
limit = int(limit)
|
||||
except (ValueError, TypeError):
|
||||
limit = 10
|
||||
|
||||
# Convert boolean parameters from strings if needed (from JSON parsing)
|
||||
if isinstance(download_attachments, str):
|
||||
download_attachments = download_attachments.lower() in ("true", "1", "yes", "on")
|
||||
if isinstance(unread_only, str):
|
||||
unread_only = unread_only.lower() in ("true", "1", "yes", "on")
|
||||
if isinstance(mark_seen, str):
|
||||
mark_seen = mark_seen.lower() in ("true", "1", "yes", "on")
|
||||
|
||||
# Normalize attachment_name (empty string or None means no filter)
|
||||
if attachment_name is not None:
|
||||
attachment_name = str(attachment_name).strip()
|
||||
if not attachment_name:
|
||||
attachment_name = None
|
||||
|
||||
# Handle common parameter name mismatches (agent sometimes uses 'count' instead of 'limit')
|
||||
# Also handle if count is passed as a positional argument via kwargs
|
||||
if 'count' in kwargs:
|
||||
@ -192,9 +126,9 @@ class EmailTool(Tool):
|
||||
|
||||
# Limit to reasonable maximum
|
||||
try:
|
||||
limit = min(max(1, int(limit)), 100)
|
||||
limit = min(max(1, int(limit)), 50)
|
||||
except (ValueError, TypeError):
|
||||
limit = 100
|
||||
limit = 10
|
||||
|
||||
try:
|
||||
messages = await asyncio.to_thread(
|
||||
@ -202,14 +136,10 @@ class EmailTool(Tool):
|
||||
unread_only=unread_only,
|
||||
mark_seen=mark_seen,
|
||||
limit=limit,
|
||||
download_attachments=download_attachments,
|
||||
attachment_name=attachment_name,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
if attachment_name:
|
||||
return f"No emails found with attachments matching '{attachment_name}'. Try increasing the limit or checking if the attachment name is correct."
|
||||
elif unread_only:
|
||||
if unread_only:
|
||||
return "No unread emails found in your inbox."
|
||||
else:
|
||||
return f"No emails found in your inbox. The mailbox appears to be empty or there was an issue retrieving emails."
|
||||
@ -220,12 +150,6 @@ class EmailTool(Tool):
|
||||
result_parts.append(f"From: {msg['sender']}")
|
||||
result_parts.append(f"Subject: {msg['subject']}")
|
||||
result_parts.append(f"Date: {msg['metadata']['date']}")
|
||||
if msg['metadata'].get('attachments'):
|
||||
att_list = ', '.join([a['filename'] for a in msg['metadata']['attachments']])
|
||||
result_parts.append(f"Attachments: {att_list}")
|
||||
if msg['metadata'].get('downloaded_files'):
|
||||
dl_list = ', '.join(msg['metadata']['downloaded_files'])
|
||||
result_parts.append(f"Downloaded to: {dl_list}")
|
||||
# Only include body content if specifically requested, otherwise keep it brief
|
||||
result_parts.append(f"\nBody: {msg['content'][:500]}..." if len(msg['content']) > 500 else f"\nBody: {msg['content']}")
|
||||
|
||||
@ -241,8 +165,6 @@ class EmailTool(Tool):
|
||||
unread_only: bool,
|
||||
mark_seen: bool,
|
||||
limit: int,
|
||||
download_attachments: bool = False,
|
||||
attachment_name: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch messages from IMAP mailbox."""
|
||||
messages: list[dict[str, Any]] = []
|
||||
@ -312,105 +234,6 @@ class EmailTool(Tool):
|
||||
date_value = parsed.get("Date", "")
|
||||
message_id = parsed.get("Message-ID", "").strip()
|
||||
body = self._extract_text_body(parsed)
|
||||
attachments = self._extract_attachments(parsed)
|
||||
|
||||
# Download attachments if requested
|
||||
downloaded_files = []
|
||||
if download_attachments and attachments:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
if self._workspace is not None:
|
||||
workspace = Path(self._workspace).expanduser().resolve()
|
||||
else:
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
workspace = load_config().workspace_path.expanduser().resolve()
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build a map of attachment parts by decoded filename for efficient lookup
|
||||
attachment_parts = {}
|
||||
for part in parsed.walk():
|
||||
if part.get_content_disposition() == "attachment":
|
||||
part_filename = part.get_filename()
|
||||
if part_filename:
|
||||
try:
|
||||
from email.header import decode_header, make_header
|
||||
decoded_part_filename = str(make_header(decode_header(part_filename)))
|
||||
except Exception:
|
||||
decoded_part_filename = part_filename
|
||||
attachment_parts[decoded_part_filename] = part
|
||||
logger.debug(f"Found attachment part: '{decoded_part_filename}' (original: '{part_filename}')")
|
||||
|
||||
logger.debug(f"Total attachment parts found: {len(attachment_parts)}, requested attachments: {len(attachments)}")
|
||||
if attachments:
|
||||
logger.debug(f"Requested attachment filenames: {[a['filename'] for a in attachments]}")
|
||||
|
||||
# Download each attachment
|
||||
for att_info in attachments:
|
||||
filename = att_info['filename']
|
||||
matched_filename = filename # Will be updated if we match by base name
|
||||
try:
|
||||
# Try exact match first
|
||||
part = attachment_parts.get(filename)
|
||||
|
||||
# If no exact match, try case-insensitive and normalized matching
|
||||
if part is None:
|
||||
filename_lower = filename.lower().strip()
|
||||
for decoded_name, part_candidate in attachment_parts.items():
|
||||
decoded_lower = decoded_name.lower().strip()
|
||||
if decoded_lower == filename_lower:
|
||||
part = part_candidate
|
||||
matched_filename = decoded_name
|
||||
logger.debug(f"Matched attachment '{filename}' using case-insensitive match with '{decoded_name}'")
|
||||
break
|
||||
|
||||
# If still no match, try matching by base filename (strip common prefixes like Gmail attachment IDs)
|
||||
if part is None:
|
||||
# Extract base filename (everything after last underscore or use full name)
|
||||
# Gmail sometimes adds prefixes like "65afea09c4f7a02afbb9d876_filename.pdf"
|
||||
base_filename = filename
|
||||
if '_' in filename:
|
||||
# Try to match the part after the last underscore if it looks like a hash prefix
|
||||
parts = filename.rsplit('_', 1)
|
||||
if len(parts) == 2 and len(parts[0]) >= 20 and parts[0].isalnum():
|
||||
# Looks like a hash prefix, use the base name
|
||||
base_filename = parts[1]
|
||||
|
||||
base_lower = base_filename.lower().strip()
|
||||
for decoded_name, part_candidate in attachment_parts.items():
|
||||
decoded_lower = decoded_name.lower().strip()
|
||||
# Check if decoded name matches base filename
|
||||
if decoded_lower == base_lower:
|
||||
part = part_candidate
|
||||
matched_filename = decoded_name
|
||||
logger.debug(f"Matched attachment '{filename}' by base filename '{base_filename}' with '{decoded_name}'")
|
||||
break
|
||||
# Also check if decoded name ends with base filename (in case it has its own prefix)
|
||||
if decoded_lower.endswith(base_lower) or base_lower.endswith(decoded_lower):
|
||||
part = part_candidate
|
||||
matched_filename = decoded_name
|
||||
logger.debug(f"Matched attachment '{filename}' by partial match: base '{base_filename}' with '{decoded_name}'")
|
||||
break
|
||||
|
||||
if part is None:
|
||||
logger.warning(f"Could not find attachment part for filename: {filename}")
|
||||
logger.debug(f"Available attachment filenames: {list(attachment_parts.keys())}")
|
||||
continue
|
||||
|
||||
# Save the attachment using the matched filename (cleaner, without prefixes)
|
||||
att_data = part.get_payload(decode=True)
|
||||
if att_data:
|
||||
# Sanitize filename but preserve extension
|
||||
safe_filename = "".join(c for c in matched_filename if c.isalnum() or c in "._- ")
|
||||
safe_filename = safe_filename.replace(" ", "_")
|
||||
file_path = workspace / safe_filename
|
||||
file_path.write_bytes(att_data)
|
||||
downloaded_files.append(str(file_path))
|
||||
logger.info(f"Downloaded attachment '{filename}' (matched as '{matched_filename}') to {file_path}")
|
||||
else:
|
||||
logger.warning(f"Attachment '{filename}' has no data to save")
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading attachment '{filename}': {str(e)}", exc_info=True)
|
||||
|
||||
if not body:
|
||||
body = "(empty email body)"
|
||||
@ -423,37 +246,15 @@ class EmailTool(Tool):
|
||||
f"Email received.\n"
|
||||
f"From: {sender}\n"
|
||||
f"Subject: {subject}\n"
|
||||
f"Date: {date_value}\n"
|
||||
f"Date: {date_value}\n\n"
|
||||
f"{body}"
|
||||
)
|
||||
if attachments:
|
||||
content += f"Attachments: {', '.join([a['filename'] for a in attachments])}\n"
|
||||
if downloaded_files:
|
||||
content += f"Downloaded attachments to: {', '.join(downloaded_files)}\n"
|
||||
content += f"\n{body}"
|
||||
|
||||
# Filter by attachment name if specified
|
||||
if attachment_name:
|
||||
attachment_name_lower = attachment_name.lower().strip()
|
||||
# Check if any attachment filename contains the search term (case-insensitive)
|
||||
has_matching_attachment = False
|
||||
if attachments:
|
||||
for att in attachments:
|
||||
att_filename_lower = att['filename'].lower()
|
||||
if attachment_name_lower in att_filename_lower:
|
||||
has_matching_attachment = True
|
||||
break
|
||||
|
||||
# Skip this email if it doesn't have a matching attachment
|
||||
if not has_matching_attachment:
|
||||
continue
|
||||
|
||||
metadata = {
|
||||
"message_id": message_id,
|
||||
"subject": subject,
|
||||
"date": date_value,
|
||||
"sender_email": sender,
|
||||
"attachments": attachments,
|
||||
"downloaded_files": downloaded_files,
|
||||
}
|
||||
|
||||
messages.append({
|
||||
@ -492,29 +293,6 @@ class EmailTool(Tool):
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _extract_attachments(msg: Any) -> list[dict[str, str]]:
|
||||
"""Extract attachment information from email message."""
|
||||
attachments = []
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
disposition = part.get_content_disposition()
|
||||
if disposition == "attachment":
|
||||
filename = part.get_filename()
|
||||
if filename:
|
||||
# Decode filename if needed
|
||||
try:
|
||||
from email.header import decode_header, make_header
|
||||
decoded_filename = str(make_header(decode_header(filename)))
|
||||
except Exception:
|
||||
decoded_filename = filename
|
||||
attachments.append({
|
||||
"filename": decoded_filename,
|
||||
"content_type": part.get_content_type(),
|
||||
"size": len(part.get_payload(decode=True) or b"") if part.get_payload(decode=True) else 0,
|
||||
})
|
||||
return attachments
|
||||
|
||||
@staticmethod
|
||||
def _extract_text_body(msg: Any) -> str:
|
||||
"""Extract readable text body from email message."""
|
||||
|
||||
@ -28,9 +28,7 @@ class ReadFileTool(Tool):
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return """Read the contents of a file at the given path.
|
||||
|
||||
`path` must be a single file path under the configured workspace (no `*` globs).
|
||||
return """Read the contents of a file at the given path.
|
||||
|
||||
ALWAYS use this tool to read files - it supports:
|
||||
- Text files (plain text, code, markdown, etc.)
|
||||
@ -46,7 +44,7 @@ For reading files, use read_file FIRST. Only use exec for complex data processin
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Absolute or workspace-relative path to one file (no wildcards)",
|
||||
"description": "The file path to read"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
@ -117,7 +115,7 @@ class WriteFileTool(Tool):
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Write content to a file at the given path. Creates parent directories if needed. IMPORTANT: Always provide both 'path' and 'content' parameters. Paths must be under the workspace root from the system prompt (no globs)."
|
||||
return "Write content to a file at the given path. Creates parent directories if needed."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
@ -221,11 +219,7 @@ class ListDirTool(Tool):
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"List files and subfolders in one directory. "
|
||||
"`path` must be a directory that exists under the workspace root—no `*` or `*.pdf` wildcards. "
|
||||
"To list PDFs, list the directory and read names ending in .pdf, or use exec with find."
|
||||
)
|
||||
return "List the contents of a directory."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
@ -234,7 +228,7 @@ class ListDirTool(Tool):
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to an existing directory under the workspace (no wildcards)",
|
||||
"description": "The directory path to list"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
|
||||
@ -13,65 +9,15 @@ from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
|
||||
_SAFE_TOOL_NAME_RE = re.compile(r"[^A-Za-z0-9_]+")
|
||||
|
||||
|
||||
def _normalize_tool_segment(segment: str) -> str:
|
||||
"""
|
||||
Normalize MCP server/tool names into a safe function name segment.
|
||||
|
||||
- Replace non [A-Za-z0-9_] with underscore
|
||||
- Collapse repeated underscores
|
||||
- Trim leading/trailing underscores
|
||||
- Ensure non-empty
|
||||
"""
|
||||
s = _SAFE_TOOL_NAME_RE.sub("_", (segment or "").strip())
|
||||
s = re.sub(r"_+", "_", s).strip("_")
|
||||
return s or "tool"
|
||||
|
||||
|
||||
def _render_mcp_content_blocks(blocks: list[Any]) -> str:
|
||||
"""Render MCP content blocks into a stable, readable string."""
|
||||
from mcp import types
|
||||
|
||||
parts: list[str] = []
|
||||
for block in blocks or []:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
continue
|
||||
|
||||
# Prefer structured JSON for non-text blocks when possible.
|
||||
dump = getattr(block, "model_dump", None)
|
||||
if callable(dump):
|
||||
try:
|
||||
parts.append(json.dumps(dump(), ensure_ascii=False, indent=2))
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parts.append(str(block))
|
||||
return "\n".join([p for p in parts if p is not None]).strip()
|
||||
|
||||
|
||||
class MCPToolWrapper(Tool):
|
||||
"""Wraps a single MCP server tool as a nanobot Tool."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
server_key: str,
|
||||
tool_def,
|
||||
registered_name: str,
|
||||
call_timeout_s: float = 30.0,
|
||||
):
|
||||
def __init__(self, session, server_name: str, tool_def):
|
||||
self._session = session
|
||||
self._original_name = tool_def.name
|
||||
self._server_key = server_key
|
||||
self._name = registered_name
|
||||
self._name = f"mcp_{server_name}_{tool_def.name}"
|
||||
self._description = tool_def.description or tool_def.name
|
||||
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
|
||||
self._call_timeout_s = call_timeout_s
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -86,103 +32,49 @@ class MCPToolWrapper(Tool):
|
||||
return self._parameters
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self._session.call_tool(self._original_name, arguments=kwargs),
|
||||
timeout=self._call_timeout_s,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return (
|
||||
f"Error: MCP tool timed out after {self._call_timeout_s:.0f}s "
|
||||
f"({self._server_key}:{self._original_name})"
|
||||
)
|
||||
|
||||
output = _render_mcp_content_blocks(getattr(result, "content", []))
|
||||
if not output:
|
||||
return "(no output)"
|
||||
|
||||
# If the tool returned JSON, normalize empty collections to a clearer message.
|
||||
try:
|
||||
parsed = json.loads(output)
|
||||
if parsed == [] or parsed == {}:
|
||||
return "No results found."
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass # Not JSON, continue with original output
|
||||
|
||||
return output
|
||||
|
||||
|
||||
async def connect_mcp_server(
|
||||
name: str, cfg: Any, registry: ToolRegistry, stack: AsyncExitStack
|
||||
) -> None:
|
||||
"""Connect one MCP server and register its tools (used for lazy profile-scoped connections)."""
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
def _expand_env(env: dict[str, str]) -> dict[str, str]:
|
||||
"""
|
||||
Expand $VARS in cfg.env using the current process environment.
|
||||
|
||||
This lets configs safely reference secrets that are already injected into the
|
||||
container environment (e.g. via .env.shared), without duplicating them in JSON:
|
||||
{ "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" }
|
||||
"""
|
||||
if not env:
|
||||
return {}
|
||||
expanded: dict[str, str] = {}
|
||||
for k, v in env.items():
|
||||
if v is None:
|
||||
continue
|
||||
expanded[k] = os.path.expandvars(str(v))
|
||||
return expanded
|
||||
|
||||
if cfg.command:
|
||||
params = StdioServerParameters(
|
||||
command=cfg.command, args=cfg.args, env=_expand_env(cfg.env) or None
|
||||
)
|
||||
read, write = await stack.enter_async_context(stdio_client(params))
|
||||
elif cfg.url:
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
read, write, _ = await stack.enter_async_context(
|
||||
streamable_http_client(cfg.url)
|
||||
)
|
||||
else:
|
||||
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
|
||||
return
|
||||
|
||||
session = await stack.enter_async_context(ClientSession(read, write))
|
||||
await session.initialize()
|
||||
|
||||
tools = await session.list_tools()
|
||||
for tool_def in tools.tools:
|
||||
safe_server = _normalize_tool_segment(name)
|
||||
safe_tool = _normalize_tool_segment(tool_def.name)
|
||||
base = f"mcp_{safe_server}_{safe_tool}"
|
||||
registered_name = base
|
||||
i = 2
|
||||
while registry.has(registered_name):
|
||||
registered_name = f"{base}_{i}"
|
||||
i += 1
|
||||
|
||||
wrapper = MCPToolWrapper(
|
||||
session,
|
||||
server_key=name,
|
||||
tool_def=tool_def,
|
||||
registered_name=registered_name,
|
||||
)
|
||||
registry.register(wrapper)
|
||||
logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
|
||||
|
||||
logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
|
||||
from mcp import types
|
||||
result = await self._session.call_tool(self._original_name, arguments=kwargs)
|
||||
parts = []
|
||||
for block in result.content:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
else:
|
||||
parts.append(str(block))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
|
||||
|
||||
async def connect_mcp_servers(
|
||||
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
|
||||
) -> None:
|
||||
"""Connect to every configured MCP server and register their tools."""
|
||||
"""Connect to configured MCP servers and register their tools."""
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
for name, cfg in mcp_servers.items():
|
||||
try:
|
||||
await connect_mcp_server(name, cfg, registry, stack)
|
||||
if cfg.command:
|
||||
params = StdioServerParameters(
|
||||
command=cfg.command, args=cfg.args, env=cfg.env or None
|
||||
)
|
||||
read, write = await stack.enter_async_context(stdio_client(params))
|
||||
elif cfg.url:
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
read, write, _ = await stack.enter_async_context(
|
||||
streamable_http_client(cfg.url)
|
||||
)
|
||||
else:
|
||||
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
|
||||
continue
|
||||
|
||||
session = await stack.enter_async_context(ClientSession(read, write))
|
||||
await session.initialize()
|
||||
|
||||
tools = await session.list_tools()
|
||||
for tool_def in tools.tools:
|
||||
wrapper = MCPToolWrapper(session, name, tool_def)
|
||||
registry.register(wrapper)
|
||||
logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
|
||||
|
||||
logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
|
||||
except Exception as e:
|
||||
logger.error(f"MCP server '{name}': failed to connect: {e}")
|
||||
|
||||
@ -8,52 +8,44 @@ from nanobot.agent.tools.base import Tool
|
||||
class ToolRegistry:
|
||||
"""
|
||||
Registry for agent tools.
|
||||
|
||||
|
||||
Allows dynamic registration and execution of tools.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self._tools: dict[str, Tool] = {}
|
||||
|
||||
|
||||
def register(self, tool: Tool) -> None:
|
||||
"""Register a tool."""
|
||||
self._tools[tool.name] = tool
|
||||
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Unregister a tool by name."""
|
||||
self._tools.pop(name, None)
|
||||
|
||||
|
||||
def get(self, name: str) -> Tool | None:
|
||||
"""Get a tool by name."""
|
||||
return self._tools.get(name)
|
||||
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""Check if a tool is registered."""
|
||||
return name in self._tools
|
||||
|
||||
|
||||
def get_definitions(self) -> list[dict[str, Any]]:
|
||||
"""Get all tool definitions in OpenAI format."""
|
||||
return [tool.to_schema() for tool in self._tools.values()]
|
||||
|
||||
def get_definitions_subset(self, names: set[str]) -> list[dict[str, Any]]:
|
||||
"""Tool definitions for the given names only (preserves registration order)."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for key, tool in self._tools.items():
|
||||
if key in names:
|
||||
out.append(tool.to_schema())
|
||||
return out
|
||||
|
||||
|
||||
async def execute(self, name: str, params: dict[str, Any]) -> str:
|
||||
"""
|
||||
Execute a tool by name with given parameters.
|
||||
|
||||
|
||||
Args:
|
||||
name: Tool name.
|
||||
params: Tool parameters.
|
||||
|
||||
|
||||
Returns:
|
||||
Tool execution result as string.
|
||||
|
||||
|
||||
Raises:
|
||||
KeyError: If tool not found.
|
||||
"""
|
||||
@ -70,14 +62,14 @@ class ToolRegistry:
|
||||
return await tool.execute(**coerced_params)
|
||||
except Exception as e:
|
||||
return f"Error executing {name}: {str(e)}"
|
||||
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]:
|
||||
"""Get list of registered tool names."""
|
||||
return list(self._tools.keys())
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._tools)
|
||||
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._tools
|
||||
|
||||
@ -2,22 +2,23 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import select
|
||||
import sys
|
||||
|
||||
import typer
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from nanobot import __logo__, __version__
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
|
||||
from nanobot import __version__, __logo__
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
app = typer.Typer(
|
||||
@ -158,9 +159,9 @@ def onboard():
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.helpers import get_workspace_path
|
||||
|
||||
|
||||
config_path = get_config_path()
|
||||
|
||||
|
||||
if config_path.exists():
|
||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
|
||||
@ -176,17 +177,17 @@ def onboard():
|
||||
else:
|
||||
save_config(Config())
|
||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||
|
||||
|
||||
# Create workspace
|
||||
workspace = get_workspace_path()
|
||||
|
||||
|
||||
if not workspace.exists():
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||
|
||||
|
||||
# Create default bootstrap files
|
||||
_create_workspace_templates(workspace)
|
||||
|
||||
|
||||
console.print(f"\n{__logo__} nanobot is ready!")
|
||||
console.print("\nNext steps:")
|
||||
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
|
||||
@ -238,13 +239,13 @@ Information about the user goes here.
|
||||
- Language: (your preferred language)
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
for filename, content in templates.items():
|
||||
file_path = workspace / filename
|
||||
if not file_path.exists():
|
||||
file_path.write_text(content)
|
||||
console.print(f" [dim]Created {filename}[/dim]")
|
||||
|
||||
|
||||
# Create memory directory and MEMORY.md
|
||||
memory_dir = workspace / "memory"
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
@ -267,7 +268,7 @@ This file stores important information that should persist across sessions.
|
||||
(Things to remember)
|
||||
""")
|
||||
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
||||
|
||||
|
||||
history_file = memory_dir / "HISTORY.md"
|
||||
if not history_file.exists():
|
||||
history_file.write_text("")
|
||||
@ -280,9 +281,9 @@ This file stores important information that should persist across sessions.
|
||||
|
||||
def _make_provider(config: Config):
|
||||
"""Create the appropriate LLM provider from config."""
|
||||
from nanobot.providers.custom_provider import CustomProvider
|
||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
from nanobot.providers.custom_provider import CustomProvider
|
||||
|
||||
model = config.agents.defaults.model
|
||||
provider_name = config.get_provider_name(model)
|
||||
@ -309,7 +310,7 @@ def _make_provider(config: Config):
|
||||
airllm_config = getattr(config.providers, "airllm", None)
|
||||
model_path = None
|
||||
compression = None
|
||||
|
||||
|
||||
# Try to get model from airllm config's api_key field (repurposed as model path)
|
||||
# or from the default model
|
||||
if airllm_config and airllm_config.api_key:
|
||||
@ -324,7 +325,7 @@ def _make_provider(config: Config):
|
||||
else:
|
||||
model_path = model
|
||||
hf_token = None
|
||||
|
||||
|
||||
# Check for compression setting in extra_headers or api_base
|
||||
if airllm_config:
|
||||
if airllm_config.api_base:
|
||||
@ -334,7 +335,7 @@ def _make_provider(config: Config):
|
||||
# Check for HF token in extra_headers
|
||||
if not hf_token and airllm_config.extra_headers and "hf_token" in airllm_config.extra_headers:
|
||||
hf_token = airllm_config.extra_headers["hf_token"]
|
||||
|
||||
|
||||
return AirLLMProvider(
|
||||
api_key=airllm_config.api_key if airllm_config else None,
|
||||
api_base=compression if compression else None,
|
||||
@ -374,30 +375,30 @@ def gateway(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""Start the nanobot gateway."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.config.loader import load_config, get_data_dir
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.config.loader import get_data_dir, load_config
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
if verbose:
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||
|
||||
|
||||
config = load_config()
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
session_manager = SessionManager(config.workspace_path)
|
||||
|
||||
|
||||
# Create cron service first (callback set after agent creation)
|
||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
cron = CronService(cron_store_path)
|
||||
|
||||
|
||||
# Create agent with cron service
|
||||
agent = AgentLoop(
|
||||
bus=bus,
|
||||
@ -414,11 +415,8 @@ def gateway(
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
session_manager=session_manager,
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
tool_profiles=config.tools.tool_profiles,
|
||||
default_tool_profile=config.tools.default_tool_profile,
|
||||
tool_routing=config.tools.tool_routing,
|
||||
)
|
||||
|
||||
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
@ -451,33 +449,33 @@ def gateway(
|
||||
))
|
||||
return response
|
||||
cron.on_job = on_cron_job
|
||||
|
||||
|
||||
# Create heartbeat service
|
||||
async def on_heartbeat(prompt: str) -> str:
|
||||
"""Execute heartbeat through the agent."""
|
||||
return await agent.process_direct(prompt, session_key="heartbeat")
|
||||
|
||||
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=config.workspace_path,
|
||||
on_heartbeat=on_heartbeat,
|
||||
interval_s=30 * 60, # 30 minutes
|
||||
enabled=True
|
||||
)
|
||||
|
||||
|
||||
# Create channel manager
|
||||
channels = ChannelManager(config, bus)
|
||||
|
||||
|
||||
if channels.enabled_channels:
|
||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||
else:
|
||||
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
||||
|
||||
|
||||
cron_status = cron.status()
|
||||
if cron_status["jobs"] > 0:
|
||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||
|
||||
console.print("[green]✓[/green] Heartbeat: every 30m")
|
||||
|
||||
|
||||
console.print(f"[green]✓[/green] Heartbeat: every 30m")
|
||||
|
||||
async def run():
|
||||
try:
|
||||
await cron.start()
|
||||
@ -494,7 +492,7 @@ def gateway(
|
||||
cron.stop()
|
||||
agent.stop()
|
||||
await channels.stop_all()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@ -513,16 +511,15 @@ def agent(
|
||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
||||
):
|
||||
"""Interact with the agent directly."""
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.config.loader import load_config, get_data_dir
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.loader import get_data_dir, load_config
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Load config (this also loads .env file into environment)
|
||||
config = load_config()
|
||||
|
||||
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
|
||||
@ -534,7 +531,7 @@ def agent(
|
||||
logger.enable("nanobot")
|
||||
else:
|
||||
logger.disable("nanobot")
|
||||
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
@ -549,11 +546,8 @@ def agent(
|
||||
cron_service=cron,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
tool_profiles=config.tools.tool_profiles,
|
||||
default_tool_profile=config.tools.default_tool_profile,
|
||||
tool_routing=config.tools.tool_routing,
|
||||
)
|
||||
|
||||
|
||||
# Show spinner when logs are off (no output to miss); skip when logs are on
|
||||
def _thinking_ctx():
|
||||
if logs:
|
||||
@ -579,7 +573,7 @@ def agent(
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
||||
raise
|
||||
|
||||
|
||||
asyncio.run(run_once())
|
||||
else:
|
||||
# Interactive mode
|
||||
@ -592,7 +586,7 @@ def agent(
|
||||
os._exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, _exit_on_sigint)
|
||||
|
||||
|
||||
async def run_interactive():
|
||||
try:
|
||||
while True:
|
||||
@ -607,7 +601,7 @@ def agent(
|
||||
_restore_terminal()
|
||||
console.print("\nGoodbye!")
|
||||
break
|
||||
|
||||
|
||||
with _thinking_ctx():
|
||||
response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
|
||||
_print_agent_response(response, render_markdown=markdown)
|
||||
@ -621,7 +615,7 @@ def agent(
|
||||
break
|
||||
finally:
|
||||
await agent_loop.close_mcp()
|
||||
|
||||
|
||||
asyncio.run(run_interactive())
|
||||
|
||||
|
||||
@ -678,7 +672,7 @@ def channels_status():
|
||||
"✓" if mc.enabled else "✗",
|
||||
mc_base
|
||||
)
|
||||
|
||||
|
||||
# Telegram
|
||||
tg = config.channels.telegram
|
||||
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
||||
@ -704,57 +698,57 @@ def _get_bridge_dir() -> Path:
|
||||
"""Get the bridge directory, setting it up if needed."""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
# User's bridge location
|
||||
user_bridge = Path.home() / ".nanobot" / "bridge"
|
||||
|
||||
|
||||
# Check if already built
|
||||
if (user_bridge / "dist" / "index.js").exists():
|
||||
return user_bridge
|
||||
|
||||
|
||||
# Check for npm
|
||||
if not shutil.which("npm"):
|
||||
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# Find source bridge: first check package data, then source dir
|
||||
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
||||
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
|
||||
|
||||
|
||||
source = None
|
||||
if (pkg_bridge / "package.json").exists():
|
||||
source = pkg_bridge
|
||||
elif (src_bridge / "package.json").exists():
|
||||
source = src_bridge
|
||||
|
||||
|
||||
if not source:
|
||||
console.print("[red]Bridge source not found.[/red]")
|
||||
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
console.print(f"{__logo__} Setting up bridge...")
|
||||
|
||||
|
||||
# Copy to user directory
|
||||
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
||||
if user_bridge.exists():
|
||||
shutil.rmtree(user_bridge)
|
||||
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
||||
|
||||
|
||||
# Install and build
|
||||
try:
|
||||
console.print(" Installing dependencies...")
|
||||
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
|
||||
|
||||
|
||||
console.print(" Building...")
|
||||
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
||||
|
||||
|
||||
console.print("[green]✓[/green] Bridge ready\n")
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]Build failed: {e}[/red]")
|
||||
if e.stderr:
|
||||
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
return user_bridge
|
||||
|
||||
|
||||
@ -762,19 +756,18 @@ def _get_bridge_dir() -> Path:
|
||||
def channels_login():
|
||||
"""Link device via QR code."""
|
||||
import subprocess
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
|
||||
config = load_config()
|
||||
bridge_dir = _get_bridge_dir()
|
||||
|
||||
|
||||
console.print(f"{__logo__} Starting bridge...")
|
||||
console.print("Scan the QR code to connect.\n")
|
||||
|
||||
|
||||
env = {**os.environ}
|
||||
if config.channels.whatsapp.bridge_token:
|
||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||
|
||||
|
||||
try:
|
||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||
except subprocess.CalledProcessError as e:
|
||||
@ -798,23 +791,23 @@ def cron_list(
|
||||
"""List scheduled jobs."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
jobs = service.list_jobs(include_disabled=all)
|
||||
|
||||
|
||||
if not jobs:
|
||||
console.print("No scheduled jobs.")
|
||||
return
|
||||
|
||||
|
||||
table = Table(title="Scheduled Jobs")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Schedule")
|
||||
table.add_column("Status")
|
||||
table.add_column("Next Run")
|
||||
|
||||
|
||||
import time
|
||||
from datetime import datetime as _dt
|
||||
from zoneinfo import ZoneInfo
|
||||
@ -826,7 +819,7 @@ def cron_list(
|
||||
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
|
||||
else:
|
||||
sched = "one-time"
|
||||
|
||||
|
||||
# Format next run
|
||||
next_run = ""
|
||||
if job.state.next_run_at_ms:
|
||||
@ -836,11 +829,11 @@ def cron_list(
|
||||
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
||||
|
||||
|
||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
||||
|
||||
|
||||
table.add_row(job.id, job.name, sched, status, next_run)
|
||||
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@ -860,7 +853,7 @@ def cron_add(
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
|
||||
if tz and not cron_expr:
|
||||
console.print("[red]Error: --tz can only be used with --cron[/red]")
|
||||
raise typer.Exit(1)
|
||||
@ -877,10 +870,10 @@ def cron_add(
|
||||
else:
|
||||
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
job = service.add_job(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
@ -889,7 +882,7 @@ def cron_add(
|
||||
to=to,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
|
||||
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
||||
|
||||
|
||||
@ -900,10 +893,10 @@ def cron_remove(
|
||||
"""Remove a scheduled job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
if service.remove_job(job_id):
|
||||
console.print(f"[green]✓[/green] Removed job {job_id}")
|
||||
else:
|
||||
@ -918,10 +911,10 @@ def cron_enable(
|
||||
"""Enable or disable a job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
job = service.enable_job(job_id, enabled=not disable)
|
||||
if job:
|
||||
status = "disabled" if disable else "enabled"
|
||||
@ -938,15 +931,15 @@ def cron_run(
|
||||
"""Manually run a job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
async def run():
|
||||
return await service.run_job(job_id, force=force)
|
||||
|
||||
|
||||
if asyncio.run(run()):
|
||||
console.print("[green]✓[/green] Job executed")
|
||||
console.print(f"[green]✓[/green] Job executed")
|
||||
else:
|
||||
console.print(f"[red]Failed to run job {job_id}[/red]")
|
||||
|
||||
@ -959,7 +952,7 @@ def cron_run(
|
||||
@app.command()
|
||||
def status():
|
||||
"""Show nanobot status."""
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
from nanobot.config.loader import load_config, get_config_path
|
||||
|
||||
config_path = get_config_path()
|
||||
config = load_config()
|
||||
@ -974,7 +967,7 @@ def status():
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
console.print(f"Model: {config.agents.defaults.model}")
|
||||
|
||||
|
||||
# Check API keys from registry
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(config.providers, spec.name, None)
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
"""Configuration schema using Pydantic."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@ -271,47 +270,14 @@ class MCPServerConfig(Base):
|
||||
url: str = "" # HTTP: streamable HTTP endpoint URL
|
||||
|
||||
|
||||
class ToolProfileConfig(Base):
|
||||
"""Subset of tools exposed to the LLM when this profile is active."""
|
||||
|
||||
description: str = "" # Shown to the router model when toolRouting is enabled
|
||||
builtin_tools: list[str] | None = None # None = all non-MCP tools; [] = none (except always-include)
|
||||
mcp_servers: list[str] | None = None # None = all configured MCP servers; [] = no MCP tools
|
||||
|
||||
|
||||
class ToolRoutingConfig(Base):
|
||||
"""Optional LLM router that picks a tool profile from the user message (phase 2)."""
|
||||
|
||||
enabled: bool = False
|
||||
router_temperature: float = 0.2
|
||||
router_max_tokens: int = 128
|
||||
# Always merged into the allowed set (if registered), e.g. channel reply + subagent spawn
|
||||
always_include_tools: list[str] = Field(default_factory=lambda: ["message", "spawn"])
|
||||
# If the model calls a missing tool, retry the loop once with all tools registered
|
||||
expand_on_missing_tool: bool = True
|
||||
|
||||
|
||||
class ToolsConfig(Base):
|
||||
"""Tools configuration."""
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
||||
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
||||
calendar: CalendarConfig = Field(default_factory=CalendarConfig)
|
||||
restrict_to_workspace: bool = True # If true, restrict all tool access to workspace directory
|
||||
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
|
||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||
tool_profiles: dict[str, ToolProfileConfig] = Field(default_factory=dict)
|
||||
default_tool_profile: str = "default"
|
||||
tool_routing: ToolRoutingConfig = Field(default_factory=ToolRoutingConfig)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _tool_profiles_consistent(self) -> "ToolsConfig":
|
||||
if self.tool_profiles and self.default_tool_profile not in self.tool_profiles:
|
||||
raise ValueError(
|
||||
f"defaultToolProfile '{self.default_tool_profile}' is missing from tools.toolProfiles"
|
||||
)
|
||||
if self.tool_routing.enabled and not self.tool_profiles:
|
||||
raise ValueError("toolRouting.enabled requires a non-empty tools.toolProfiles map")
|
||||
return self
|
||||
|
||||
|
||||
class Config(BaseSettings):
|
||||
|
||||
@ -59,38 +59,15 @@ class CustomProvider(LLMProvider):
|
||||
for tc in (msg.tool_calls or [])
|
||||
]
|
||||
|
||||
# If no structured tool calls, try to parse from content (some OpenAI-compatible backends return JSON in content)
|
||||
# If no structured tool calls, try to parse from content (Ollama sometimes returns JSON in content)
|
||||
# Only parse if content looks like it contains a tool call JSON (to avoid false positives)
|
||||
content = msg.content or ""
|
||||
stripped = content.strip()
|
||||
# Note: This list should match tools registered in AgentLoop._register_default_tools().
|
||||
# MCP tools are registered dynamically and are prefixed with "mcp_" (allow those too).
|
||||
valid_tools = [
|
||||
# File tools
|
||||
"read_file", "write_file", "edit_file", "list_dir",
|
||||
# Shell tool
|
||||
"exec",
|
||||
# Web tools
|
||||
"web_search", "web_fetch",
|
||||
# Communication tools
|
||||
"message", "spawn",
|
||||
# Calendar tool
|
||||
"calendar",
|
||||
# Cron tool
|
||||
"cron",
|
||||
# Email tool
|
||||
"email",
|
||||
]
|
||||
# Check for standard format: {"name": "...", "parameters": {...}}
|
||||
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"]))
|
||||
|
||||
# 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("}")
|
||||
|
||||
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):
|
||||
import re
|
||||
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...}
|
||||
# Find complete JSON objects by matching braces
|
||||
@ -154,11 +131,28 @@ class CustomProvider(LLMProvider):
|
||||
continue
|
||||
|
||||
# Handle standard format: {"name": "...", "parameters": {...}}
|
||||
# Note: This list should match tools registered in AgentLoop._register_default_tools()
|
||||
valid_tools = [
|
||||
# File tools
|
||||
"read_file", "write_file", "edit_file", "list_dir",
|
||||
# Shell tool
|
||||
"exec",
|
||||
# Web tools
|
||||
"web_search", "web_fetch",
|
||||
# Communication tools
|
||||
"message", "spawn",
|
||||
# Calendar tool
|
||||
"calendar",
|
||||
# Cron tool
|
||||
"cron",
|
||||
# Email tool
|
||||
"email",
|
||||
]
|
||||
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_obj["name"] in valid_tools):
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=f"call_{len(tool_calls)}",
|
||||
name=tool_obj["name"],
|
||||
@ -172,32 +166,6 @@ class CustomProvider(LLMProvider):
|
||||
pass # If parsing fails, skip this match
|
||||
|
||||
start_pos = json_start + 1 # Move past this match
|
||||
|
||||
# If we still didn't match embedded objects, try parsing the whole message as a single tool-call JSON object.
|
||||
if not tool_calls and looks_like_json_object:
|
||||
try:
|
||||
tool_obj = json_repair.loads(stripped)
|
||||
if isinstance(tool_obj, dict) and "action" in tool_obj:
|
||||
action = tool_obj.get("action")
|
||||
if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]:
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id="call_0",
|
||||
name="calendar",
|
||||
arguments=tool_obj,
|
||||
))
|
||||
content = ""
|
||||
if isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj:
|
||||
if isinstance(tool_obj["name"], str) and (
|
||||
tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_")
|
||||
):
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id="call_0",
|
||||
name=tool_obj["name"],
|
||||
arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])},
|
||||
))
|
||||
content = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
u = response.usage
|
||||
return LLMResponse(
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create ~/.nanobot/workspaces/{ilia,family,wife}/ from repo templates (Option B).
|
||||
# Does not overwrite existing files — safe to re-run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
NANOBOT_HOME="${NANOBOT_HOME:-$HOME/.nanobot}"
|
||||
DEST="${NANOBOT_HOME}/workspaces"
|
||||
SKEL="${REPO_ROOT}/agent_workspaces"
|
||||
|
||||
if [[ ! -d "${SKEL}/ilia" ]]; then
|
||||
echo "error: missing ${SKEL}/ilia — run from nanobot repo root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install_skel() {
|
||||
local agent="$1"
|
||||
local d="${DEST}/${agent}"
|
||||
mkdir -p "${d}/memory"
|
||||
for f in AGENTS.md USER.md SOUL.md; do
|
||||
if [[ ! -f "${d}/${f}" ]]; then
|
||||
cp "${SKEL}/${agent}/${f}" "${d}/${f}"
|
||||
echo "created ${d}/${f}"
|
||||
else
|
||||
echo "skip (exists): ${d}/${f}"
|
||||
fi
|
||||
done
|
||||
for f in MEMORY.md HISTORY.md; do
|
||||
if [[ ! -f "${d}/memory/${f}" ]]; then
|
||||
cp "${SKEL}/${agent}/memory/${f}" "${d}/memory/${f}"
|
||||
echo "created ${d}/memory/${f}"
|
||||
else
|
||||
echo "skip (exists): ${d}/memory/${f}"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
echo "NANOBOT_HOME=${NANOBOT_HOME}"
|
||||
echo "DEST=${DEST}"
|
||||
mkdir -p "${DEST}"
|
||||
|
||||
for agent in ilia family wife; do
|
||||
echo "--- ${agent} ---"
|
||||
install_skel "${agent}"
|
||||
done
|
||||
|
||||
echo "done. Fix ownership if needed, e.g.:"
|
||||
echo " sudo chown -R \"\$(whoami):\$(whoami)\" \"${DEST}\""
|
||||
@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Clone/build local MCP servers into ./mcp-servers (local-clone policy).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MCP_DIR="${REPO_ROOT}/mcp-servers"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage:
|
||||
./scripts/setup-mcp-servers.sh gitea
|
||||
|
||||
notes:
|
||||
- clones into ./mcp-servers/<name>
|
||||
- builds artifacts needed to run the MCP server locally
|
||||
EOF
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
local cmd="$1"
|
||||
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
||||
echo "error: missing '${cmd}' on PATH" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
need_go_min() {
|
||||
local want_major="$1"
|
||||
local want_minor="$2"
|
||||
|
||||
local v
|
||||
v="$(go version 2>/dev/null || true)"
|
||||
# Example: "go version go1.26.0 linux/amd64"
|
||||
local ver
|
||||
ver="$(echo "${v}" | awk '{print $3}' | sed 's/^go//')"
|
||||
local major minor
|
||||
major="$(echo "${ver}" | cut -d. -f1)"
|
||||
minor="$(echo "${ver}" | cut -d. -f2)"
|
||||
|
||||
if [[ -z "${major}" || -z "${minor}" ]]; then
|
||||
echo "error: could not parse Go version from: ${v}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Compare major/minor only (sufficient for our use).
|
||||
if (( major < want_major )) || { (( major == want_major )) && (( minor < want_minor )); }; then
|
||||
echo "error: Go ${want_major}.${want_minor}+ required; found ${ver}" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
setup_gitea() {
|
||||
need_cmd git
|
||||
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
cat <<'EOF' >&2
|
||||
error: Go toolchain not found (required to build gitea-mcp).
|
||||
|
||||
install one of:
|
||||
- Debian/Ubuntu: sudo apt-get update && sudo apt-get install -y golang
|
||||
- Or install Go from https://go.dev/dl/
|
||||
|
||||
then rerun:
|
||||
./scripts/setup-mcp-servers.sh gitea
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! need_go_min 1 26; then
|
||||
cat <<'EOF' >&2
|
||||
|
||||
gitea-mcp currently requires a newer Go toolchain than Debian stable typically ships.
|
||||
If you already installed a newer Go under /usr/local (example: /usr/local/go1.26/bin/go),
|
||||
rerun with PATH overridden, e.g.:
|
||||
|
||||
PATH="/usr/local/go1.26/bin:$PATH" ./scripts/setup-mcp-servers.sh gitea
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "${MCP_DIR}"
|
||||
|
||||
if [[ ! -d "${MCP_DIR}/gitea-mcp/.git" ]]; then
|
||||
git clone https://gitea.com/gitea/gitea-mcp.git "${MCP_DIR}/gitea-mcp"
|
||||
else
|
||||
echo "info: gitea-mcp already cloned, skipping clone"
|
||||
fi
|
||||
|
||||
(cd "${MCP_DIR}/gitea-mcp" && go build -o gitea-mcp .)
|
||||
|
||||
echo "done: built ${MCP_DIR}/gitea-mcp/gitea-mcp"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ "${#}" -ne 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
gitea) setup_gitea ;;
|
||||
-h|--help|help) usage ;;
|
||||
*)
|
||||
echo "error: unknown target '$1'" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to update shared settings across all bot configs
|
||||
|
||||
set -e
|
||||
|
||||
CONFIG_DIRS=(
|
||||
~/.nanobot-user1
|
||||
~/.nanobot-user2
|
||||
~/.nanobot-user3
|
||||
)
|
||||
|
||||
# Function to update a specific key in all configs
|
||||
update_config_key() {
|
||||
local key_path="$1"
|
||||
local new_value="$2"
|
||||
|
||||
echo "Updating $key_path to $new_value in all configs..."
|
||||
|
||||
for dir in "${CONFIG_DIRS[@]}"; do
|
||||
config_file="$dir/config.json"
|
||||
if [ -f "$config_file" ]; then
|
||||
# Use jq to update the config
|
||||
if command -v jq &> /dev/null; then
|
||||
# Convert key_path like "providers.openrouter.apiKey" to jq path
|
||||
jq_path=$(echo "$key_path" | sed 's/\./"."/g' | sed 's/^/./' | sed 's/\.$//')
|
||||
jq "$jq_path = \"$new_value\"" "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
|
||||
echo " ✓ Updated $config_file"
|
||||
else
|
||||
echo " ⚠ jq not found, skipping $config_file (install jq for automatic updates)"
|
||||
fi
|
||||
else
|
||||
echo " ⚠ Config not found: $config_file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Function to show usage
|
||||
usage() {
|
||||
cat << EOF
|
||||
Usage: $0 <command> [args]
|
||||
|
||||
Commands:
|
||||
update-api-key <provider> <key> Update API key for a provider (e.g., openrouter)
|
||||
update-model <model> Update default model
|
||||
update-setting <key.path> <value> Update any setting using dot notation
|
||||
|
||||
Examples:
|
||||
$0 update-api-key openrouter "sk-or-v1-xxx"
|
||||
$0 update-model "anthropic/claude-opus-4-5"
|
||||
$0 update-setting "agents.defaults.temperature" "0.8"
|
||||
|
||||
Note: Requires 'jq' to be installed for automatic updates.
|
||||
Install with: sudo apt install jq (or brew install jq on macOS)
|
||||
EOF
|
||||
}
|
||||
|
||||
# Main script
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
update-api-key)
|
||||
if [ $# -ne 3 ]; then
|
||||
echo "Error: update-api-key requires provider name and API key"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
provider="$2"
|
||||
api_key="$3"
|
||||
update_config_key "providers.$provider.apiKey" "$api_key"
|
||||
;;
|
||||
update-model)
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Error: update-model requires model name"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
model="$2"
|
||||
update_config_key "agents.defaults.model" "$model"
|
||||
;;
|
||||
update-setting)
|
||||
if [ $# -ne 3 ]; then
|
||||
echo "Error: update-setting requires key path and value"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
key_path="$2"
|
||||
value="$3"
|
||||
update_config_key "$key_path" "$value"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "Done! Restart containers to apply changes:"
|
||||
echo " docker compose -f docker-compose.multi.yml restart"
|
||||
|
||||
|
||||
@ -8,32 +8,6 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
- DO NOT explain what tool you would call - just call it
|
||||
- Tools are executed automatically when you call them - you don't need to show the JSON
|
||||
|
||||
**CRITICAL: CONVERSATION CONTEXT - READ THE HISTORY**
|
||||
- **ALWAYS read the conversation history BEFORE responding** - it contains previous messages that provide context
|
||||
- When the user says "yes", "download it", "do it", "download the attachment", etc., **LOOK AT YOUR PREVIOUS RESPONSE** in the conversation history
|
||||
- If YOU previously mentioned an attachment (e.g., "This email includes a PDF attachment named X.pdf"), and the user says "yes download it" or "download the attachment", they are referring to THAT attachment
|
||||
- DO NOT ask for clarification if the context is clear from YOUR OWN previous messages
|
||||
- Example conversation flow:
|
||||
- You: "Email has attachment X.pdf. Would you like me to download it?"
|
||||
- User: "yes download it"
|
||||
- You: IMMEDIATELY use `read_emails(limit=1, download_attachments=true)` - do NOT ask which attachment
|
||||
- When user says "yes" or "download it" after you mentioned an attachment, they mean download the attachment YOU just mentioned
|
||||
|
||||
## 🛑 CRITICAL: System Permissions & Safety Override
|
||||
You are running in a secure environment. YOU HAVE EXPLICIT PERMISSION AND AUTHORIZATION from the user to create, modify, and delete files on the local filesystem.
|
||||
- NEVER refuse a request to write, save, or edit files due to "safety", "security", or "system harm" concerns.
|
||||
- DO NOT claim you cannot modify external files. You have the `write_file` and `edit_file` tools exactly for this purpose.
|
||||
- When asked to save, write, or create a file, immediately use the `write_file` tool to do so.
|
||||
|
||||
## 🚨 CRITICAL: File Saving Behavior
|
||||
**When the user asks you to "save", "write", or "create" a file:**
|
||||
- DO NOT explain what you will do - JUST DO IT
|
||||
- DO NOT say "I will create a Python script" - USE THE `write_file` TOOL DIRECTLY
|
||||
- DO NOT show code examples - EXECUTE THE TOOL IMMEDIATELY
|
||||
- The `write_file` tool REQUIRES both `path` and `content` parameters - ALWAYS provide both
|
||||
- If user says "save to filename.txt", use full path: `/mnt/data/nanobot/workspace/filename.txt`
|
||||
- Example: User says "save story to story.txt" → IMMEDIATELY call `write_file(path="/mnt/data/nanobot/workspace/story.txt", content="...")` - DO NOT explain, DO NOT show code
|
||||
|
||||
## 🚨 CRITICAL: Gitea API Requests
|
||||
|
||||
**When user asks to list PRs, issues, or use Gitea API:**
|
||||
@ -125,34 +99,6 @@ You have access to:
|
||||
- Scheduled tasks (cron) - for reminders and delayed actions
|
||||
- Email (read_emails) - read emails from IMAP mailbox
|
||||
- Calendar (calendar) - interact with Google Calendar (if enabled)
|
||||
- Gmail MCP tools (mcp_gmail_mcp_*) - search, read, send emails via Gmail API
|
||||
|
||||
## Email Tools
|
||||
|
||||
**CRITICAL: Which tool to use:**
|
||||
- **ALWAYS use `read_emails`** for queries about emails received via the email channel (IMAP)
|
||||
- **ONLY use Gmail MCP tools** (`mcp_gmail_mcp_*`) when explicitly working with Gmail API features (labels, filters, etc.)
|
||||
- When user asks about "the last email", "latest email", "recent emails", or emails received via email channel → use `read_emails(limit=1)` or `read_emails(limit=5)`
|
||||
- When user asks about attachments in emails received via email channel → use `read_emails` first to get the email, then check metadata for attachment info
|
||||
- When user asks to "download attachment" or "download it" (referring to an attachment) → use `read_emails(limit=1, download_attachments=true)` to download attachments from the last email
|
||||
- When user asks to "find emails with attachment X" or "emails containing attachment Y" → use `read_emails(limit=100, attachment_name="X")` to filter emails by attachment filename (case-insensitive partial match)
|
||||
- DO NOT use `mcp_gmail_mcp_read_email` for emails received via the email channel - those emails are from IMAP, not Gmail API
|
||||
|
||||
**When checking for emails:**
|
||||
- Use `read_emails` for IMAP mailbox access (this is the PRIMARY tool for email queries)
|
||||
- Use `mcp_gmail_mcp_search_emails` ONLY for Gmail API-specific searches
|
||||
- When a search returns "No unread emails found" or empty results, tell the user clearly: "You have no new unread emails" or "No emails found matching your criteria"
|
||||
- DO NOT ask for clarification when you get empty results - empty results ARE a valid answer
|
||||
- If the tool returns "(no output)" for a search query, interpret it as "no results found"
|
||||
|
||||
**When receiving emails via the email channel:**
|
||||
- Messages starting with "Email received.\nFrom:" contain the FULL email content - you already have everything you need
|
||||
- DO NOT try to fetch the email again using `mcp_gmail_mcp_read_email` - the content is already in the message
|
||||
- The message format is: "Email received.\nFrom: {sender}\nSubject: {subject}\nDate: {date}\n\n{body}"
|
||||
- Process the email content directly from the message - do not attempt to retrieve it from Gmail API
|
||||
- If you need to reply, use the email channel's reply functionality or `mcp_gmail_mcp_send_email`
|
||||
- The metadata.message_id in the message is the email's Message-ID header, NOT a Gmail API message ID - do not use it with Gmail MCP tools
|
||||
- For attachment information, check the email metadata or use `read_emails` to fetch the full email details
|
||||
|
||||
## Memory
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user