Compare commits
No commits in common. "feature/cleanup-providers-llama-only" and "fix-cron-scheduled-tasks" have entirely different histories.
feature/cl
...
fix-cron-s
@ -1,102 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, master, develop, feature/** ]
|
|
||||||
pull_request:
|
|
||||||
# Trigger on all pull requests regardless of target branch
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint with ruff
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install ruff
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install ruff>=0.1.0
|
|
||||||
|
|
||||||
- name: Run ruff check
|
|
||||||
run: |
|
|
||||||
ruff check nanobot/
|
|
||||||
|
|
||||||
- name: Run ruff format check
|
|
||||||
run: |
|
|
||||||
ruff format --check nanobot/
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Test Python ${{ matrix.python-version }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.11', '3.12']
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
|
|
||||||
- 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: |
|
|
||||||
pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
- name: Check package can be imported
|
|
||||||
run: |
|
|
||||||
python -c "import nanobot; print(f'nanobot version check passed')"
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build package
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [lint, test]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install build dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install build hatchling
|
|
||||||
|
|
||||||
- name: Build package
|
|
||||||
run: |
|
|
||||||
python -m build
|
|
||||||
|
|
||||||
- name: Check build artifacts
|
|
||||||
run: |
|
|
||||||
ls -lh dist/
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: dist-packages
|
|
||||||
path: dist/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,6 +1,5 @@
|
|||||||
.assets
|
.assets
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
*.pyc
|
*.pyc
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
@ -22,8 +21,3 @@ poetry.lock
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
botpy.log
|
botpy.log
|
||||||
tests/
|
tests/
|
||||||
|
|
||||||
# Local-cloned MCP servers (kept out of git; clone/build locally)
|
|
||||||
mcp-servers/*
|
|
||||||
!mcp-servers/README.md
|
|
||||||
!mcp-servers/.gitkeep
|
|
||||||
|
|||||||
@ -1,229 +0,0 @@
|
|||||||
# Google Calendar Integration Setup
|
|
||||||
|
|
||||||
This guide explains how to set up Google Calendar integration for nanobot.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **List upcoming events** from your Google Calendar
|
|
||||||
- **Create calendar events** programmatically
|
|
||||||
- **Check availability** for time slots
|
|
||||||
- **Automatic scheduling from emails** - when an email mentions a meeting, nanobot can automatically schedule it
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. Google account with Calendar access
|
|
||||||
2. Google Cloud Project with Calendar API enabled
|
|
||||||
3. OAuth2 credentials (Desktop app type)
|
|
||||||
|
|
||||||
## Setup Steps
|
|
||||||
|
|
||||||
### 1. Enable Google Calendar API
|
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
2. Create a new project or select an existing one
|
|
||||||
3. Enable the **Google Calendar API**:
|
|
||||||
- Navigate to "APIs & Services" > "Library"
|
|
||||||
- Search for "Google Calendar API"
|
|
||||||
- Click "Enable"
|
|
||||||
|
|
||||||
### 2. Configure OAuth Consent Screen
|
|
||||||
|
|
||||||
**IMPORTANT:** This step is required before creating credentials.
|
|
||||||
|
|
||||||
1. Go to "APIs & Services" > "OAuth consent screen"
|
|
||||||
2. Choose **"External"** user type (unless you have a Google Workspace account)
|
|
||||||
3. Fill in required fields:
|
|
||||||
- **App name**: "nanobot" (or any name)
|
|
||||||
- **User support email**: Your email address
|
|
||||||
- **Developer contact information**: Your email address
|
|
||||||
4. Click "Save and Continue"
|
|
||||||
5. **Add Scopes:**
|
|
||||||
- Click "Add or Remove Scopes"
|
|
||||||
- Search for and add: `https://www.googleapis.com/auth/calendar`
|
|
||||||
- Click "Update" then "Save and Continue"
|
|
||||||
6. **Add Test Users (CRITICAL):**
|
|
||||||
- Click "Add Users"
|
|
||||||
- Add your email address (`adayear2025@gmail.com`)
|
|
||||||
- Click "Add" then "Save and Continue"
|
|
||||||
7. Review and go back to dashboard
|
|
||||||
|
|
||||||
### 3. Create OAuth2 Credentials
|
|
||||||
|
|
||||||
1. Go to "APIs & Services" > "Credentials"
|
|
||||||
2. Click "Create Credentials" > "OAuth client ID"
|
|
||||||
3. Select:
|
|
||||||
- **Application type**: **Desktop app**
|
|
||||||
- **Name**: "nanobot" (or any name)
|
|
||||||
4. Click "Create"
|
|
||||||
5. **Download the credentials JSON file** - click "Download JSON"
|
|
||||||
6. Save it as `credentials.json` and copy to your server at `~/.nanobot/credentials.json`
|
|
||||||
|
|
||||||
### 3. Configure nanobot
|
|
||||||
|
|
||||||
Set environment variables or add to your `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable calendar functionality
|
|
||||||
export NANOBOT_TOOLS__CALENDAR__ENABLED=true
|
|
||||||
|
|
||||||
# Path to OAuth2 credentials JSON file
|
|
||||||
export NANOBOT_TOOLS__CALENDAR__CREDENTIALS_FILE=/path/to/credentials.json
|
|
||||||
|
|
||||||
# Optional: Custom token storage location (default: ~/.nanobot/calendar_token.json)
|
|
||||||
export NANOBOT_TOOLS__CALENDAR__TOKEN_FILE=~/.nanobot/calendar_token.json
|
|
||||||
|
|
||||||
# Optional: Calendar ID (default: "primary")
|
|
||||||
export NANOBOT_TOOLS__CALENDAR__CALENDAR_ID=primary
|
|
||||||
|
|
||||||
# Optional: Auto-schedule meetings from emails (default: true)
|
|
||||||
export NANOBOT_TOOLS__CALENDAR__AUTO_SCHEDULE_FROM_EMAIL=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. First-Time Authorization
|
|
||||||
|
|
||||||
**You don't need to manually get or copy any token** - the OAuth flow handles everything automatically.
|
|
||||||
|
|
||||||
On first run, when nanobot tries to use the calendar tool, it will:
|
|
||||||
|
|
||||||
1. **Automatically open a browser window** for Google OAuth authorization
|
|
||||||
2. **You sign in** to your Google account in the browser
|
|
||||||
3. **Grant calendar access** by clicking "Allow"
|
|
||||||
4. **Automatically save the token** to `~/.nanobot/calendar_token.json` for future use
|
|
||||||
|
|
||||||
**Important:**
|
|
||||||
- The token is **automatically generated and saved** - you don't need to copy it from anywhere
|
|
||||||
- This happens **automatically** the first time you use a calendar command
|
|
||||||
- After the first authorization, you won't need to do this again (the token is reused)
|
|
||||||
- The token file is created automatically at `~/.nanobot/calendar_token.json`
|
|
||||||
|
|
||||||
**Example first run:**
|
|
||||||
```bash
|
|
||||||
# First time using calendar - triggers OAuth flow
|
|
||||||
python3 -m nanobot.cli.commands agent -m "What's on my calendar?"
|
|
||||||
|
|
||||||
# Browser opens automatically → Sign in → Grant access → Token saved
|
|
||||||
# Future runs use the saved token automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note for remote/headless servers:**
|
|
||||||
If you're running nanobot on a remote server without a display, you have two options:
|
|
||||||
|
|
||||||
1. **Run OAuth on your local machine first:**
|
|
||||||
- Run nanobot locally once to complete OAuth
|
|
||||||
- Copy the generated `~/.nanobot/calendar_token.json` to your remote server
|
|
||||||
- The token will work on the remote server
|
|
||||||
|
|
||||||
2. **Use SSH port forwarding:**
|
|
||||||
- The OAuth flow uses a local web server
|
|
||||||
- You may need to set up port forwarding or use a different OAuth flow method
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### List Upcoming Events
|
|
||||||
|
|
||||||
```
|
|
||||||
User: "What's on my calendar?"
|
|
||||||
Agent: [Uses calendar tool to list events]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create an Event
|
|
||||||
|
|
||||||
```
|
|
||||||
User: "Schedule a meeting tomorrow at 2pm"
|
|
||||||
Agent: [Creates calendar event]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Automatic Email Scheduling
|
|
||||||
|
|
||||||
When an email mentions a meeting:
|
|
||||||
|
|
||||||
```
|
|
||||||
Email: "Hi, let's have a meeting tomorrow at 2pm in Conference Room A"
|
|
||||||
Agent: [Automatically extracts meeting info and creates calendar event]
|
|
||||||
Agent: "I've scheduled a meeting for tomorrow at 2pm in Conference Room A"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Calendar Tool API
|
|
||||||
|
|
||||||
The calendar tool supports three actions:
|
|
||||||
|
|
||||||
### 1. List Events
|
|
||||||
|
|
||||||
```python
|
|
||||||
calendar(
|
|
||||||
action="list_events",
|
|
||||||
max_results=10, # Optional, default: 10
|
|
||||||
time_min="2024-01-15T00:00:00Z" # Optional, defaults to now
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create Event
|
|
||||||
|
|
||||||
```python
|
|
||||||
calendar(
|
|
||||||
action="create_event",
|
|
||||||
title="Team Meeting",
|
|
||||||
start_time="tomorrow 2pm", # or "2024-01-15T14:00:00"
|
|
||||||
end_time="tomorrow 3pm", # Optional, defaults to 1 hour after start
|
|
||||||
description="Discuss project progress", # Optional
|
|
||||||
location="Conference Room A", # Optional
|
|
||||||
attendees=["colleague@example.com"] # Optional list
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Time formats:**
|
|
||||||
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
|
|
||||||
- ISO format: `"2024-01-15T14:00:00"`
|
|
||||||
|
|
||||||
### 3. Check Availability
|
|
||||||
|
|
||||||
```python
|
|
||||||
calendar(
|
|
||||||
action="check_availability",
|
|
||||||
start_time="2024-01-15T14:00:00",
|
|
||||||
end_time="2024-01-15T15:00:00"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Error: Could not authenticate with Google Calendar"
|
|
||||||
|
|
||||||
- Ensure `credentials_file` path is correct
|
|
||||||
- Check that the credentials JSON file is valid
|
|
||||||
- Run nanobot once to complete OAuth flow
|
|
||||||
|
|
||||||
### "Error accessing Google Calendar API"
|
|
||||||
|
|
||||||
- Verify Calendar API is enabled in Google Cloud Console
|
|
||||||
- Check that OAuth consent screen is configured
|
|
||||||
- Ensure your email is added as a test user (if app is in testing mode)
|
|
||||||
|
|
||||||
### Token Expired
|
|
||||||
|
|
||||||
The tool automatically refreshes expired tokens. If refresh fails:
|
|
||||||
1. Delete `~/.nanobot/calendar_token.json`
|
|
||||||
2. Run nanobot again to re-authorize
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- Keep your `credentials.json` file secure (don't commit to git)
|
|
||||||
- The `calendar_token.json` file contains sensitive access tokens
|
|
||||||
- Use file permissions: `chmod 600 ~/.nanobot/calendar_token.json`
|
|
||||||
- Consider using environment variables or a secrets manager for production
|
|
||||||
|
|
||||||
## Integration with Email Channel
|
|
||||||
|
|
||||||
When `auto_schedule_from_email` is enabled, nanobot will:
|
|
||||||
|
|
||||||
1. Monitor incoming emails
|
|
||||||
2. Detect meeting-related keywords (meeting, appointment, call, etc.)
|
|
||||||
3. Extract meeting details (time, location, attendees)
|
|
||||||
4. Automatically create calendar events
|
|
||||||
5. Confirm with the user
|
|
||||||
|
|
||||||
This works best when:
|
|
||||||
- Emails contain clear time references ("tomorrow at 2pm", "next Monday")
|
|
||||||
- Meeting details are in the email body or subject
|
|
||||||
- The agent has access to the email channel
|
|
||||||
|
|
||||||
@ -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.
|
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`:
|
Add MCP servers to your `config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -829,15 +827,6 @@ vim ~/.nanobot/config.json # add API keys
|
|||||||
docker compose up -d nanobot-gateway # start gateway
|
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
|
```bash
|
||||||
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
|
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
|
||||||
docker compose logs -f nanobot-gateway # view logs
|
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
|
dockerfile: Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.nanobot:/root/.nanobot
|
- ~/.nanobot:/root/.nanobot
|
||||||
# Host repo ./workspace → /workspace in container. Set agents.defaults.workspace to /workspace.
|
|
||||||
- ./workspace:/workspace
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nanobot-gateway:
|
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."""
|
"""Agent core module."""
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
|
|||||||
@ -13,43 +13,43 @@ from nanobot.agent.skills import SkillsLoader
|
|||||||
class ContextBuilder:
|
class ContextBuilder:
|
||||||
"""
|
"""
|
||||||
Builds the context (system prompt + messages) for the agent.
|
Builds the context (system prompt + messages) for the agent.
|
||||||
|
|
||||||
Assembles bootstrap files, memory, skills, and conversation history
|
Assembles bootstrap files, memory, skills, and conversation history
|
||||||
into a coherent prompt for the LLM.
|
into a coherent prompt for the LLM.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
|
||||||
|
|
||||||
def __init__(self, workspace: Path):
|
def __init__(self, workspace: Path):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.memory = MemoryStore(workspace)
|
self.memory = MemoryStore(workspace)
|
||||||
self.skills = SkillsLoader(workspace)
|
self.skills = SkillsLoader(workspace)
|
||||||
|
|
||||||
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Build the system prompt from bootstrap files, memory, and skills.
|
Build the system prompt from bootstrap files, memory, and skills.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
skill_names: Optional list of skills to include.
|
skill_names: Optional list of skills to include.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Complete system prompt.
|
Complete system prompt.
|
||||||
"""
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
# Core identity
|
# Core identity
|
||||||
parts.append(self._get_identity())
|
parts.append(self._get_identity())
|
||||||
|
|
||||||
# Bootstrap files
|
# Bootstrap files
|
||||||
bootstrap = self._load_bootstrap_files()
|
bootstrap = self._load_bootstrap_files()
|
||||||
if bootstrap:
|
if bootstrap:
|
||||||
parts.append(bootstrap)
|
parts.append(bootstrap)
|
||||||
|
|
||||||
# Memory context
|
# Memory context
|
||||||
memory = self.memory.get_memory_context()
|
memory = self.memory.get_memory_context()
|
||||||
if memory:
|
if memory:
|
||||||
parts.append(f"# Memory\n\n{memory}")
|
parts.append(f"# Memory\n\n{memory}")
|
||||||
|
|
||||||
# Skills - progressive loading
|
# Skills - progressive loading
|
||||||
# 1. Always-loaded skills: include full content
|
# 1. Always-loaded skills: include full content
|
||||||
always_skills = self.skills.get_always_skills()
|
always_skills = self.skills.get_always_skills()
|
||||||
@ -57,7 +57,7 @@ class ContextBuilder:
|
|||||||
always_content = self.skills.load_skills_for_context(always_skills)
|
always_content = self.skills.load_skills_for_context(always_skills)
|
||||||
if always_content:
|
if always_content:
|
||||||
parts.append(f"# Active Skills\n\n{always_content}")
|
parts.append(f"# Active Skills\n\n{always_content}")
|
||||||
|
|
||||||
# 2. Available skills: only show summary (agent uses read_file to load)
|
# 2. Available skills: only show summary (agent uses read_file to load)
|
||||||
skills_summary = self.skills.build_skills_summary()
|
skills_summary = self.skills.build_skills_summary()
|
||||||
if 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 with available="false" need dependencies installed first - you can try installing them with apt/brew.
|
||||||
|
|
||||||
{skills_summary}""")
|
{skills_summary}""")
|
||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
def _get_identity(self) -> str:
|
def _get_identity(self) -> str:
|
||||||
"""Get the core identity section."""
|
"""Get the core identity section."""
|
||||||
import time as _time
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import time as _time
|
||||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||||
tz = _time.strftime("%Z") or "UTC"
|
tz = _time.strftime("%Z") or "UTC"
|
||||||
workspace_path = str(self.workspace.expanduser().resolve())
|
workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||||
|
|
||||||
return f"""# nanobot 🐈
|
return f"""# nanobot 🐈
|
||||||
|
|
||||||
You are nanobot, a helpful AI assistant. You have access to tools that allow you to:
|
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
|
- Send messages to users on chat channels
|
||||||
- Spawn subagents for complex background tasks
|
- 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
|
## Current Time
|
||||||
{now} ({tz})
|
{now} ({tz})
|
||||||
|
|
||||||
@ -117,21 +101,6 @@ Your workspace is at: {workspace_path}
|
|||||||
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
|
||||||
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
|
||||||
|
|
||||||
**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. **`list_dir` takes one directory path**—no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory.
|
|
||||||
|
|
||||||
**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`
|
|
||||||
- Token: `$NANOBOT_GITLE_TOKEN`
|
|
||||||
- **NEVER use placeholder URLs like `gitea.example.com`**
|
|
||||||
- **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP, using HTTPS causes SSL errors
|
|
||||||
- Always detect from `git remote get-url origin` or use `http://10.0.30.169:3000/api/v1`
|
|
||||||
- Example: `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"`
|
|
||||||
|
|
||||||
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
|
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
|
||||||
Only use the 'message' tool when the user explicitly asks you to send a message to someone else or to a different channel.
|
Only use the 'message' tool when the user explicitly asks you to send a message to someone else or to a different channel.
|
||||||
For normal conversation, acknowledgments (Thanks, OK, etc.), or when the user is talking to YOU, just respond with text - do NOT call the message tool.
|
For normal conversation, acknowledgments (Thanks, OK, etc.), or when the user is talking to YOU, just respond with text - do NOT call the message tool.
|
||||||
@ -140,22 +109,20 @@ For simple acknowledgments like "Thanks", "OK", "You're welcome", "Got it", etc.
|
|||||||
|
|
||||||
Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
|
Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
|
||||||
When remembering something important, write to {workspace_path}/memory/MEMORY.md
|
When remembering something important, write to {workspace_path}/memory/MEMORY.md
|
||||||
To recall past events, grep {workspace_path}/memory/HISTORY.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."""
|
|
||||||
|
|
||||||
def _load_bootstrap_files(self) -> str:
|
def _load_bootstrap_files(self) -> str:
|
||||||
"""Load all bootstrap files from workspace."""
|
"""Load all bootstrap files from workspace."""
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
for filename in self.BOOTSTRAP_FILES:
|
for filename in self.BOOTSTRAP_FILES:
|
||||||
file_path = self.workspace / filename
|
file_path = self.workspace / filename
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
content = file_path.read_text(encoding="utf-8")
|
content = file_path.read_text(encoding="utf-8")
|
||||||
parts.append(f"## {filename}\n\n{content}")
|
parts.append(f"## {filename}\n\n{content}")
|
||||||
|
|
||||||
return "\n\n".join(parts) if parts else ""
|
return "\n\n".join(parts) if parts else ""
|
||||||
|
|
||||||
def build_messages(
|
def build_messages(
|
||||||
self,
|
self,
|
||||||
history: list[dict[str, Any]],
|
history: list[dict[str, Any]],
|
||||||
@ -200,7 +167,7 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
|||||||
"""Build user message content with optional base64-encoded images."""
|
"""Build user message content with optional base64-encoded images."""
|
||||||
if not media:
|
if not media:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
for path in media:
|
for path in media:
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
@ -209,11 +176,11 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
|||||||
continue
|
continue
|
||||||
b64 = base64.b64encode(p.read_bytes()).decode()
|
b64 = base64.b64encode(p.read_bytes()).decode()
|
||||||
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
|
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
|
||||||
|
|
||||||
if not images:
|
if not images:
|
||||||
return text
|
return text
|
||||||
return images + [{"type": "text", "text": text}]
|
return images + [{"type": "text", "text": text}]
|
||||||
|
|
||||||
def add_tool_result(
|
def add_tool_result(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@ -223,13 +190,13 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Add a tool result to the message list.
|
Add a tool result to the message list.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: Current message list.
|
messages: Current message list.
|
||||||
tool_call_id: ID of the tool call.
|
tool_call_id: ID of the tool call.
|
||||||
tool_name: Name of the tool.
|
tool_name: Name of the tool.
|
||||||
result: Tool execution result.
|
result: Tool execution result.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated message list.
|
Updated message list.
|
||||||
"""
|
"""
|
||||||
@ -240,7 +207,7 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
|||||||
"content": result
|
"content": result
|
||||||
})
|
})
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
def add_assistant_message(
|
def add_assistant_message(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@ -250,13 +217,13 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Add an assistant message to the message list.
|
Add an assistant message to the message list.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: Current message list.
|
messages: Current message list.
|
||||||
content: Message content.
|
content: Message content.
|
||||||
tool_calls: Optional tool calls.
|
tool_calls: Optional tool calls.
|
||||||
reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
|
reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated message list.
|
Updated message list.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,30 +1,28 @@
|
|||||||
"""Agent loop: the core processing engine."""
|
"""Agent loop: the core processing engine."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
from pathlib import Path
|
import json
|
||||||
from typing import Awaitable, Callable
|
|
||||||
|
|
||||||
import json_repair
|
import json_repair
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
from loguru import logger
|
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.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
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.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
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
|
|
||||||
@ -51,15 +49,14 @@ class AgentLoop:
|
|||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
memory_window: int = 50,
|
memory_window: int = 50,
|
||||||
brave_api_key: str | None = None,
|
brave_api_key: str | None = None,
|
||||||
exec_config: ExecToolConfig | None = None,
|
exec_config: "ExecToolConfig | None" = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: "CronService | None" = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
session_manager: SessionManager | None = None,
|
session_manager: SessionManager | None = None,
|
||||||
mcp_servers: dict | 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.bus = bus
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@ -87,16 +84,13 @@ class AgentLoop:
|
|||||||
exec_config=self.exec_config,
|
exec_config=self.exec_config,
|
||||||
restrict_to_workspace=restrict_to_workspace,
|
restrict_to_workspace=restrict_to_workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._running = False
|
self._running = False
|
||||||
self._mcp_servers = mcp_servers or {}
|
self._mcp_servers = mcp_servers or {}
|
||||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
self._mcp_stack: AsyncExitStack | None = None
|
||||||
self._mcp_connected_servers: set[str] = set()
|
self._mcp_connected = False
|
||||||
self._tool_profiles: dict = tool_profiles or {}
|
|
||||||
self._default_tool_profile = default_tool_profile
|
|
||||||
self._tool_routing = tool_routing or ToolRoutingConfig()
|
|
||||||
self._register_default_tools()
|
self._register_default_tools()
|
||||||
|
|
||||||
def _register_default_tools(self) -> None:
|
def _register_default_tools(self) -> None:
|
||||||
"""Register the default set of tools."""
|
"""Register the default set of tools."""
|
||||||
# File tools (restrict to workspace if configured)
|
# File tools (restrict to workspace if configured)
|
||||||
@ -105,126 +99,39 @@ class AgentLoop:
|
|||||||
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
|
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
|
||||||
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
||||||
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
|
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
|
||||||
|
|
||||||
# Shell tool
|
# Shell tool
|
||||||
self.tools.register(ExecTool(
|
self.tools.register(ExecTool(
|
||||||
working_dir=str(self.workspace),
|
working_dir=str(self.workspace),
|
||||||
timeout=self.exec_config.timeout,
|
timeout=self.exec_config.timeout,
|
||||||
restrict_to_workspace=self.restrict_to_workspace,
|
restrict_to_workspace=self.restrict_to_workspace,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Web tools
|
# Web tools
|
||||||
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||||
self.tools.register(WebFetchTool())
|
self.tools.register(WebFetchTool())
|
||||||
|
|
||||||
# Message tool
|
# Message tool
|
||||||
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
|
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
|
||||||
self.tools.register(message_tool)
|
self.tools.register(message_tool)
|
||||||
|
|
||||||
# Spawn tool (for subagents)
|
# Spawn tool (for subagents)
|
||||||
spawn_tool = SpawnTool(manager=self.subagents)
|
spawn_tool = SpawnTool(manager=self.subagents)
|
||||||
self.tools.register(spawn_tool)
|
self.tools.register(spawn_tool)
|
||||||
|
|
||||||
# Cron tool (for scheduling)
|
# Cron tool (for scheduling)
|
||||||
if self.cron_service:
|
if self.cron_service:
|
||||||
self.tools.register(CronTool(self.cron_service))
|
self.tools.register(CronTool(self.cron_service))
|
||||||
|
|
||||||
# Email tool (if email channel is configured)
|
async def _connect_mcp(self) -> None:
|
||||||
try:
|
"""Connect to configured MCP servers (one-time, lazy)."""
|
||||||
from nanobot.agent.tools.email import EmailTool
|
if self._mcp_connected or not self._mcp_servers:
|
||||||
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,
|
|
||||||
)
|
|
||||||
self.tools.register(email_tool)
|
|
||||||
logger.info(f"Email tool '{email_tool.name}' registered successfully")
|
|
||||||
else:
|
|
||||||
logger.debug("Email tool not registered: email channel not enabled")
|
|
||||||
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
|
|
||||||
from nanobot.config.loader import load_config
|
|
||||||
config = load_config()
|
|
||||||
if config.tools.calendar.enabled:
|
|
||||||
calendar_tool = CalendarTool(calendar_config=config.tools.calendar)
|
|
||||||
self.tools.register(calendar_tool)
|
|
||||||
logger.info(f"Calendar tool '{calendar_tool.name}' registered successfully")
|
|
||||||
else:
|
|
||||||
logger.debug("Calendar tool not registered: calendar not enabled")
|
|
||||||
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:
|
|
||||||
return
|
return
|
||||||
needed = set(needed_keys)
|
self._mcp_connected = True
|
||||||
for key in list(self._mcp_connected_servers):
|
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||||
if key not in needed:
|
self._mcp_stack = AsyncExitStack()
|
||||||
await self._disconnect_mcp_server(key)
|
await self._mcp_stack.__aenter__()
|
||||||
connect_order = [k for k in self._mcp_servers.keys() if k in needed]
|
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
|
||||||
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
|
|
||||||
|
|
||||||
def _set_tool_context(self, channel: str, chat_id: str) -> None:
|
def _set_tool_context(self, channel: str, chat_id: str) -> None:
|
||||||
"""Update context for all tools that need routing info."""
|
"""Update context for all tools that need routing info."""
|
||||||
@ -257,41 +164,6 @@ class AgentLoop:
|
|||||||
return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")'
|
return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")'
|
||||||
return ", ".join(_fmt(tc) for tc in tool_calls)
|
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(
|
async def _run_agent_loop(
|
||||||
self,
|
self,
|
||||||
initial_messages: list[dict],
|
initial_messages: list[dict],
|
||||||
@ -311,54 +183,16 @@ class AgentLoop:
|
|||||||
iteration = 0
|
iteration = 0
|
||||||
final_content = None
|
final_content = None
|
||||||
tools_used: list[str] = []
|
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:
|
while iteration < self.max_iterations:
|
||||||
iteration += 1
|
iteration += 1
|
||||||
logger.debug(f"Agent loop iteration {iteration}/{self.max_iterations}, calling LLM provider...")
|
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:
|
try:
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
self.provider.chat(
|
self.provider.chat(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=tool_defs,
|
tools=self.tools.get_definitions(),
|
||||||
model=self.model,
|
model=self.model,
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
max_tokens=self.max_tokens,
|
max_tokens=self.max_tokens,
|
||||||
@ -367,7 +201,7 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
logger.debug(f"LLM provider returned response, has_tool_calls={response.has_tool_calls}")
|
logger.debug(f"LLM provider returned response, has_tool_calls={response.has_tool_calls}")
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("LLM provider call timed out after 120 seconds")
|
logger.error(f"LLM provider call timed out after 120 seconds")
|
||||||
return "Error: Request timed out. The LLM provider may be slow or unresponsive.", tools_used
|
return "Error: Request timed out. The LLM provider may be slow or unresponsive.", tools_used
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LLM provider error: {e}")
|
logger.error(f"LLM provider error: {e}")
|
||||||
@ -400,18 +234,6 @@ class AgentLoop:
|
|||||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||||
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
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'}")
|
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 = self.context.add_tool_result(
|
||||||
messages, tool_call.id, tool_call.name, result
|
messages, tool_call.id, tool_call.name, result
|
||||||
)
|
)
|
||||||
@ -419,34 +241,17 @@ class AgentLoop:
|
|||||||
else:
|
else:
|
||||||
final_content = self._strip_think(response.content)
|
final_content = self._strip_think(response.content)
|
||||||
logger.info(f"Final response generated. Content length: {len(final_content) if final_content else 0}")
|
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
|
break
|
||||||
|
|
||||||
if final_content is None and iteration >= self.max_iterations:
|
if final_content is None and iteration >= self.max_iterations:
|
||||||
logger.warning(f"Max iterations ({self.max_iterations}) reached without final response. Last tool calls: {tools_used[-3:] if len(tools_used) >= 3 else tools_used}")
|
logger.warning(f"Max iterations ({self.max_iterations}) reached without final response. Last tool calls: {tools_used[-3:] if len(tools_used) >= 3 else tools_used}")
|
||||||
|
|
||||||
return final_content, tools_used
|
return final_content, tools_used
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Run the agent loop, processing messages from the bus."""
|
"""Run the agent loop, processing messages from the bus."""
|
||||||
self._running = True
|
self._running = True
|
||||||
|
await self._connect_mcp()
|
||||||
logger.info("Agent loop started")
|
logger.info("Agent loop started")
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
@ -468,21 +273,21 @@ class AgentLoop:
|
|||||||
))
|
))
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
"""Close all MCP connections and drop MCP tools from the registry."""
|
"""Close MCP connections."""
|
||||||
for key in list(
|
if self._mcp_stack:
|
||||||
set(self._mcp_stacks.keys()) | self._mcp_connected_servers
|
try:
|
||||||
):
|
await self._mcp_stack.aclose()
|
||||||
await self._disconnect_mcp_server(key)
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
self._mcp_stacks.clear()
|
pass # MCP SDK cancel scope cleanup is noisy but harmless
|
||||||
self._mcp_connected_servers.clear()
|
self._mcp_stack = None
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Stop the agent loop."""
|
"""Stop the agent loop."""
|
||||||
self._running = False
|
self._running = False
|
||||||
logger.info("Agent loop stopping")
|
logger.info("Agent loop stopping")
|
||||||
|
|
||||||
async def _process_message(
|
async def _process_message(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
@ -491,25 +296,25 @@ class AgentLoop:
|
|||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""
|
"""
|
||||||
Process a single inbound message.
|
Process a single inbound message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
msg: The inbound message to process.
|
msg: The inbound message to process.
|
||||||
session_key: Override session key (used by process_direct).
|
session_key: Override session key (used by process_direct).
|
||||||
on_progress: Optional callback for intermediate output (defaults to bus publish).
|
on_progress: Optional callback for intermediate output (defaults to bus publish).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The response message, or None if no response needed.
|
The response message, or None if no response needed.
|
||||||
"""
|
"""
|
||||||
# System messages route back via chat_id ("channel:chat_id")
|
# System messages route back via chat_id ("channel:chat_id")
|
||||||
if msg.channel == "system":
|
if msg.channel == "system":
|
||||||
return await self._process_system_message(msg)
|
return await self._process_system_message(msg)
|
||||||
|
|
||||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||||
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
|
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
|
||||||
|
|
||||||
key = session_key or msg.session_key
|
key = session_key or msg.session_key
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
|
|
||||||
# Handle slash commands
|
# Handle slash commands
|
||||||
cmd = msg.content.strip().lower()
|
cmd = msg.content.strip().lower()
|
||||||
if cmd == "/new":
|
if cmd == "/new":
|
||||||
@ -530,7 +335,7 @@ class AgentLoop:
|
|||||||
if cmd == "/help":
|
if cmd == "/help":
|
||||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
|
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
|
||||||
|
|
||||||
# Skip memory consolidation for CLI mode to avoid blocking/hanging
|
# Skip memory consolidation for CLI mode to avoid blocking/hanging
|
||||||
# Memory consolidation can be slow and CLI users want fast responses
|
# Memory consolidation can be slow and CLI users want fast responses
|
||||||
if len(session.messages) > self.memory_window and msg.channel != "cli":
|
if len(session.messages) > self.memory_window and msg.channel != "cli":
|
||||||
@ -557,9 +362,6 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _bus_progress(content: str) -> None:
|
async def _bus_progress(content: str) -> None:
|
||||||
# Skip progress updates for email channel to avoid sending intermediate tool call hints as emails
|
|
||||||
if msg.channel == "email":
|
|
||||||
return
|
|
||||||
await self.bus.publish_outbound(OutboundMessage(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||||
metadata=msg.metadata or {},
|
metadata=msg.metadata or {},
|
||||||
@ -571,31 +373,31 @@ class AgentLoop:
|
|||||||
|
|
||||||
if final_content is None:
|
if final_content is None:
|
||||||
final_content = "I've completed processing but have no response to give."
|
final_content = "I've completed processing but have no response to give."
|
||||||
|
|
||||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||||
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
||||||
|
|
||||||
session.add_message("user", msg.content)
|
session.add_message("user", msg.content)
|
||||||
session.add_message("assistant", final_content,
|
session.add_message("assistant", final_content,
|
||||||
tools_used=tools_used if tools_used else None)
|
tools_used=tools_used if tools_used else None)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
content=final_content,
|
content=final_content,
|
||||||
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
|
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:
|
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||||
"""
|
"""
|
||||||
Process a system message (e.g., subagent announce).
|
Process a system message (e.g., subagent announce).
|
||||||
|
|
||||||
The chat_id field contains "original_channel:original_chat_id" to route
|
The chat_id field contains "original_channel:original_chat_id" to route
|
||||||
the response back to the correct destination.
|
the response back to the correct destination.
|
||||||
"""
|
"""
|
||||||
logger.info(f"Processing system message from {msg.sender_id}")
|
logger.info(f"Processing system message from {msg.sender_id}")
|
||||||
|
|
||||||
# Parse origin from chat_id (format: "channel:chat_id")
|
# Parse origin from chat_id (format: "channel:chat_id")
|
||||||
if ":" in msg.chat_id:
|
if ":" in msg.chat_id:
|
||||||
parts = msg.chat_id.split(":", 1)
|
parts = msg.chat_id.split(":", 1)
|
||||||
@ -605,7 +407,7 @@ class AgentLoop:
|
|||||||
# Fallback
|
# Fallback
|
||||||
origin_channel = "cli"
|
origin_channel = "cli"
|
||||||
origin_chat_id = msg.chat_id
|
origin_chat_id = msg.chat_id
|
||||||
|
|
||||||
session_key = f"{origin_channel}:{origin_chat_id}"
|
session_key = f"{origin_channel}:{origin_chat_id}"
|
||||||
session = self.sessions.get_or_create(session_key)
|
session = self.sessions.get_or_create(session_key)
|
||||||
self._set_tool_context(origin_channel, origin_chat_id)
|
self._set_tool_context(origin_channel, origin_chat_id)
|
||||||
@ -619,17 +421,17 @@ class AgentLoop:
|
|||||||
|
|
||||||
if final_content is None:
|
if final_content is None:
|
||||||
final_content = "Background task completed."
|
final_content = "Background task completed."
|
||||||
|
|
||||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
||||||
session.add_message("assistant", final_content)
|
session.add_message("assistant", final_content)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=origin_channel,
|
channel=origin_channel,
|
||||||
chat_id=origin_chat_id,
|
chat_id=origin_chat_id,
|
||||||
content=final_content
|
content=final_content
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
|
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
|
||||||
"""Consolidate old messages into MEMORY.md + HISTORY.md.
|
"""Consolidate old messages into MEMORY.md + HISTORY.md.
|
||||||
|
|
||||||
@ -735,23 +537,24 @@ Respond with ONLY valid JSON, no markdown fences."""
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Process a message directly (for CLI or cron usage).
|
Process a message directly (for CLI or cron usage).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: The message content.
|
content: The message content.
|
||||||
session_key: Session identifier (overrides channel:chat_id for session lookup).
|
session_key: Session identifier (overrides channel:chat_id for session lookup).
|
||||||
channel: Source channel (for tool context routing).
|
channel: Source channel (for tool context routing).
|
||||||
chat_id: Source chat ID (for tool context routing).
|
chat_id: Source chat ID (for tool context routing).
|
||||||
on_progress: Optional callback for intermediate output.
|
on_progress: Optional callback for intermediate output.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The agent's response.
|
The agent's response.
|
||||||
"""
|
"""
|
||||||
|
await self._connect_mcp()
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
sender_id="user",
|
sender_id="user",
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
content=content
|
content=content
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
|
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
|
||||||
return response.content if response else ""
|
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
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -26,19 +26,7 @@ class CronTool(Tool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return """Schedule reminders and recurring tasks. REQUIRED: Always include 'action' parameter ('add', 'list', or 'remove').
|
return "Schedule reminders and recurring tasks. REQUIRED: Always include 'action' parameter ('add', 'list', or 'remove'). For reminders, use action='add' with message and timing (in_seconds, at, every_seconds, or cron_expr)."
|
||||||
|
|
||||||
For 'add' action:
|
|
||||||
- MUST include 'message' parameter - extract the reminder/task text from user's request
|
|
||||||
- Examples: 'remind me to call mama' → message='call mama'
|
|
||||||
|
|
||||||
For timing patterns:
|
|
||||||
- 'remind me in X seconds' → in_seconds=X (DO NOT use 'at')
|
|
||||||
- 'every X seconds' (forever) → every_seconds=X
|
|
||||||
- 'every X seconds for Y seconds' → EVERY_SECONDS=X AND IN_SECONDS=Y (creates multiple reminders, DO NOT use 'at')
|
|
||||||
- 'at specific time' → at='ISO datetime' (only when user specifies exact time)
|
|
||||||
|
|
||||||
CRITICAL: For 'every X seconds for Y seconds', you MUST use both every_seconds AND in_seconds together. DO NOT use 'at' parameter for this pattern."""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
@ -52,11 +40,11 @@ CRITICAL: For 'every X seconds for Y seconds', you MUST use both every_seconds A
|
|||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "REQUIRED for 'add' action: The reminder message to send. Extract this from the user's request. Examples: 'Remind me to call mama' → message='call mama', 'Remind me every hour to drink water' → message='drink water', 'Schedule a task to check email' → message='check email'. Always extract the actual task/reminder text, not the full user request."
|
"description": "Reminder message (for add)"
|
||||||
},
|
},
|
||||||
"every_seconds": {
|
"every_seconds": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Interval in seconds (for recurring tasks). For 'every X seconds for Y seconds', use BOTH every_seconds AND in_seconds together to create multiple reminders."
|
"description": "Interval in seconds (for recurring tasks)"
|
||||||
},
|
},
|
||||||
"cron_expr": {
|
"cron_expr": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -68,11 +56,11 @@ CRITICAL: For 'every X seconds for Y seconds', you MUST use both every_seconds A
|
|||||||
},
|
},
|
||||||
"at": {
|
"at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "ISO datetime string for one-time execution at a SPECIFIC time. Format: YYYY-MM-DDTHH:MM:SS (e.g. '2026-03-03T12:19:30'). ONLY use this when user specifies an exact time like 'at 3pm' or 'at 2026-03-03 14:30'. DO NOT use 'at' for 'every X seconds for Y seconds' - use every_seconds + in_seconds instead."
|
"description": "ISO datetime string for one-time execution. Format: YYYY-MM-DDTHH:MM:SS (e.g. '2026-03-03T12:19:30'). You MUST calculate this from the current time shown in your system prompt plus the requested seconds/minutes, then format as ISO string."
|
||||||
},
|
},
|
||||||
"in_seconds": {
|
"in_seconds": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Schedule reminder in N seconds from now, OR duration for recurring reminders. Use this instead of calculating 'at' manually. Examples: 'remind me in 25 seconds' → in_seconds=25. For 'every 10 seconds for the next minute' → every_seconds=10 AND in_seconds=60 (creates 6 reminders)."
|
"description": "Alternative to 'at': Schedule reminder in N seconds from now. Use this instead of calculating 'at' manually. Example: in_seconds=25 for 'remind me in 25 seconds'."
|
||||||
},
|
},
|
||||||
"reminder": {
|
"reminder": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@ -123,11 +111,7 @@ CRITICAL: For 'every X seconds for Y seconds', you MUST use both every_seconds A
|
|||||||
reminder: bool = False,
|
reminder: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not message:
|
if not message:
|
||||||
return "Error: message is required for 'add' action. You must extract the reminder/task text from the user's request. Example: if user says 'remind me to call mama', use message='call mama'. If user says 'remind me every hour to drink water', use message='drink water'."
|
return "Error: message is required for add"
|
||||||
|
|
||||||
# Detect common mistake: using 'at' with 'every_seconds' when 'in_seconds' should be used
|
|
||||||
if every_seconds is not None and at is not None and in_seconds is None:
|
|
||||||
return f"Error: You used 'at' with 'every_seconds', but for 'every X seconds for Y seconds' pattern, you MUST use 'in_seconds' instead of 'at'. Example: 'every 10 seconds for the next minute' → every_seconds=10 AND in_seconds=60 (NOT 'at'). The 'in_seconds' parameter specifies the duration, and the tool will create multiple reminders automatically."
|
|
||||||
|
|
||||||
# Use defaults for CLI mode if context not set
|
# Use defaults for CLI mode if context not set
|
||||||
channel = self._channel or "cli"
|
channel = self._channel or "cli"
|
||||||
@ -147,34 +131,6 @@ CRITICAL: For 'every X seconds for Y seconds', you MUST use both every_seconds A
|
|||||||
# Build schedule - prioritize 'in_seconds' for relative time, then 'at' for absolute time
|
# Build schedule - prioritize 'in_seconds' for relative time, then 'at' for absolute time
|
||||||
delete_after = False
|
delete_after = False
|
||||||
|
|
||||||
# Special case: recurring job with duration limit (every_seconds + in_seconds)
|
|
||||||
if every_seconds is not None and in_seconds is not None:
|
|
||||||
# Create multiple one-time jobs for "every X seconds for Y seconds"
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
num_jobs = max(1, in_seconds // every_seconds)
|
|
||||||
results = []
|
|
||||||
for i in range(num_jobs):
|
|
||||||
job_time = datetime.now() + timedelta(seconds=i * every_seconds)
|
|
||||||
job_at = job_time.isoformat()
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(job_at)
|
|
||||||
at_ms = int(dt.timestamp() * 1000)
|
|
||||||
schedule = CronSchedule(kind="at", at_ms=at_ms)
|
|
||||||
job = self._cron.add_job(
|
|
||||||
name=f"{message[:25]} ({i+1}/{num_jobs})" if num_jobs > 1 else message[:30],
|
|
||||||
schedule=schedule,
|
|
||||||
message=message,
|
|
||||||
deliver=True,
|
|
||||||
channel=channel,
|
|
||||||
to=chat_id,
|
|
||||||
delete_after_run=True,
|
|
||||||
reminder=reminder,
|
|
||||||
)
|
|
||||||
results.append(f"Created job '{job.name}' (id: {job.id})")
|
|
||||||
except Exception as e:
|
|
||||||
results.append(f"Error creating job {i+1}: {str(e)}")
|
|
||||||
return f"Created {len([r for r in results if 'Created' in r])} reminder(s):\n" + "\n".join(results)
|
|
||||||
|
|
||||||
# Handle relative time (in_seconds) - compute datetime automatically
|
# Handle relative time (in_seconds) - compute datetime automatically
|
||||||
if in_seconds is not None:
|
if in_seconds is not None:
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|||||||
@ -1,568 +0,0 @@
|
|||||||
"""Email tool: read emails from IMAP mailbox."""
|
|
||||||
|
|
||||||
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
|
|
||||||
from email.parser import BytesParser
|
|
||||||
from email.utils import parseaddr
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
|
|
||||||
|
|
||||||
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."
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, email_config: Any = None, workspace: Path | None = 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:
|
|
||||||
"""Lazy load email config if not provided."""
|
|
||||||
if self._email_config is None:
|
|
||||||
from nanobot.config.loader import load_config
|
|
||||||
config = load_config()
|
|
||||||
self._email_config = config.channels.email
|
|
||||||
return self._email_config
|
|
||||||
|
|
||||||
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:
|
|
||||||
try:
|
|
||||||
coerced['limit'] = int(coerced.pop('count'))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
# Remove unsupported parameters
|
|
||||||
supported = {'limit', 'unread_only', 'mark_seen', 'download_attachments', 'attachment_name'}
|
|
||||||
coerced = {k: v for k, v in coerced.items() if k in supported}
|
|
||||||
return coerced
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"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)",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 100,
|
|
||||||
},
|
|
||||||
"unread_only": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "If true, only return unread emails. If false, returns all emails including read ones (default: false)",
|
|
||||||
},
|
|
||||||
"mark_seen": {
|
|
||||||
"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:
|
|
||||||
"""
|
|
||||||
Read emails from IMAP mailbox.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: Maximum number of emails to return (use limit=1 for latest email)
|
|
||||||
unread_only: If true, only fetch unread emails
|
|
||||||
mark_seen: If true, mark emails as read after fetching
|
|
||||||
**kwargs: Ignore any extra parameters (like count, sort_by, direction)
|
|
||||||
|
|
||||||
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:
|
|
||||||
try:
|
|
||||||
limit = int(kwargs['count'])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
# Also check if limit was passed in kwargs (in case it wasn't a named parameter)
|
|
||||||
if 'limit' in kwargs:
|
|
||||||
try:
|
|
||||||
limit = int(kwargs['limit'])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
# Ignore unsupported parameters like sort_by, direction, reverse, etc.
|
|
||||||
try:
|
|
||||||
config = self.config
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error loading email configuration: {str(e)}"
|
|
||||||
|
|
||||||
if not config:
|
|
||||||
return "Error: Email configuration not found"
|
|
||||||
|
|
||||||
if not hasattr(config, 'enabled') or not config.enabled:
|
|
||||||
return "Error: Email channel is not enabled in configuration. Set NANOBOT_CHANNELS__EMAIL__ENABLED=true"
|
|
||||||
|
|
||||||
if not hasattr(config, 'consent_granted') or not config.consent_granted:
|
|
||||||
return "Error: Email access consent not granted. Set NANOBOT_CHANNELS__EMAIL__CONSENT_GRANTED=true"
|
|
||||||
|
|
||||||
if not hasattr(config, 'imap_host') or not config.imap_host:
|
|
||||||
return "Error: IMAP host not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_HOST"
|
|
||||||
|
|
||||||
if not hasattr(config, 'imap_username') or not config.imap_username:
|
|
||||||
return "Error: IMAP username not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_USERNAME"
|
|
||||||
|
|
||||||
if not hasattr(config, 'imap_password') or not config.imap_password:
|
|
||||||
return "Error: IMAP password not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_PASSWORD"
|
|
||||||
|
|
||||||
# Limit to reasonable maximum
|
|
||||||
try:
|
|
||||||
limit = min(max(1, int(limit)), 100)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
limit = 100
|
|
||||||
|
|
||||||
try:
|
|
||||||
messages = await asyncio.to_thread(
|
|
||||||
self._fetch_messages,
|
|
||||||
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:
|
|
||||||
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."
|
|
||||||
|
|
||||||
result_parts = [f"Found {len(messages)} email(s):\n"]
|
|
||||||
for i, msg in enumerate(messages, 1):
|
|
||||||
result_parts.append(f"\n--- Email {i} ---")
|
|
||||||
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']}")
|
|
||||||
|
|
||||||
return "\n".join(result_parts)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
error_details = traceback.format_exc()
|
|
||||||
return f"Error reading emails: {str(e)}\n\nDetails: {error_details}"
|
|
||||||
|
|
||||||
def _fetch_messages(
|
|
||||||
self,
|
|
||||||
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]] = []
|
|
||||||
mailbox = self.config.imap_mailbox or "INBOX"
|
|
||||||
|
|
||||||
# Build search criteria
|
|
||||||
if unread_only:
|
|
||||||
search_criteria = ("UNSEEN",)
|
|
||||||
else:
|
|
||||||
search_criteria = ("ALL",)
|
|
||||||
|
|
||||||
# Connect to IMAP server
|
|
||||||
try:
|
|
||||||
if self.config.imap_use_ssl:
|
|
||||||
client = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
|
|
||||||
else:
|
|
||||||
client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port)
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Failed to connect to IMAP server {self.config.imap_host}:{self.config.imap_port}: {str(e)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
client.login(self.config.imap_username, self.config.imap_password.strip())
|
|
||||||
except imaplib.IMAP4.error as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
if "AUTHENTICATE" in error_msg.upper() or "LOGIN" in error_msg.upper():
|
|
||||||
raise Exception(
|
|
||||||
f"IMAP authentication failed. Please check:\n"
|
|
||||||
f"1. Your email username: {self.config.imap_username}\n"
|
|
||||||
f"2. Your password/app password is correct\n"
|
|
||||||
f"3. For Gmail: Enable 2-Step Verification and create an App Password at https://myaccount.google.com/apppasswords\n"
|
|
||||||
f"4. IMAP is enabled in your email account settings\n"
|
|
||||||
f"Original error: {error_msg}"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
status, _ = client.select(mailbox)
|
|
||||||
if status != "OK":
|
|
||||||
return messages
|
|
||||||
|
|
||||||
status, data = client.search(None, *search_criteria)
|
|
||||||
if status != "OK" or not data:
|
|
||||||
return messages
|
|
||||||
|
|
||||||
ids = data[0].split()
|
|
||||||
if limit > 0 and len(ids) > limit:
|
|
||||||
# Get most recent emails (last N)
|
|
||||||
ids = ids[-limit:]
|
|
||||||
|
|
||||||
for imap_id in ids:
|
|
||||||
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
|
|
||||||
if status != "OK" or not fetched:
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw_bytes = self._extract_message_bytes(fetched)
|
|
||||||
if raw_bytes is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
|
|
||||||
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
|
|
||||||
if not sender:
|
|
||||||
# Try to get display name if email not found
|
|
||||||
from_addr = parsed.get("From", "")
|
|
||||||
sender = from_addr if from_addr else "unknown"
|
|
||||||
|
|
||||||
subject = self._decode_header_value(parsed.get("Subject", ""))
|
|
||||||
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)"
|
|
||||||
|
|
||||||
# Limit body length
|
|
||||||
max_chars = getattr(self.config, 'max_body_chars', 12000)
|
|
||||||
body = body[:max_chars]
|
|
||||||
|
|
||||||
content = (
|
|
||||||
f"Email received.\n"
|
|
||||||
f"From: {sender}\n"
|
|
||||||
f"Subject: {subject}\n"
|
|
||||||
f"Date: {date_value}\n"
|
|
||||||
)
|
|
||||||
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({
|
|
||||||
"sender": sender,
|
|
||||||
"subject": subject,
|
|
||||||
"message_id": message_id,
|
|
||||||
"content": content,
|
|
||||||
"metadata": metadata,
|
|
||||||
})
|
|
||||||
|
|
||||||
if mark_seen:
|
|
||||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
client.logout()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return messages
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
|
|
||||||
"""Extract raw message bytes from IMAP fetch response."""
|
|
||||||
for item in fetched:
|
|
||||||
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
|
|
||||||
return bytes(item[1])
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _decode_header_value(value: str) -> str:
|
|
||||||
"""Decode email header value (handles encoded words)."""
|
|
||||||
if not value:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
return str(make_header(decode_header(value)))
|
|
||||||
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."""
|
|
||||||
if msg.is_multipart():
|
|
||||||
plain_parts: list[str] = []
|
|
||||||
html_parts: list[str] = []
|
|
||||||
for part in msg.walk():
|
|
||||||
if part.get_content_disposition() == "attachment":
|
|
||||||
continue
|
|
||||||
content_type = part.get_content_type()
|
|
||||||
try:
|
|
||||||
payload = part.get_content()
|
|
||||||
except Exception:
|
|
||||||
payload_bytes = part.get_payload(decode=True) or b""
|
|
||||||
charset = part.get_content_charset() or "utf-8"
|
|
||||||
payload = payload_bytes.decode(charset, errors="replace")
|
|
||||||
if not isinstance(payload, str):
|
|
||||||
continue
|
|
||||||
if content_type == "text/plain":
|
|
||||||
plain_parts.append(payload)
|
|
||||||
elif content_type == "text/html":
|
|
||||||
html_parts.append(payload)
|
|
||||||
if plain_parts:
|
|
||||||
return "\n\n".join(plain_parts).strip()
|
|
||||||
if html_parts:
|
|
||||||
# Simple HTML to text conversion
|
|
||||||
import re
|
|
||||||
import html
|
|
||||||
text = re.sub(r"<\s*br\s*/?>", "\n", "\n\n".join(html_parts), flags=re.IGNORECASE)
|
|
||||||
text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
|
|
||||||
text = re.sub(r"<[^>]+>", "", text)
|
|
||||||
return html.unescape(text).strip()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = msg.get_content()
|
|
||||||
except Exception:
|
|
||||||
payload_bytes = msg.get_payload(decode=True) or b""
|
|
||||||
charset = msg.get_content_charset() or "utf-8"
|
|
||||||
payload = payload_bytes.decode(charset, errors="replace")
|
|
||||||
if not isinstance(payload, str):
|
|
||||||
return ""
|
|
||||||
if msg.get_content_type() == "text/html":
|
|
||||||
import re
|
|
||||||
import html
|
|
||||||
text = re.sub(r"<\s*br\s*/?>", "\n", payload, flags=re.IGNORECASE)
|
|
||||||
text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
|
|
||||||
text = re.sub(r"<[^>]+>", "", text)
|
|
||||||
return html.unescape(text).strip()
|
|
||||||
return payload.strip()
|
|
||||||
|
|
||||||
@ -28,9 +28,7 @@ class ReadFileTool(Tool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return """Read the contents of a file at the given path.
|
return """Read the contents of a file at the given path.
|
||||||
|
|
||||||
`path` must be a single file path under the configured workspace (no `*` globs).
|
|
||||||
|
|
||||||
ALWAYS use this tool to read files - it supports:
|
ALWAYS use this tool to read files - it supports:
|
||||||
- Text files (plain text, code, markdown, etc.)
|
- 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": {
|
"properties": {
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Absolute or workspace-relative path to one file (no wildcards)",
|
"description": "The file path to read"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
@ -117,7 +115,7 @@ class WriteFileTool(Tool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
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
|
@property
|
||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
@ -221,11 +219,7 @@ class ListDirTool(Tool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return "List the contents of a directory."
|
||||||
"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."
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
@ -234,7 +228,7 @@ class ListDirTool(Tool):
|
|||||||
"properties": {
|
"properties": {
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path to an existing directory under the workspace (no wildcards)",
|
"description": "The directory path to list"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
"""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 contextlib import AsyncExitStack
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -13,65 +9,15 @@ from nanobot.agent.tools.base import Tool
|
|||||||
from nanobot.agent.tools.registry import ToolRegistry
|
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):
|
class MCPToolWrapper(Tool):
|
||||||
"""Wraps a single MCP server tool as a nanobot Tool."""
|
"""Wraps a single MCP server tool as a nanobot Tool."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, session, server_name: str, tool_def):
|
||||||
self,
|
|
||||||
session,
|
|
||||||
*,
|
|
||||||
server_key: str,
|
|
||||||
tool_def,
|
|
||||||
registered_name: str,
|
|
||||||
call_timeout_s: float = 30.0,
|
|
||||||
):
|
|
||||||
self._session = session
|
self._session = session
|
||||||
self._original_name = tool_def.name
|
self._original_name = tool_def.name
|
||||||
self._server_key = server_key
|
self._name = f"mcp_{server_name}_{tool_def.name}"
|
||||||
self._name = registered_name
|
|
||||||
self._description = tool_def.description or tool_def.name
|
self._description = tool_def.description or tool_def.name
|
||||||
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
|
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
|
||||||
self._call_timeout_s = call_timeout_s
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -86,103 +32,49 @@ class MCPToolWrapper(Tool):
|
|||||||
return self._parameters
|
return self._parameters
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> str:
|
async def execute(self, **kwargs: Any) -> str:
|
||||||
try:
|
from mcp import types
|
||||||
result = await asyncio.wait_for(
|
result = await self._session.call_tool(self._original_name, arguments=kwargs)
|
||||||
self._session.call_tool(self._original_name, arguments=kwargs),
|
parts = []
|
||||||
timeout=self._call_timeout_s,
|
for block in result.content:
|
||||||
)
|
if isinstance(block, types.TextContent):
|
||||||
except asyncio.TimeoutError:
|
parts.append(block.text)
|
||||||
return (
|
else:
|
||||||
f"Error: MCP tool timed out after {self._call_timeout_s:.0f}s "
|
parts.append(str(block))
|
||||||
f"({self._server_key}:{self._original_name})"
|
return "\n".join(parts) or "(no output)"
|
||||||
)
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
async def connect_mcp_servers(
|
||||||
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
|
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
|
||||||
) -> None:
|
) -> 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():
|
for name, cfg in mcp_servers.items():
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"MCP server '{name}': failed to connect: {e}")
|
logger.error(f"MCP server '{name}': failed to connect: {e}")
|
||||||
|
|||||||
@ -8,52 +8,44 @@ from nanobot.agent.tools.base import Tool
|
|||||||
class ToolRegistry:
|
class ToolRegistry:
|
||||||
"""
|
"""
|
||||||
Registry for agent tools.
|
Registry for agent tools.
|
||||||
|
|
||||||
Allows dynamic registration and execution of tools.
|
Allows dynamic registration and execution of tools.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._tools: dict[str, Tool] = {}
|
self._tools: dict[str, Tool] = {}
|
||||||
|
|
||||||
def register(self, tool: Tool) -> None:
|
def register(self, tool: Tool) -> None:
|
||||||
"""Register a tool."""
|
"""Register a tool."""
|
||||||
self._tools[tool.name] = tool
|
self._tools[tool.name] = tool
|
||||||
|
|
||||||
def unregister(self, name: str) -> None:
|
def unregister(self, name: str) -> None:
|
||||||
"""Unregister a tool by name."""
|
"""Unregister a tool by name."""
|
||||||
self._tools.pop(name, None)
|
self._tools.pop(name, None)
|
||||||
|
|
||||||
def get(self, name: str) -> Tool | None:
|
def get(self, name: str) -> Tool | None:
|
||||||
"""Get a tool by name."""
|
"""Get a tool by name."""
|
||||||
return self._tools.get(name)
|
return self._tools.get(name)
|
||||||
|
|
||||||
def has(self, name: str) -> bool:
|
def has(self, name: str) -> bool:
|
||||||
"""Check if a tool is registered."""
|
"""Check if a tool is registered."""
|
||||||
return name in self._tools
|
return name in self._tools
|
||||||
|
|
||||||
def get_definitions(self) -> list[dict[str, Any]]:
|
def get_definitions(self) -> list[dict[str, Any]]:
|
||||||
"""Get all tool definitions in OpenAI format."""
|
"""Get all tool definitions in OpenAI format."""
|
||||||
return [tool.to_schema() for tool in self._tools.values()]
|
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:
|
async def execute(self, name: str, params: dict[str, Any]) -> str:
|
||||||
"""
|
"""
|
||||||
Execute a tool by name with given parameters.
|
Execute a tool by name with given parameters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Tool name.
|
name: Tool name.
|
||||||
params: Tool parameters.
|
params: Tool parameters.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tool execution result as string.
|
Tool execution result as string.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
KeyError: If tool not found.
|
KeyError: If tool not found.
|
||||||
"""
|
"""
|
||||||
@ -70,14 +62,14 @@ class ToolRegistry:
|
|||||||
return await tool.execute(**coerced_params)
|
return await tool.execute(**coerced_params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error executing {name}: {str(e)}"
|
return f"Error executing {name}: {str(e)}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
"""Get list of registered tool names."""
|
"""Get list of registered tool names."""
|
||||||
return list(self._tools.keys())
|
return list(self._tools.keys())
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._tools)
|
return len(self._tools)
|
||||||
|
|
||||||
def __contains__(self, name: str) -> bool:
|
def __contains__(self, name: str) -> bool:
|
||||||
return name in self._tools
|
return name in self._tools
|
||||||
|
|||||||
@ -46,7 +46,6 @@ class ExecTool(Tool):
|
|||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
- For READING files (including PDFs, text files, etc.), ALWAYS use read_file FIRST. Do NOT use exec to read files.
|
- For READING files (including PDFs, text files, etc.), ALWAYS use read_file FIRST. Do NOT use exec to read files.
|
||||||
- Only use exec for complex data processing AFTER you have already read the file content using read_file.
|
- Only use exec for complex data processing AFTER you have already read the file content using read_file.
|
||||||
- For git commands (git commit, git push, git status, etc.), ALWAYS use exec tool, NOT write_file or edit_file.
|
|
||||||
|
|
||||||
For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
|
For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
|
||||||
- Excel files: python3 -c "import pandas as pd; df = pd.read_excel('file.xlsx'); result = df['Column Name'].sum(); print(result)"
|
- Excel files: python3 -c "import pandas as pd; df = pd.read_excel('file.xlsx'); result = df['Column Name'].sum(); print(result)"
|
||||||
@ -54,13 +53,7 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
|
|||||||
- NEVER use pandas/openpyxl as command-line tools (they don't exist)
|
- NEVER use pandas/openpyxl as command-line tools (they don't exist)
|
||||||
- NEVER use non-existent tools like csvcalc, xlsxcalc, etc.
|
- NEVER use non-existent tools like csvcalc, xlsxcalc, etc.
|
||||||
- For calculations: Use pandas operations like .sum(), .mean(), .max(), etc.
|
- For calculations: Use pandas operations like .sum(), .mean(), .max(), etc.
|
||||||
- For total inventory value: (df['Unit Price'] * df['Quantity']).sum()
|
- For total inventory value: (df['Unit Price'] * df['Quantity']).sum()"""
|
||||||
|
|
||||||
For git operations:
|
|
||||||
- git commit: exec(command="git commit -m 'message'")
|
|
||||||
- git status: exec(command="git status")
|
|
||||||
- git push: exec(command="git push")
|
|
||||||
- NEVER use write_file or edit_file for git commands"""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
@ -81,10 +74,6 @@ For git operations:
|
|||||||
|
|
||||||
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
|
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
|
||||||
cwd = working_dir or self.working_dir or os.getcwd()
|
cwd = working_dir or self.working_dir or os.getcwd()
|
||||||
|
|
||||||
# Sanitize Gitea API URLs: convert HTTPS to HTTP for 10.0.30.169:3000
|
|
||||||
command = self._sanitize_gitea_urls(command)
|
|
||||||
|
|
||||||
guard_error = self._guard_command(command, cwd)
|
guard_error = self._guard_command(command, cwd)
|
||||||
if guard_error:
|
if guard_error:
|
||||||
return guard_error
|
return guard_error
|
||||||
@ -94,14 +83,11 @@ For git operations:
|
|||||||
logger.debug(f"ExecTool: command={command[:200]}, cwd={cwd}, working_dir={working_dir}")
|
logger.debug(f"ExecTool: command={command[:200]}, cwd={cwd}, working_dir={working_dir}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Ensure environment variables are available (including from .env file)
|
|
||||||
env = os.environ.copy()
|
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_shell(
|
||||||
command,
|
command,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -214,33 +200,3 @@ For git operations:
|
|||||||
return "Error: Command blocked by safety guard (path outside working dir)"
|
return "Error: Command blocked by safety guard (path outside working dir)"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _sanitize_gitea_urls(self, command: str) -> str:
|
|
||||||
"""
|
|
||||||
Sanitize Gitea API URLs in curl commands: convert HTTPS to HTTP.
|
|
||||||
|
|
||||||
Gitea API at 10.0.30.169:3000 runs on HTTP, not HTTPS.
|
|
||||||
This prevents SSL/TLS errors when the agent generates HTTPS URLs.
|
|
||||||
"""
|
|
||||||
# Pattern to match https://10.0.30.169:3000/api/... in curl commands
|
|
||||||
# This handles various curl formats:
|
|
||||||
# - curl "https://10.0.30.169:3000/api/..."
|
|
||||||
# - curl -X GET https://10.0.30.169:3000/api/...
|
|
||||||
# - curl -H "..." "https://10.0.30.169:3000/api/..."
|
|
||||||
# Matches URLs with or without quotes, and captures the full path
|
|
||||||
pattern = r'https://10\.0\.30\.169:3000(/api/[^\s"\']*)'
|
|
||||||
|
|
||||||
def replace_url(match):
|
|
||||||
path = match.group(1)
|
|
||||||
return f'http://10.0.30.169:3000{path}'
|
|
||||||
|
|
||||||
sanitized = re.sub(pattern, replace_url, command)
|
|
||||||
|
|
||||||
# Log if we made a change
|
|
||||||
if sanitized != command:
|
|
||||||
from loguru import logger
|
|
||||||
logger.info(f"ExecTool: Sanitized Gitea API URL (HTTPS -> HTTP)")
|
|
||||||
logger.debug(f"Original: {command[:200]}...")
|
|
||||||
logger.debug(f"Sanitized: {sanitized[:200]}...")
|
|
||||||
|
|
||||||
return sanitized
|
|
||||||
|
|||||||
@ -35,16 +35,7 @@ class SpawnTool(Tool):
|
|||||||
return (
|
return (
|
||||||
"Spawn a subagent to handle a task in the background. "
|
"Spawn a subagent to handle a task in the background. "
|
||||||
"Use this for complex or time-consuming tasks that can run independently. "
|
"Use this for complex or time-consuming tasks that can run independently. "
|
||||||
"The subagent will complete the task and report back when done.\n\n"
|
"The subagent will complete the task and report back when done."
|
||||||
"CRITICAL: The 'task' parameter MUST be a natural language description of what to do, "
|
|
||||||
"NOT a tool call. The subagent will figure out how to accomplish the task using its own tools.\n\n"
|
|
||||||
"CORRECT examples:\n"
|
|
||||||
"- task='Read all documentation files in the project and create a summary'\n"
|
|
||||||
"- task='Analyze the codebase structure and generate a report'\n"
|
|
||||||
"- task='Search for information about X and compile findings'\n\n"
|
|
||||||
"WRONG (do not use tool call syntax):\n"
|
|
||||||
"- task='read_dir(path=\"/path/to/file\")' ❌\n"
|
|
||||||
"- task='read_file(path=\"file.txt\")' ❌"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -54,12 +45,7 @@ class SpawnTool(Tool):
|
|||||||
"properties": {
|
"properties": {
|
||||||
"task": {
|
"task": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": "The task for the subagent to complete",
|
||||||
"A natural language description of the task for the subagent to complete. "
|
|
||||||
"DO NOT use tool call syntax. Examples: 'Read all documentation and summarize', "
|
|
||||||
"'Analyze the codebase structure', 'Search the web for X and compile findings'. "
|
|
||||||
"The subagent will determine which tools to use to accomplish this task."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@ -101,9 +101,9 @@ class WebSearchTool(Tool):
|
|||||||
try:
|
try:
|
||||||
n = min(max(count or self.max_results, 1), 10)
|
n = min(max(count or self.max_results, 1), 10)
|
||||||
|
|
||||||
# Try using ddgs library if available (renamed from duckduckgo_search)
|
# Try using duckduckgo_search library if available
|
||||||
try:
|
try:
|
||||||
from ddgs import DDGS
|
from duckduckgo_search import DDGS
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = []
|
results = []
|
||||||
for r in ddgs.text(query, max_results=n):
|
for r in ddgs.text(query, max_results=n):
|
||||||
@ -112,7 +112,7 @@ class WebSearchTool(Tool):
|
|||||||
"url": r.get("href", ""),
|
"url": r.get("href", ""),
|
||||||
"description": r.get("body", "")
|
"description": r.get("body", "")
|
||||||
})
|
})
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return f"No results found for: {query}"
|
return f"No results found for: {query}"
|
||||||
|
|
||||||
@ -123,58 +123,51 @@ class WebSearchTool(Tool):
|
|||||||
lines.append(f" {item['description']}")
|
lines.append(f" {item['description']}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# ddgs package not installed, fall through to fallback
|
# Fallback: use DuckDuckGo instant answer API (simpler, but limited)
|
||||||
pass
|
async with httpx.AsyncClient(
|
||||||
except Exception as e:
|
follow_redirects=True,
|
||||||
# Log ddgs errors but fall through to fallback API
|
timeout=15.0
|
||||||
import logging
|
) as client:
|
||||||
logging.debug(f"ddgs search error: {e}")
|
# Use DuckDuckGo instant answer API (no key needed)
|
||||||
|
url = "https://api.duckduckgo.com/"
|
||||||
# Fallback: use DuckDuckGo instant answer API (simpler, but limited)
|
r = await client.get(
|
||||||
async with httpx.AsyncClient(
|
url,
|
||||||
follow_redirects=True,
|
params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"},
|
||||||
timeout=15.0
|
headers={"User-Agent": USER_AGENT},
|
||||||
) as client:
|
)
|
||||||
# Use DuckDuckGo instant answer API (no key needed)
|
r.raise_for_status()
|
||||||
url = "https://api.duckduckgo.com/"
|
data = r.json()
|
||||||
r = await client.get(
|
|
||||||
url,
|
results = []
|
||||||
params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"},
|
# Get RelatedTopics (search results)
|
||||||
headers={"User-Agent": USER_AGENT},
|
if "RelatedTopics" in data:
|
||||||
)
|
for topic in data["RelatedTopics"][:n]:
|
||||||
r.raise_for_status()
|
if "Text" in topic and "FirstURL" in topic:
|
||||||
data = r.json()
|
results.append({
|
||||||
|
"title": topic.get("Text", "").split(" - ")[0] if " - " in topic.get("Text", "") else topic.get("Text", "")[:50],
|
||||||
results = []
|
"url": topic.get("FirstURL", ""),
|
||||||
# Get RelatedTopics (search results)
|
"description": topic.get("Text", "")
|
||||||
if "RelatedTopics" in data:
|
})
|
||||||
for topic in data["RelatedTopics"][:n]:
|
|
||||||
if "Text" in topic and "FirstURL" in topic:
|
# Also check AbstractText for direct answer
|
||||||
results.append({
|
if "AbstractText" in data and data["AbstractText"]:
|
||||||
"title": topic.get("Text", "").split(" - ")[0] if " - " in topic.get("Text", "") else topic.get("Text", "")[:50],
|
results.insert(0, {
|
||||||
"url": topic.get("FirstURL", ""),
|
"title": data.get("Heading", query),
|
||||||
"description": topic.get("Text", "")
|
"url": data.get("AbstractURL", ""),
|
||||||
})
|
"description": data.get("AbstractText", "")
|
||||||
|
})
|
||||||
# Also check AbstractText for direct answer
|
|
||||||
if "AbstractText" in data and data["AbstractText"]:
|
if not results:
|
||||||
results.insert(0, {
|
return f"No results found for: {query}. Try installing 'duckduckgo-search' package for better results: pip install duckduckgo-search"
|
||||||
"title": data.get("Heading", query),
|
|
||||||
"url": data.get("AbstractURL", ""),
|
lines = [f"Results for: {query}\n"]
|
||||||
"description": data.get("AbstractText", "")
|
for i, item in enumerate(results[:n], 1):
|
||||||
})
|
lines.append(f"{i}. {item['title']}\n {item['url']}")
|
||||||
|
if item['description']:
|
||||||
if not results:
|
lines.append(f" {item['description']}")
|
||||||
return f"No results found for: {query}. Try installing 'ddgs' package for better results: pip install ddgs"
|
return "\n".join(lines)
|
||||||
|
|
||||||
lines = [f"Results for: {query}\n"]
|
|
||||||
for i, item in enumerate(results[:n], 1):
|
|
||||||
lines.append(f"{i}. {item['title']}\n {item['url']}")
|
|
||||||
if item['description']:
|
|
||||||
lines.append(f" {item['description']}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error searching: {e}. Try installing 'ddgs' package: pip install ddgs"
|
return f"Error searching: {e}. Try installing 'duckduckgo-search' package: pip install duckduckgo-search"
|
||||||
|
|
||||||
|
|
||||||
class WebFetchTool(Tool):
|
class WebFetchTool(Tool):
|
||||||
|
|||||||
@ -1,228 +0,0 @@
|
|||||||
"""Email parsing utilities for extracting meeting information."""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
|
|
||||||
def extract_meeting_info(email_content: str, email_subject: str = "") -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Extract meeting information from email content and subject.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
email_content: Email body text
|
|
||||||
email_subject: Email subject line
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with meeting details if found, None otherwise
|
|
||||||
"""
|
|
||||||
text = (email_subject + " " + email_content).lower()
|
|
||||||
|
|
||||||
# Check for meeting-related keywords
|
|
||||||
meeting_keywords = [
|
|
||||||
"meeting",
|
|
||||||
"appointment",
|
|
||||||
"call",
|
|
||||||
"conference",
|
|
||||||
"standup",
|
|
||||||
"stand-up",
|
|
||||||
"sync",
|
|
||||||
"discussion",
|
|
||||||
"catch up",
|
|
||||||
"catch-up",
|
|
||||||
]
|
|
||||||
|
|
||||||
has_meeting_keyword = any(keyword in text for keyword in meeting_keywords)
|
|
||||||
if not has_meeting_keyword:
|
|
||||||
return None
|
|
||||||
|
|
||||||
result: dict[str, Any] = {
|
|
||||||
"title": None,
|
|
||||||
"start_time": None,
|
|
||||||
"end_time": None,
|
|
||||||
"location": None,
|
|
||||||
"attendees": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract title from subject or first line
|
|
||||||
if email_subject:
|
|
||||||
# Remove common prefixes
|
|
||||||
title = email_subject
|
|
||||||
for prefix in ["Re:", "Fwd:", "FW:"]:
|
|
||||||
if title.lower().startswith(prefix.lower()):
|
|
||||||
title = title[len(prefix) :].strip()
|
|
||||||
result["title"] = title[:100] # Limit length
|
|
||||||
|
|
||||||
# Extract time information
|
|
||||||
# Month names mapping
|
|
||||||
month_names = {
|
|
||||||
"january": 1, "jan": 1,
|
|
||||||
"february": 2, "feb": 2,
|
|
||||||
"march": 3, "mar": 3,
|
|
||||||
"april": 4, "apr": 4,
|
|
||||||
"may": 5,
|
|
||||||
"june": 6, "jun": 6,
|
|
||||||
"july": 7, "jul": 7,
|
|
||||||
"august": 8, "aug": 8,
|
|
||||||
"september": 9, "sep": 9, "sept": 9,
|
|
||||||
"october": 10, "oct": 10,
|
|
||||||
"november": 11, "nov": 11,
|
|
||||||
"december": 12, "dec": 12,
|
|
||||||
}
|
|
||||||
|
|
||||||
time_patterns = [
|
|
||||||
# "March 7 at 15:00" or "March 7th at 3pm" or "on March 7 at 15:00"
|
|
||||||
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?\s+(?:at\s+)?(\d{1,2}):(\d{2})",
|
|
||||||
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?\s+(?:at\s+)?(\d{1,2})\s*(am|pm)",
|
|
||||||
# "March 7" (date only, assume current year)
|
|
||||||
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?",
|
|
||||||
# Relative dates
|
|
||||||
r"tomorrow\s+at\s+(\d{1,2})\s*(am|pm)?",
|
|
||||||
r"tomorrow\s+(\d{1,2})\s*(am|pm)?",
|
|
||||||
r"(\d{1,2})\s*(am|pm)\s+tomorrow",
|
|
||||||
r"(\d{1,2}):(\d{2})\s*(am|pm)?\s+tomorrow",
|
|
||||||
r"in\s+(\d+)\s+(hour|hours|minute|minutes|day|days)",
|
|
||||||
# Date formats
|
|
||||||
r"(\d{1,2})/(\d{1,2})/(\d{4})\s+at\s+(\d{1,2}):(\d{2})\s*(am|pm)?",
|
|
||||||
r"(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})",
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in time_patterns:
|
|
||||||
match = re.search(pattern, text, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
now = datetime.now()
|
|
||||||
groups = match.groups()
|
|
||||||
|
|
||||||
# Check if this is a month name pattern (first group is month name)
|
|
||||||
if groups and groups[0].lower() in month_names:
|
|
||||||
month_name = groups[0].lower()
|
|
||||||
month = month_names[month_name]
|
|
||||||
day = int(groups[1])
|
|
||||||
year = now.year
|
|
||||||
|
|
||||||
# Check if date is in the past (assume next year if so)
|
|
||||||
test_date = datetime(year, month, day)
|
|
||||||
if test_date < now.replace(hour=0, minute=0, second=0, microsecond=0):
|
|
||||||
year += 1
|
|
||||||
|
|
||||||
# Check if time is provided (pattern with 4 groups means time included)
|
|
||||||
if len(groups) >= 4 and groups[2] and groups[3]:
|
|
||||||
# Check if groups[3] is am/pm or minutes
|
|
||||||
if groups[3].lower() in ['am', 'pm']:
|
|
||||||
# Format: "March 7 at 3pm" (12-hour with am/pm)
|
|
||||||
hour = int(groups[2])
|
|
||||||
period = groups[3].lower()
|
|
||||||
minute = 0
|
|
||||||
if period == "pm" and hour != 12:
|
|
||||||
hour += 12
|
|
||||||
elif period == "am" and hour == 12:
|
|
||||||
hour = 0
|
|
||||||
else:
|
|
||||||
# Format: "March 7 at 15:00" (24-hour with colon)
|
|
||||||
# groups[2] = hour, groups[3] = minute
|
|
||||||
hour = int(groups[2])
|
|
||||||
minute = int(groups[3])
|
|
||||||
result["start_time"] = datetime(year, month, day, hour, minute)
|
|
||||||
else:
|
|
||||||
# Date only, default to 9am
|
|
||||||
result["start_time"] = datetime(year, month, day, 9, 0)
|
|
||||||
break
|
|
||||||
elif "tomorrow" in pattern:
|
|
||||||
base_date = now + timedelta(days=1)
|
|
||||||
hour = int(match.group(1))
|
|
||||||
period = match.group(2) if len(match.groups()) > 1 else None
|
|
||||||
if period:
|
|
||||||
if period.lower() == "pm" and hour != 12:
|
|
||||||
hour += 12
|
|
||||||
elif period.lower() == "am" and hour == 12:
|
|
||||||
hour = 0
|
|
||||||
result["start_time"] = base_date.replace(hour=hour, minute=0, second=0, microsecond=0)
|
|
||||||
break
|
|
||||||
elif "in" in pattern:
|
|
||||||
amount = int(match.group(1))
|
|
||||||
unit = match.group(2)
|
|
||||||
if "hour" in unit:
|
|
||||||
result["start_time"] = now + timedelta(hours=amount)
|
|
||||||
elif "minute" in unit:
|
|
||||||
result["start_time"] = now + timedelta(minutes=amount)
|
|
||||||
elif "day" in unit:
|
|
||||||
result["start_time"] = now + timedelta(days=amount)
|
|
||||||
break
|
|
||||||
except (ValueError, IndexError, AttributeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract location
|
|
||||||
location_patterns = [
|
|
||||||
r"location[:\s]+([^\n]+)",
|
|
||||||
r"where[:\s]+([^\n]+)",
|
|
||||||
r"at\s+([A-Z][^\n]+)", # Capitalized location names
|
|
||||||
r"room\s+([A-Z0-9]+)",
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in location_patterns:
|
|
||||||
match = re.search(pattern, text, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
location = match.group(1).strip()
|
|
||||||
if len(location) < 100: # Reasonable length
|
|
||||||
result["location"] = location
|
|
||||||
break
|
|
||||||
|
|
||||||
# Extract attendees (email addresses)
|
|
||||||
email_pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
|
|
||||||
emails = re.findall(email_pattern, email_content)
|
|
||||||
if emails:
|
|
||||||
result["attendees"] = list(set(emails)) # Remove duplicates
|
|
||||||
|
|
||||||
# Only return if we found at least a title or time
|
|
||||||
if result["title"] or result["start_time"]:
|
|
||||||
logger.info(f"Extracted meeting info: {result}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def format_meeting_for_calendar(meeting_info: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Format meeting info for calendar tool.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
meeting_info: Meeting information dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted dictionary for calendar.create_event
|
|
||||||
"""
|
|
||||||
formatted: dict[str, Any] = {
|
|
||||||
"action": "create_event",
|
|
||||||
}
|
|
||||||
|
|
||||||
if meeting_info.get("title"):
|
|
||||||
formatted["title"] = meeting_info["title"]
|
|
||||||
else:
|
|
||||||
formatted["title"] = "Meeting"
|
|
||||||
|
|
||||||
if meeting_info.get("start_time"):
|
|
||||||
if isinstance(meeting_info["start_time"], datetime):
|
|
||||||
formatted["start_time"] = meeting_info["start_time"].isoformat()
|
|
||||||
else:
|
|
||||||
formatted["start_time"] = str(meeting_info["start_time"])
|
|
||||||
|
|
||||||
if meeting_info.get("end_time"):
|
|
||||||
if isinstance(meeting_info["end_time"], datetime):
|
|
||||||
formatted["end_time"] = meeting_info["end_time"].isoformat()
|
|
||||||
else:
|
|
||||||
formatted["end_time"] = str(meeting_info["end_time"])
|
|
||||||
|
|
||||||
if meeting_info.get("location"):
|
|
||||||
formatted["location"] = meeting_info["location"]
|
|
||||||
|
|
||||||
if meeting_info.get("description"):
|
|
||||||
formatted["description"] = meeting_info["description"]
|
|
||||||
|
|
||||||
if meeting_info.get("attendees"):
|
|
||||||
formatted["attendees"] = meeting_info["attendees"]
|
|
||||||
|
|
||||||
return formatted
|
|
||||||
|
|
||||||
@ -6,7 +6,6 @@ import imaplib
|
|||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
import uuid
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from email import policy
|
from email import policy
|
||||||
from email.header import decode_header, make_header
|
from email.header import decode_header, make_header
|
||||||
@ -58,8 +57,6 @@ class EmailChannel(BaseChannel):
|
|||||||
self._last_message_id_by_chat: dict[str, str] = {}
|
self._last_message_id_by_chat: dict[str, str] = {}
|
||||||
self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
|
self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
|
||||||
self._MAX_PROCESSED_UIDS = 100000
|
self._MAX_PROCESSED_UIDS = 100000
|
||||||
self._sent_message_ids: set[str] = set() # Track Message-IDs of emails we sent to prevent feedback loops
|
|
||||||
self._MAX_SENT_MESSAGE_IDS = 10000
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start polling IMAP for inbound emails."""
|
"""Start polling IMAP for inbound emails."""
|
||||||
@ -137,12 +134,6 @@ class EmailChannel(BaseChannel):
|
|||||||
email_msg["To"] = to_addr
|
email_msg["To"] = to_addr
|
||||||
email_msg["Subject"] = subject
|
email_msg["Subject"] = subject
|
||||||
email_msg.set_content(msg.content or "")
|
email_msg.set_content(msg.content or "")
|
||||||
|
|
||||||
# Generate a Message-ID for the email we're sending (to track and prevent feedback loops)
|
|
||||||
from_email = email_msg["From"]
|
|
||||||
domain = from_email.split("@")[-1] if "@" in from_email else "nanobot.local"
|
|
||||||
message_id = f"<{uuid.uuid4()}@{domain}>"
|
|
||||||
email_msg["Message-ID"] = message_id
|
|
||||||
|
|
||||||
in_reply_to = self._last_message_id_by_chat.get(to_addr)
|
in_reply_to = self._last_message_id_by_chat.get(to_addr)
|
||||||
if in_reply_to:
|
if in_reply_to:
|
||||||
@ -151,13 +142,6 @@ class EmailChannel(BaseChannel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self._smtp_send, email_msg)
|
await asyncio.to_thread(self._smtp_send, email_msg)
|
||||||
# Track this Message-ID so we can ignore replies to it (prevent feedback loops)
|
|
||||||
self._sent_message_ids.add(message_id)
|
|
||||||
# Trim if too large
|
|
||||||
if len(self._sent_message_ids) > self._MAX_SENT_MESSAGE_IDS:
|
|
||||||
# Remove oldest entries (simple approach: keep recent ones)
|
|
||||||
self._sent_message_ids.clear()
|
|
||||||
logger.debug(f"Sent email with Message-ID: {message_id} to {to_addr}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending email to {to_addr}: {e}")
|
logger.error(f"Error sending email to {to_addr}: {e}")
|
||||||
raise
|
raise
|
||||||
@ -264,10 +248,6 @@ class EmailChannel(BaseChannel):
|
|||||||
ids = data[0].split()
|
ids = data[0].split()
|
||||||
if limit > 0 and len(ids) > limit:
|
if limit > 0 and len(ids) > limit:
|
||||||
ids = ids[-limit:]
|
ids = ids[-limit:]
|
||||||
|
|
||||||
our_email = (self.config.from_address or self.config.smtp_username or self.config.imap_username).strip().lower()
|
|
||||||
skipped_count = 0
|
|
||||||
|
|
||||||
for imap_id in ids:
|
for imap_id in ids:
|
||||||
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
|
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
|
||||||
if status != "OK" or not fetched:
|
if status != "OK" or not fetched:
|
||||||
@ -285,46 +265,10 @@ class EmailChannel(BaseChannel):
|
|||||||
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
|
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
|
||||||
if not sender:
|
if not sender:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip emails from ourselves (prevent feedback loops)
|
|
||||||
if sender == our_email:
|
|
||||||
# Track skipped UIDs to avoid reprocessing
|
|
||||||
if uid and dedupe:
|
|
||||||
self._processed_uids.add(uid)
|
|
||||||
# Trim if too large
|
|
||||||
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
|
|
||||||
# Remove oldest entries (simple approach: keep recent ones)
|
|
||||||
self._processed_uids.clear()
|
|
||||||
# Mark as seen so it doesn't keep appearing in UNSEEN searches
|
|
||||||
if mark_seen:
|
|
||||||
try:
|
|
||||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
subject = self._decode_header_value(parsed.get("Subject", ""))
|
subject = self._decode_header_value(parsed.get("Subject", ""))
|
||||||
date_value = parsed.get("Date", "")
|
date_value = parsed.get("Date", "")
|
||||||
message_id = parsed.get("Message-ID", "").strip()
|
message_id = parsed.get("Message-ID", "").strip()
|
||||||
in_reply_to = parsed.get("In-Reply-To", "").strip()
|
|
||||||
|
|
||||||
# Skip emails that are replies to emails we sent (prevent feedback loops)
|
|
||||||
if in_reply_to and in_reply_to in self._sent_message_ids:
|
|
||||||
# Track skipped UIDs to avoid reprocessing
|
|
||||||
if uid and dedupe:
|
|
||||||
self._processed_uids.add(uid)
|
|
||||||
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
|
|
||||||
self._processed_uids.clear()
|
|
||||||
# Mark as seen so it doesn't keep appearing in UNSEEN searches
|
|
||||||
if mark_seen:
|
|
||||||
try:
|
|
||||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
body = self._extract_text_body(parsed)
|
body = self._extract_text_body(parsed)
|
||||||
|
|
||||||
if not body:
|
if not body:
|
||||||
@ -369,10 +313,6 @@ class EmailChannel(BaseChannel):
|
|||||||
client.logout()
|
client.logout()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Log summary of skipped emails (only if significant number) - reduces log noise
|
|
||||||
if skipped_count > 0:
|
|
||||||
logger.debug(f"Skipped {skipped_count} email(s) from self or replies to our emails")
|
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|||||||
@ -2,22 +2,23 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import select
|
|
||||||
import signal
|
import signal
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import select
|
||||||
|
import sys
|
||||||
|
|
||||||
import typer
|
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.console import Console
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
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
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
@ -158,9 +159,9 @@ def onboard():
|
|||||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.utils.helpers import get_workspace_path
|
from nanobot.utils.helpers import get_workspace_path
|
||||||
|
|
||||||
config_path = get_config_path()
|
config_path = get_config_path()
|
||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||||
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
|
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
|
||||||
@ -176,17 +177,17 @@ def onboard():
|
|||||||
else:
|
else:
|
||||||
save_config(Config())
|
save_config(Config())
|
||||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||||
|
|
||||||
# Create workspace
|
# Create workspace
|
||||||
workspace = get_workspace_path()
|
workspace = get_workspace_path()
|
||||||
|
|
||||||
if not workspace.exists():
|
if not workspace.exists():
|
||||||
workspace.mkdir(parents=True, exist_ok=True)
|
workspace.mkdir(parents=True, exist_ok=True)
|
||||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||||
|
|
||||||
# Create default bootstrap files
|
# Create default bootstrap files
|
||||||
_create_workspace_templates(workspace)
|
_create_workspace_templates(workspace)
|
||||||
|
|
||||||
console.print(f"\n{__logo__} nanobot is ready!")
|
console.print(f"\n{__logo__} nanobot is ready!")
|
||||||
console.print("\nNext steps:")
|
console.print("\nNext steps:")
|
||||||
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
|
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)
|
- Language: (your preferred language)
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|
||||||
for filename, content in templates.items():
|
for filename, content in templates.items():
|
||||||
file_path = workspace / filename
|
file_path = workspace / filename
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
file_path.write_text(content)
|
file_path.write_text(content)
|
||||||
console.print(f" [dim]Created {filename}[/dim]")
|
console.print(f" [dim]Created {filename}[/dim]")
|
||||||
|
|
||||||
# Create memory directory and MEMORY.md
|
# Create memory directory and MEMORY.md
|
||||||
memory_dir = workspace / "memory"
|
memory_dir = workspace / "memory"
|
||||||
memory_dir.mkdir(exist_ok=True)
|
memory_dir.mkdir(exist_ok=True)
|
||||||
@ -267,7 +268,7 @@ This file stores important information that should persist across sessions.
|
|||||||
(Things to remember)
|
(Things to remember)
|
||||||
""")
|
""")
|
||||||
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
||||||
|
|
||||||
history_file = memory_dir / "HISTORY.md"
|
history_file = memory_dir / "HISTORY.md"
|
||||||
if not history_file.exists():
|
if not history_file.exists():
|
||||||
history_file.write_text("")
|
history_file.write_text("")
|
||||||
@ -280,9 +281,9 @@ This file stores important information that should persist across sessions.
|
|||||||
|
|
||||||
def _make_provider(config: Config):
|
def _make_provider(config: Config):
|
||||||
"""Create the appropriate LLM provider from 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.litellm_provider import LiteLLMProvider
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
|
from nanobot.providers.custom_provider import CustomProvider
|
||||||
|
|
||||||
model = config.agents.defaults.model
|
model = config.agents.defaults.model
|
||||||
provider_name = config.get_provider_name(model)
|
provider_name = config.get_provider_name(model)
|
||||||
@ -309,7 +310,7 @@ def _make_provider(config: Config):
|
|||||||
airllm_config = getattr(config.providers, "airllm", None)
|
airllm_config = getattr(config.providers, "airllm", None)
|
||||||
model_path = None
|
model_path = None
|
||||||
compression = None
|
compression = None
|
||||||
|
|
||||||
# Try to get model from airllm config's api_key field (repurposed as model path)
|
# Try to get model from airllm config's api_key field (repurposed as model path)
|
||||||
# or from the default model
|
# or from the default model
|
||||||
if airllm_config and airllm_config.api_key:
|
if airllm_config and airllm_config.api_key:
|
||||||
@ -324,7 +325,7 @@ def _make_provider(config: Config):
|
|||||||
else:
|
else:
|
||||||
model_path = model
|
model_path = model
|
||||||
hf_token = None
|
hf_token = None
|
||||||
|
|
||||||
# Check for compression setting in extra_headers or api_base
|
# Check for compression setting in extra_headers or api_base
|
||||||
if airllm_config:
|
if airllm_config:
|
||||||
if airllm_config.api_base:
|
if airllm_config.api_base:
|
||||||
@ -334,7 +335,7 @@ def _make_provider(config: Config):
|
|||||||
# Check for HF token in extra_headers
|
# Check for HF token in extra_headers
|
||||||
if not hf_token and airllm_config.extra_headers and "hf_token" in airllm_config.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"]
|
hf_token = airllm_config.extra_headers["hf_token"]
|
||||||
|
|
||||||
return AirLLMProvider(
|
return AirLLMProvider(
|
||||||
api_key=airllm_config.api_key if airllm_config else None,
|
api_key=airllm_config.api_key if airllm_config else None,
|
||||||
api_base=compression if compression 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"),
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
):
|
):
|
||||||
"""Start the nanobot gateway."""
|
"""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.bus.queue import MessageBus
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.channels.manager import ChannelManager
|
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.service import CronService
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.heartbeat.service import HeartbeatService
|
from nanobot.heartbeat.service import HeartbeatService
|
||||||
from nanobot.session.manager import SessionManager
|
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
import logging
|
import logging
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
session_manager = SessionManager(config.workspace_path)
|
session_manager = SessionManager(config.workspace_path)
|
||||||
|
|
||||||
# Create cron service first (callback set after agent creation)
|
# Create cron service first (callback set after agent creation)
|
||||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop(
|
agent = AgentLoop(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
@ -414,11 +415,8 @@ def gateway(
|
|||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
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)
|
# Set cron callback (needs agent)
|
||||||
async def on_cron_job(job: CronJob) -> str | None:
|
async def on_cron_job(job: CronJob) -> str | None:
|
||||||
"""Execute a cron job through the agent."""
|
"""Execute a cron job through the agent."""
|
||||||
@ -451,33 +449,33 @@ def gateway(
|
|||||||
))
|
))
|
||||||
return response
|
return response
|
||||||
cron.on_job = on_cron_job
|
cron.on_job = on_cron_job
|
||||||
|
|
||||||
# Create heartbeat service
|
# Create heartbeat service
|
||||||
async def on_heartbeat(prompt: str) -> str:
|
async def on_heartbeat(prompt: str) -> str:
|
||||||
"""Execute heartbeat through the agent."""
|
"""Execute heartbeat through the agent."""
|
||||||
return await agent.process_direct(prompt, session_key="heartbeat")
|
return await agent.process_direct(prompt, session_key="heartbeat")
|
||||||
|
|
||||||
heartbeat = HeartbeatService(
|
heartbeat = HeartbeatService(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
on_heartbeat=on_heartbeat,
|
on_heartbeat=on_heartbeat,
|
||||||
interval_s=30 * 60, # 30 minutes
|
interval_s=30 * 60, # 30 minutes
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create channel manager
|
# Create channel manager
|
||||||
channels = ChannelManager(config, bus)
|
channels = ChannelManager(config, bus)
|
||||||
|
|
||||||
if channels.enabled_channels:
|
if channels.enabled_channels:
|
||||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||||
else:
|
else:
|
||||||
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
||||||
|
|
||||||
cron_status = cron.status()
|
cron_status = cron.status()
|
||||||
if cron_status["jobs"] > 0:
|
if cron_status["jobs"] > 0:
|
||||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
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():
|
async def run():
|
||||||
try:
|
try:
|
||||||
await cron.start()
|
await cron.start()
|
||||||
@ -494,7 +492,7 @@ def gateway(
|
|||||||
cron.stop()
|
cron.stop()
|
||||||
agent.stop()
|
agent.stop()
|
||||||
await channels.stop_all()
|
await channels.stop_all()
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
@ -513,16 +511,14 @@ def agent(
|
|||||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
||||||
):
|
):
|
||||||
"""Interact with the agent directly."""
|
"""Interact with the agent directly."""
|
||||||
from loguru import logger
|
from nanobot.config.loader import load_config, get_data_dir
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.bus.queue import MessageBus
|
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 nanobot.cron.service import CronService
|
||||||
|
from loguru import logger
|
||||||
# Load config (this also loads .env file into environment)
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
|
|
||||||
@ -534,7 +530,7 @@ def agent(
|
|||||||
logger.enable("nanobot")
|
logger.enable("nanobot")
|
||||||
else:
|
else:
|
||||||
logger.disable("nanobot")
|
logger.disable("nanobot")
|
||||||
|
|
||||||
agent_loop = AgentLoop(
|
agent_loop = AgentLoop(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@ -549,11 +545,8 @@ def agent(
|
|||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
mcp_servers=config.tools.mcp_servers,
|
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
|
# Show spinner when logs are off (no output to miss); skip when logs are on
|
||||||
def _thinking_ctx():
|
def _thinking_ctx():
|
||||||
if logs:
|
if logs:
|
||||||
@ -579,7 +572,7 @@ def agent(
|
|||||||
console.print(f"[red]Error: {e}[/red]")
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
asyncio.run(run_once())
|
asyncio.run(run_once())
|
||||||
else:
|
else:
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
@ -592,7 +585,7 @@ def agent(
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, _exit_on_sigint)
|
signal.signal(signal.SIGINT, _exit_on_sigint)
|
||||||
|
|
||||||
async def run_interactive():
|
async def run_interactive():
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@ -607,7 +600,7 @@ def agent(
|
|||||||
_restore_terminal()
|
_restore_terminal()
|
||||||
console.print("\nGoodbye!")
|
console.print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
|
|
||||||
with _thinking_ctx():
|
with _thinking_ctx():
|
||||||
response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
|
response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
|
||||||
_print_agent_response(response, render_markdown=markdown)
|
_print_agent_response(response, render_markdown=markdown)
|
||||||
@ -621,7 +614,7 @@ def agent(
|
|||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
await agent_loop.close_mcp()
|
await agent_loop.close_mcp()
|
||||||
|
|
||||||
asyncio.run(run_interactive())
|
asyncio.run(run_interactive())
|
||||||
|
|
||||||
|
|
||||||
@ -678,7 +671,7 @@ def channels_status():
|
|||||||
"✓" if mc.enabled else "✗",
|
"✓" if mc.enabled else "✗",
|
||||||
mc_base
|
mc_base
|
||||||
)
|
)
|
||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
tg = config.channels.telegram
|
tg = config.channels.telegram
|
||||||
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
||||||
@ -704,57 +697,57 @@ def _get_bridge_dir() -> Path:
|
|||||||
"""Get the bridge directory, setting it up if needed."""
|
"""Get the bridge directory, setting it up if needed."""
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
# User's bridge location
|
# User's bridge location
|
||||||
user_bridge = Path.home() / ".nanobot" / "bridge"
|
user_bridge = Path.home() / ".nanobot" / "bridge"
|
||||||
|
|
||||||
# Check if already built
|
# Check if already built
|
||||||
if (user_bridge / "dist" / "index.js").exists():
|
if (user_bridge / "dist" / "index.js").exists():
|
||||||
return user_bridge
|
return user_bridge
|
||||||
|
|
||||||
# Check for npm
|
# Check for npm
|
||||||
if not shutil.which("npm"):
|
if not shutil.which("npm"):
|
||||||
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
# Find source bridge: first check package data, then source dir
|
# Find source bridge: first check package data, then source dir
|
||||||
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
||||||
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
|
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
|
||||||
|
|
||||||
source = None
|
source = None
|
||||||
if (pkg_bridge / "package.json").exists():
|
if (pkg_bridge / "package.json").exists():
|
||||||
source = pkg_bridge
|
source = pkg_bridge
|
||||||
elif (src_bridge / "package.json").exists():
|
elif (src_bridge / "package.json").exists():
|
||||||
source = src_bridge
|
source = src_bridge
|
||||||
|
|
||||||
if not source:
|
if not source:
|
||||||
console.print("[red]Bridge source not found.[/red]")
|
console.print("[red]Bridge source not found.[/red]")
|
||||||
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
console.print(f"{__logo__} Setting up bridge...")
|
console.print(f"{__logo__} Setting up bridge...")
|
||||||
|
|
||||||
# Copy to user directory
|
# Copy to user directory
|
||||||
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if user_bridge.exists():
|
if user_bridge.exists():
|
||||||
shutil.rmtree(user_bridge)
|
shutil.rmtree(user_bridge)
|
||||||
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
||||||
|
|
||||||
# Install and build
|
# Install and build
|
||||||
try:
|
try:
|
||||||
console.print(" Installing dependencies...")
|
console.print(" Installing dependencies...")
|
||||||
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
|
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
|
||||||
|
|
||||||
console.print(" Building...")
|
console.print(" Building...")
|
||||||
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
||||||
|
|
||||||
console.print("[green]✓[/green] Bridge ready\n")
|
console.print("[green]✓[/green] Bridge ready\n")
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
console.print(f"[red]Build failed: {e}[/red]")
|
console.print(f"[red]Build failed: {e}[/red]")
|
||||||
if e.stderr:
|
if e.stderr:
|
||||||
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
|
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
return user_bridge
|
return user_bridge
|
||||||
|
|
||||||
|
|
||||||
@ -762,19 +755,18 @@ def _get_bridge_dir() -> Path:
|
|||||||
def channels_login():
|
def channels_login():
|
||||||
"""Link device via QR code."""
|
"""Link device via QR code."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
bridge_dir = _get_bridge_dir()
|
bridge_dir = _get_bridge_dir()
|
||||||
|
|
||||||
console.print(f"{__logo__} Starting bridge...")
|
console.print(f"{__logo__} Starting bridge...")
|
||||||
console.print("Scan the QR code to connect.\n")
|
console.print("Scan the QR code to connect.\n")
|
||||||
|
|
||||||
env = {**os.environ}
|
env = {**os.environ}
|
||||||
if config.channels.whatsapp.bridge_token:
|
if config.channels.whatsapp.bridge_token:
|
||||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
@ -798,23 +790,23 @@ def cron_list(
|
|||||||
"""List scheduled jobs."""
|
"""List scheduled jobs."""
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
jobs = service.list_jobs(include_disabled=all)
|
jobs = service.list_jobs(include_disabled=all)
|
||||||
|
|
||||||
if not jobs:
|
if not jobs:
|
||||||
console.print("No scheduled jobs.")
|
console.print("No scheduled jobs.")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = Table(title="Scheduled Jobs")
|
table = Table(title="Scheduled Jobs")
|
||||||
table.add_column("ID", style="cyan")
|
table.add_column("ID", style="cyan")
|
||||||
table.add_column("Name")
|
table.add_column("Name")
|
||||||
table.add_column("Schedule")
|
table.add_column("Schedule")
|
||||||
table.add_column("Status")
|
table.add_column("Status")
|
||||||
table.add_column("Next Run")
|
table.add_column("Next Run")
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime as _dt
|
from datetime import datetime as _dt
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@ -826,7 +818,7 @@ def cron_list(
|
|||||||
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
|
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
|
||||||
else:
|
else:
|
||||||
sched = "one-time"
|
sched = "one-time"
|
||||||
|
|
||||||
# Format next run
|
# Format next run
|
||||||
next_run = ""
|
next_run = ""
|
||||||
if job.state.next_run_at_ms:
|
if job.state.next_run_at_ms:
|
||||||
@ -836,11 +828,11 @@ def cron_list(
|
|||||||
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
|
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
|
||||||
except Exception:
|
except Exception:
|
||||||
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
||||||
|
|
||||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
||||||
|
|
||||||
table.add_row(job.id, job.name, sched, status, next_run)
|
table.add_row(job.id, job.name, sched, status, next_run)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
@ -860,7 +852,7 @@ def cron_add(
|
|||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronSchedule
|
from nanobot.cron.types import CronSchedule
|
||||||
|
|
||||||
if tz and not cron_expr:
|
if tz and not cron_expr:
|
||||||
console.print("[red]Error: --tz can only be used with --cron[/red]")
|
console.print("[red]Error: --tz can only be used with --cron[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@ -877,10 +869,10 @@ def cron_add(
|
|||||||
else:
|
else:
|
||||||
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
job = service.add_job(
|
job = service.add_job(
|
||||||
name=name,
|
name=name,
|
||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
@ -889,7 +881,7 @@ def cron_add(
|
|||||||
to=to,
|
to=to,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
||||||
|
|
||||||
|
|
||||||
@ -900,10 +892,10 @@ def cron_remove(
|
|||||||
"""Remove a scheduled job."""
|
"""Remove a scheduled job."""
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
if service.remove_job(job_id):
|
if service.remove_job(job_id):
|
||||||
console.print(f"[green]✓[/green] Removed job {job_id}")
|
console.print(f"[green]✓[/green] Removed job {job_id}")
|
||||||
else:
|
else:
|
||||||
@ -918,10 +910,10 @@ def cron_enable(
|
|||||||
"""Enable or disable a job."""
|
"""Enable or disable a job."""
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
job = service.enable_job(job_id, enabled=not disable)
|
job = service.enable_job(job_id, enabled=not disable)
|
||||||
if job:
|
if job:
|
||||||
status = "disabled" if disable else "enabled"
|
status = "disabled" if disable else "enabled"
|
||||||
@ -938,15 +930,15 @@ def cron_run(
|
|||||||
"""Manually run a job."""
|
"""Manually run a job."""
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.loader import get_data_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||||
service = CronService(store_path)
|
service = CronService(store_path)
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
return await service.run_job(job_id, force=force)
|
return await service.run_job(job_id, force=force)
|
||||||
|
|
||||||
if asyncio.run(run()):
|
if asyncio.run(run()):
|
||||||
console.print("[green]✓[/green] Job executed")
|
console.print(f"[green]✓[/green] Job executed")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]Failed to run job {job_id}[/red]")
|
console.print(f"[red]Failed to run job {job_id}[/red]")
|
||||||
|
|
||||||
@ -959,7 +951,7 @@ def cron_run(
|
|||||||
@app.command()
|
@app.command()
|
||||||
def status():
|
def status():
|
||||||
"""Show nanobot 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_path = get_config_path()
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@ -974,7 +966,7 @@ def status():
|
|||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
console.print(f"Model: {config.agents.defaults.model}")
|
console.print(f"Model: {config.agents.defaults.model}")
|
||||||
|
|
||||||
# Check API keys from registry
|
# Check API keys from registry
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
p = getattr(config.providers, spec.name, None)
|
p = getattr(config.providers, spec.name, None)
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""Configuration loading utilities."""
|
"""Configuration loading utilities."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
@ -18,43 +17,6 @@ def get_data_dir() -> Path:
|
|||||||
return get_data_path()
|
return get_data_path()
|
||||||
|
|
||||||
|
|
||||||
def _load_env_file(workspace: Path | None = None) -> None:
|
|
||||||
"""Load .env file from workspace directory if it exists."""
|
|
||||||
if workspace:
|
|
||||||
env_file = Path(workspace) / ".env"
|
|
||||||
else:
|
|
||||||
# Try current directory and workspace
|
|
||||||
env_file = Path(".env")
|
|
||||||
if not env_file.exists():
|
|
||||||
# Try workspace directory
|
|
||||||
try:
|
|
||||||
from nanobot.utils.helpers import get_workspace_path
|
|
||||||
workspace_path = get_workspace_path()
|
|
||||||
env_file = workspace_path / ".env"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if env_file.exists():
|
|
||||||
try:
|
|
||||||
with open(env_file) as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
# Skip comments and empty lines
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
# Parse KEY=VALUE format
|
|
||||||
if "=" in line:
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip().strip('"').strip("'")
|
|
||||||
# Only set if not already in environment
|
|
||||||
if key and key not in os.environ:
|
|
||||||
os.environ[key] = value
|
|
||||||
except Exception:
|
|
||||||
# Silently fail if .env can't be loaded
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: Path | None = None) -> Config:
|
def load_config(config_path: Path | None = None) -> Config:
|
||||||
"""
|
"""
|
||||||
Load configuration from file or create default.
|
Load configuration from file or create default.
|
||||||
@ -65,15 +27,6 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
Returns:
|
Returns:
|
||||||
Loaded configuration object.
|
Loaded configuration object.
|
||||||
"""
|
"""
|
||||||
# Load .env file before loading config (so env vars are available to pydantic)
|
|
||||||
try:
|
|
||||||
from nanobot.utils.helpers import get_workspace_path
|
|
||||||
workspace = get_workspace_path()
|
|
||||||
_load_env_file(workspace)
|
|
||||||
except:
|
|
||||||
# Fallback to current directory
|
|
||||||
_load_env_file()
|
|
||||||
|
|
||||||
path = config_path or get_config_path()
|
path = config_path or get_config_path()
|
||||||
|
|
||||||
if path.exists():
|
if path.exists():
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
"""Configuration schema using Pydantic."""
|
"""Configuration schema using Pydantic."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
||||||
from pydantic.alias_generators import to_camel
|
from pydantic.alias_generators import to_camel
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@ -245,17 +244,6 @@ class WebToolsConfig(Base):
|
|||||||
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
|
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
|
||||||
|
|
||||||
|
|
||||||
class CalendarConfig(Base):
|
|
||||||
"""Google Calendar configuration."""
|
|
||||||
|
|
||||||
enabled: bool = False
|
|
||||||
credentials_file: str = "" # Path to OAuth2 credentials JSON file
|
|
||||||
token_file: str = "" # Path to store OAuth2 token (default: ~/.nanobot/calendar_token.json)
|
|
||||||
calendar_id: str = "primary" # Calendar ID to use (default: primary calendar)
|
|
||||||
auto_schedule_from_email: bool = True # Automatically schedule meetings from emails
|
|
||||||
timezone: str = "UTC" # Timezone for parsing times (e.g., "America/New_York", "Europe/London", "UTC")
|
|
||||||
|
|
||||||
|
|
||||||
class ExecToolConfig(Base):
|
class ExecToolConfig(Base):
|
||||||
"""Shell exec tool configuration."""
|
"""Shell exec tool configuration."""
|
||||||
|
|
||||||
@ -271,47 +259,13 @@ class MCPServerConfig(Base):
|
|||||||
url: str = "" # HTTP: streamable HTTP endpoint URL
|
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):
|
class ToolsConfig(Base):
|
||||||
"""Tools configuration."""
|
"""Tools configuration."""
|
||||||
|
|
||||||
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
||||||
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
||||||
calendar: CalendarConfig = Field(default_factory=CalendarConfig)
|
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
|
||||||
restrict_to_workspace: bool = True # If true, restrict all tool access to workspace directory
|
|
||||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
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):
|
class Config(BaseSettings):
|
||||||
|
|||||||
@ -59,63 +59,25 @@ class CustomProvider(LLMProvider):
|
|||||||
for tc in (msg.tool_calls or [])
|
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)
|
# Only parse if content looks like it contains a tool call JSON (to avoid false positives)
|
||||||
content = msg.content or ""
|
content = msg.content or ""
|
||||||
stripped = content.strip()
|
if not tool_calls and content and '"name"' in content and '"parameters"' in content:
|
||||||
# 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):
|
|
||||||
import re
|
import re
|
||||||
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...}
|
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}}
|
||||||
# Find complete JSON objects by matching braces
|
# Find complete JSON objects by matching braces
|
||||||
# Try "action" pattern first (for calendar tool), then "name" pattern
|
pattern = r'\{\s*"name"\s*:\s*"(\w+)"'
|
||||||
patterns = [
|
|
||||||
(r'\{\s*"action"\s*:\s*"(\w+)"', "action"), # Calendar tool format
|
|
||||||
(r'\{\s*"name"\s*:\s*"(\w+)"', "name"), # Standard format
|
|
||||||
]
|
|
||||||
start_pos = 0
|
start_pos = 0
|
||||||
max_iterations = 10 # Increased for multiple patterns
|
max_iterations = 5 # Safety limit
|
||||||
iteration = 0
|
iteration = 0
|
||||||
while iteration < max_iterations:
|
while iteration < max_iterations:
|
||||||
iteration += 1
|
iteration += 1
|
||||||
match = None
|
match = re.search(pattern, content[start_pos:])
|
||||||
pattern_type = None
|
|
||||||
for pattern, ptype in patterns:
|
|
||||||
match = re.search(pattern, content[start_pos:])
|
|
||||||
if match:
|
|
||||||
pattern_type = ptype
|
|
||||||
break
|
|
||||||
if not match:
|
if not match:
|
||||||
break
|
break
|
||||||
|
|
||||||
json_start = start_pos + match.start()
|
json_start = start_pos + match.start()
|
||||||
key_value = match.group(1)
|
name = match.group(1)
|
||||||
|
|
||||||
# Find the matching closing brace by counting braces
|
# Find the matching closing brace by counting braces
|
||||||
brace_count = 0
|
brace_count = 0
|
||||||
@ -136,29 +98,13 @@ class CustomProvider(LLMProvider):
|
|||||||
try:
|
try:
|
||||||
json_str = content[json_start:json_end]
|
json_str = content[json_start:json_end]
|
||||||
tool_obj = json_repair.loads(json_str)
|
tool_obj = json_repair.loads(json_str)
|
||||||
|
# Only accept if it has both name and parameters, and name is a valid tool name
|
||||||
# Handle calendar tool format: {"action": "...", ...}
|
valid_tools = ["exec", "read_file", "write_file", "list_dir", "web_search"]
|
||||||
if isinstance(tool_obj, dict) and "action" in tool_obj:
|
|
||||||
# This is a calendar tool call in JSON format
|
|
||||||
action = tool_obj.get("action")
|
|
||||||
if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]:
|
|
||||||
# Convert to calendar tool call format
|
|
||||||
tool_calls.append(ToolCallRequest(
|
|
||||||
id=f"call_{len(tool_calls)}",
|
|
||||||
name="calendar",
|
|
||||||
arguments=tool_obj # Pass the whole object as arguments
|
|
||||||
))
|
|
||||||
# Remove the tool call from content
|
|
||||||
content = content[:json_start] + content[json_end:].strip()
|
|
||||||
start_pos = json_start # Stay at same position since we removed text
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Handle standard format: {"name": "...", "parameters": {...}}
|
|
||||||
if (isinstance(tool_obj, dict) and
|
if (isinstance(tool_obj, dict) and
|
||||||
"name" in tool_obj and
|
"name" in tool_obj and
|
||||||
"parameters" in tool_obj and
|
"parameters" in tool_obj and
|
||||||
isinstance(tool_obj["name"], str) 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(
|
tool_calls.append(ToolCallRequest(
|
||||||
id=f"call_{len(tool_calls)}",
|
id=f"call_{len(tool_calls)}",
|
||||||
name=tool_obj["name"],
|
name=tool_obj["name"],
|
||||||
@ -172,32 +118,6 @@ class CustomProvider(LLMProvider):
|
|||||||
pass # If parsing fails, skip this match
|
pass # If parsing fails, skip this match
|
||||||
|
|
||||||
start_pos = json_start + 1 # Move past 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
|
u = response.usage
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
---
|
|
||||||
name: calendar
|
|
||||||
description: "Interact with Google Calendar. Create events, list upcoming events, and check availability."
|
|
||||||
---
|
|
||||||
|
|
||||||
# Calendar Skill
|
|
||||||
|
|
||||||
Use the `calendar` tool to interact with Google Calendar.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Enable Google Calendar API in Google Cloud Console
|
|
||||||
2. Create OAuth2 credentials (Desktop app)
|
|
||||||
3. Download credentials JSON file
|
|
||||||
4. Configure in nanobot:
|
|
||||||
```bash
|
|
||||||
export NANOBOT_TOOLS__CALENDAR__ENABLED=true
|
|
||||||
export NANOBOT_TOOLS__CALENDAR__CREDENTIALS_FILE=/path/to/credentials.json
|
|
||||||
```
|
|
||||||
|
|
||||||
On first run, you'll be prompted to authorize access via OAuth flow.
|
|
||||||
|
|
||||||
## Actions
|
|
||||||
|
|
||||||
### List Events
|
|
||||||
|
|
||||||
List upcoming calendar events:
|
|
||||||
```
|
|
||||||
calendar(action="list_events", max_results=10)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create Event
|
|
||||||
|
|
||||||
Create a new calendar event:
|
|
||||||
```
|
|
||||||
calendar(
|
|
||||||
action="create_event",
|
|
||||||
title="Team Meeting",
|
|
||||||
start_time="2024-01-15T14:00:00",
|
|
||||||
end_time="2024-01-15T15:00:00",
|
|
||||||
description="Discuss project progress",
|
|
||||||
location="Conference Room A",
|
|
||||||
attendees=["colleague@example.com"]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Time formats:**
|
|
||||||
- ISO format: `"2024-01-15T14:00:00"`
|
|
||||||
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
|
|
||||||
|
|
||||||
### Check Availability
|
|
||||||
|
|
||||||
Check if a time slot is available:
|
|
||||||
```
|
|
||||||
calendar(
|
|
||||||
action="check_availability",
|
|
||||||
start_time="2024-01-15T14:00:00",
|
|
||||||
end_time="2024-01-15T15:00:00"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Email Integration
|
|
||||||
|
|
||||||
When an email mentions a meeting (e.g., "meeting tomorrow at 2pm"), the agent can automatically:
|
|
||||||
1. Parse the email to extract meeting details
|
|
||||||
2. Create a calendar event using `create_event`
|
|
||||||
3. Confirm the event was created
|
|
||||||
|
|
||||||
Enable automatic scheduling:
|
|
||||||
```bash
|
|
||||||
export NANOBOT_TOOLS__CALENDAR__AUTO_SCHEDULE_FROM_EMAIL=true
|
|
||||||
```
|
|
||||||
|
|
||||||
@ -15,11 +15,6 @@ Use the `cron` tool to schedule reminders or recurring tasks.
|
|||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
**IMPORTANT**: Always extract the message from the user's request:
|
|
||||||
- User: "remind me to call mama" → `message="call mama"`
|
|
||||||
- User: "remind me every hour to drink water" → `message="drink water"`
|
|
||||||
- User: "remind me every 10 seconds for the next minute to call mama" → `message="call mama"`
|
|
||||||
|
|
||||||
Fixed reminder:
|
Fixed reminder:
|
||||||
```
|
```
|
||||||
cron(action="add", message="Time to take a break!", every_seconds=1200)
|
cron(action="add", message="Time to take a break!", every_seconds=1200)
|
||||||
@ -55,8 +50,6 @@ cron(action="remove", job_id="abc123")
|
|||||||
| remind me in 1 hour | **in_seconds: 3600** (1 hour = 3600 seconds) |
|
| remind me in 1 hour | **in_seconds: 3600** (1 hour = 3600 seconds) |
|
||||||
| every 20 minutes | every_seconds: 1200 |
|
| every 20 minutes | every_seconds: 1200 |
|
||||||
| every hour | every_seconds: 3600 |
|
| every hour | every_seconds: 3600 |
|
||||||
| **every 10 seconds for the next minute** | **every_seconds: 10 AND in_seconds: 60** (creates 6 reminders: at 0s, 10s, 20s, 30s, 40s, 50s) |
|
|
||||||
| **every 5 seconds for 30 seconds** | **every_seconds: 5 AND in_seconds: 30** (creates 6 reminders) |
|
|
||||||
| every day at 8am | cron_expr: "0 8 * * *" |
|
| every day at 8am | cron_expr: "0 8 * * *" |
|
||||||
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
|
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
|
||||||
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
|
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
|
||||||
@ -68,8 +61,6 @@ cron(action="remove", job_id="abc123")
|
|||||||
- "remind me in 25 seconds" → `cron(action="add", message="...", in_seconds=25)`
|
- "remind me in 25 seconds" → `cron(action="add", message="...", in_seconds=25)`
|
||||||
- "remind me in 5 minutes" → `cron(action="add", message="...", in_seconds=300)` (5 * 60 = 300)
|
- "remind me in 5 minutes" → `cron(action="add", message="...", in_seconds=300)` (5 * 60 = 300)
|
||||||
- "remind me in 1 hour" → `cron(action="add", message="...", in_seconds=3600)` (60 * 60 = 3600)
|
- "remind me in 1 hour" → `cron(action="add", message="...", in_seconds=3600)` (60 * 60 = 3600)
|
||||||
- **"remind me every 10 seconds for the next minute"** → `cron(action="add", message="...", every_seconds=10, in_seconds=60)` (creates 6 reminders)
|
|
||||||
- **"every 5 seconds for 30 seconds"** → `cron(action="add", message="...", every_seconds=5, in_seconds=30)` (creates 6 reminders)
|
|
||||||
|
|
||||||
The `in_seconds` parameter automatically computes the correct future datetime - you don't need to calculate it yourself!
|
The `in_seconds` parameter automatically computes the correct future datetime - you don't need to calculate it yourself!
|
||||||
|
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
name: gitea
|
|
||||||
description: "Interact with Gitea API using curl. This repository uses Gitea (NOT GitHub) at http://10.0.30.169:3000/api/v1. ALWAYS use HTTP (not HTTPS)."
|
|
||||||
metadata: {"nanobot":{"emoji":"🔧","requires":{"env":["NANOBOT_GITLE_TOKEN"]}}}
|
|
||||||
---
|
|
||||||
|
|
||||||
# Gitea Skill
|
|
||||||
|
|
||||||
**CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub.
|
|
||||||
|
|
||||||
## Important Rules
|
|
||||||
|
|
||||||
1. **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP port 3000
|
|
||||||
2. **ALWAYS include Authorization header** with `$NANOBOT_GITLE_TOKEN`
|
|
||||||
3. **Repository**: `ilia/nanobot`
|
|
||||||
4. **API Base**: `http://10.0.30.169:3000/api/v1`
|
|
||||||
|
|
||||||
## Pull Requests
|
|
||||||
|
|
||||||
List all pull requests:
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
```
|
|
||||||
|
|
||||||
List open pull requests:
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls?state=open"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Issues
|
|
||||||
|
|
||||||
List open issues:
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Helper Script
|
|
||||||
|
|
||||||
You can also use the helper script:
|
|
||||||
```bash
|
|
||||||
./workspace/gitea_api.sh prs
|
|
||||||
./workspace/gitea_api.sh issues open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Mistakes to Avoid
|
|
||||||
|
|
||||||
❌ **WRONG**: `curl https://10.0.30.169:3000/api/...` (SSL error)
|
|
||||||
❌ **WRONG**: `curl http://gitea.example.com/api/...` (placeholder URL)
|
|
||||||
✅ **CORRECT**: `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"`
|
|
||||||
@ -24,7 +24,6 @@ dependencies = [
|
|||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
"websocket-client>=1.6.0",
|
"websocket-client>=1.6.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"ddgs>=9.0.0",
|
|
||||||
"oauth-cli-kit>=0.1.1",
|
"oauth-cli-kit>=0.1.1",
|
||||||
"loguru>=0.7.0",
|
"loguru>=0.7.0",
|
||||||
"readability-lxml>=0.8.0",
|
"readability-lxml>=0.8.0",
|
||||||
@ -43,10 +42,6 @@ dependencies = [
|
|||||||
"prompt-toolkit>=3.0.0",
|
"prompt-toolkit>=3.0.0",
|
||||||
"mcp>=1.0.0",
|
"mcp>=1.0.0",
|
||||||
"json-repair>=0.30.0",
|
"json-repair>=0.30.0",
|
||||||
"google-api-python-client>=2.0.0",
|
|
||||||
"google-auth-httplib2>=0.2.0",
|
|
||||||
"google-auth-oauthlib>=1.0.0",
|
|
||||||
"pytz>=2024.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@ -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"
|
|
||||||
|
|
||||||
|
|
||||||
@ -2,94 +2,13 @@
|
|||||||
|
|
||||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||||
|
|
||||||
**CRITICAL: TOOL EXECUTION**
|
|
||||||
- When you need to use a tool, CALL IT DIRECTLY - the system will execute it automatically
|
|
||||||
- DO NOT show JSON like `{"action": "list_events"}` in your response text
|
|
||||||
- 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:**
|
|
||||||
|
|
||||||
**MANDATORY COMMAND FORMAT:**
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
```
|
|
||||||
|
|
||||||
**CRITICAL RULES:**
|
|
||||||
1. **DO NOT use web_search** - execute the curl command directly
|
|
||||||
2. **MUST use `http://` (NOT `https://`)** - Gitea runs on HTTP port 3000
|
|
||||||
3. **MUST include Authorization header** with `$NANOBOT_GITLE_TOKEN`
|
|
||||||
4. **Copy the exact command above** - do not modify the protocol to HTTPS
|
|
||||||
|
|
||||||
**WRONG (will fail):**
|
|
||||||
- `curl -X GET https://10.0.30.169:3000/api/...` ❌ (SSL error)
|
|
||||||
- `curl https://10.0.30.169:3000/api/...` ❌ (SSL error)
|
|
||||||
|
|
||||||
**CORRECT:**
|
|
||||||
- `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"` ✅
|
|
||||||
|
|
||||||
**OR use the helper script (recommended - avoids HTTPS mistakes):**
|
|
||||||
```bash
|
|
||||||
./workspace/gitea_api.sh prs
|
|
||||||
./workspace/gitea_api.sh issues open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
|
|
||||||
- **CRITICAL: When you need to use a tool, the system will automatically execute it when you call it. You do NOT need to show JSON.**
|
- Always explain what you're doing before taking actions
|
||||||
- **When user asks you to do something, IMMEDIATELY call the necessary tools - do not explain, do not show JSON, just call them.**
|
|
||||||
- The system handles tool execution automatically - you just need to call the tools in your response.
|
|
||||||
- Ask for clarification when the request is ambiguous
|
- Ask for clarification when the request is ambiguous
|
||||||
|
- Use tools to help accomplish tasks
|
||||||
- Remember important information in your memory files
|
- Remember important information in your memory files
|
||||||
|
|
||||||
## Git Operations
|
|
||||||
|
|
||||||
**CRITICAL**: When user asks to commit, push, or perform git operations:
|
|
||||||
- **ALWAYS use the `exec` tool** to run git commands
|
|
||||||
- **NEVER use `write_file` or `edit_file`** for git commands
|
|
||||||
- Git commands are shell commands and must be executed, not written to files
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- User: "commit with message 'Fix bug'" → `exec(command="git commit -m 'Fix bug'")`
|
|
||||||
- User: "commit the staged files" → `exec(command="git commit -m 'your message here'")`
|
|
||||||
- User: "push to remote" → `exec(command="git push")`
|
|
||||||
- User: "check git status" → `exec(command="git status")`
|
|
||||||
|
|
||||||
**WRONG (will not work):**
|
|
||||||
- `write_file(path="git commit -m 'message'", content="...")` ❌
|
|
||||||
- `edit_file(path="git commit", ...)` ❌
|
|
||||||
|
|
||||||
**CORRECT:**
|
|
||||||
- `exec(command="git commit -m 'Fix HTTPS to HTTP conversion for Gitea API'")` ✅
|
|
||||||
|
|
||||||
## When NOT to Use Tools
|
## When NOT to Use Tools
|
||||||
|
|
||||||
**For simple acknowledgments, respond naturally and conversationally - no tools needed.**
|
**For simple acknowledgments, respond naturally and conversationally - no tools needed.**
|
||||||
@ -123,36 +42,6 @@ You have access to:
|
|||||||
- Messaging (message)
|
- Messaging (message)
|
||||||
- Background tasks (spawn)
|
- Background tasks (spawn)
|
||||||
- Scheduled tasks (cron) - for reminders and delayed actions
|
- 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
|
## Memory
|
||||||
|
|
||||||
@ -183,106 +72,6 @@ When the scheduled time arrives, the cron system will send the message back to y
|
|||||||
|
|
||||||
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. Use the `cron` tool.
|
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. Use the `cron` tool.
|
||||||
|
|
||||||
## Calendar Integration
|
|
||||||
|
|
||||||
**CRITICAL: When processing emails that mention meetings, you MUST automatically schedule them in the calendar.**
|
|
||||||
|
|
||||||
**CRITICAL: When using calendar tools, EXECUTE them immediately. Do NOT show JSON or explain what you would do - just call the tool.**
|
|
||||||
|
|
||||||
When an email mentions a meeting (e.g., "meeting tomorrow at 2pm", "reminder about our meeting on March 7 at 15:00", "call scheduled for next week"), you MUST:
|
|
||||||
|
|
||||||
1. **Extract meeting details** from the email:
|
|
||||||
- Title/subject (use email subject if no explicit title)
|
|
||||||
- Date and time (parse formats like "March 7 at 15:00", "tomorrow 2pm", etc.)
|
|
||||||
- Location (if mentioned)
|
|
||||||
- Attendees (email addresses)
|
|
||||||
|
|
||||||
2. **Check if meeting already exists** (optional but recommended):
|
|
||||||
- Use `calendar(action="list_events")` to check upcoming events
|
|
||||||
- Look for events with similar title/time
|
|
||||||
|
|
||||||
3. **Use the `calendar` tool** to create the event:
|
|
||||||
```
|
|
||||||
calendar(
|
|
||||||
action="create_event",
|
|
||||||
title="Meeting Title",
|
|
||||||
start_time="March 7 15:00", # Use natural language format, NOT ISO format
|
|
||||||
end_time="March 7 16:00", # optional, defaults to 1 hour after start
|
|
||||||
location="Conference Room A", # optional
|
|
||||||
attendees=["colleague@example.com"] # optional
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**CRITICAL:** Always use natural language time formats like "March 7 15:00" or "tomorrow 2pm".
|
|
||||||
**DO NOT** generate ISO format strings like "2024-03-06T19:00:00" - the calendar tool will parse
|
|
||||||
natural language correctly and handle the current year automatically. If you generate ISO format
|
|
||||||
with the wrong year (e.g., 2024 instead of 2026), the meeting will be scheduled in the past.
|
|
||||||
|
|
||||||
4. **Confirm to the user** that the meeting was scheduled (include the calendar link if available).
|
|
||||||
|
|
||||||
**Time formats supported:**
|
|
||||||
- Month names: `"March 7 at 15:00"`, `"March 7th at 3pm"`, `"on March 7 at 15:00"`
|
|
||||||
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
|
|
||||||
- ISO format: `"2024-01-15T14:00:00"`
|
|
||||||
|
|
||||||
**Deleting/Canceling Events:**
|
|
||||||
When the user asks to cancel or delete meetings, you MUST follow this workflow - DO NOT explain, just execute:
|
|
||||||
|
|
||||||
**STEP 1: ALWAYS call list_events FIRST - DO THIS NOW, DO NOT EXPLAIN**
|
|
||||||
- IMMEDIATELY call `calendar(action="list_events", time_min="today")`
|
|
||||||
- Do NOT explain what you will do - just call the tool
|
|
||||||
- Do NOT try to use `delete_events_today` (it doesn't exist)
|
|
||||||
|
|
||||||
**STEP 2: From the list_events response, identify the target event(s)**
|
|
||||||
- "Cancel all meetings today" → ALL events from today (extract ALL IDs from the response)
|
|
||||||
- "Cancel my last meeting" → The last event in the list (marked as "LAST - latest time")
|
|
||||||
- "Cancel my 8pm meeting" → Event(s) at 8pm
|
|
||||||
- "Cancel the meeting with John" → Event(s) with "John" in title/description
|
|
||||||
|
|
||||||
**STEP 3: Extract event IDs from the response**
|
|
||||||
- Event IDs are long strings (20+ characters) after `[ID: ` or in the `Event IDs:` line
|
|
||||||
- For "cancel all", extract ALL IDs from the response
|
|
||||||
|
|
||||||
**STEP 4: Call delete_event or delete_events with the extracted IDs**
|
|
||||||
- Single event: `calendar(action="delete_event", event_id="...")`
|
|
||||||
- Multiple events: `calendar(action="delete_events", event_ids=[...])`
|
|
||||||
- **CRITICAL**: Do NOT use placeholder IDs - you MUST extract real IDs from list_events response
|
|
||||||
- **CRITICAL**: Do NOT use `update_event` with `status: "cancelled"` (that doesn't work)
|
|
||||||
|
|
||||||
**Rescheduling/Moving Events:**
|
|
||||||
When the user asks to reschedule or move a meeting, you MUST follow these steps:
|
|
||||||
|
|
||||||
**STEP 1: ALWAYS call list_events FIRST - DO THIS NOW, DO NOT EXPLAIN**
|
|
||||||
- IMMEDIATELY call `calendar(action="list_events", time_min="today")`
|
|
||||||
- Do NOT explain what you will do - just call the tool
|
|
||||||
- Do NOT use placeholder values - you MUST get the actual ID from the response
|
|
||||||
|
|
||||||
**STEP 2: From the list_events response, identify the target event**
|
|
||||||
- "last meeting" → The event with the LATEST time (marked as "LAST - latest time" in the response, usually the last numbered item)
|
|
||||||
- "first meeting" → The event with the EARLIEST time (marked as "FIRST - earliest time", usually #1)
|
|
||||||
- "8pm meeting" → Event(s) at 8pm (look for "8:00 PM" or "20:00" in the time)
|
|
||||||
- "meeting with John" → Event(s) with "John" in the title
|
|
||||||
- Extract the actual event_id (long string after `[ID: `, usually 20+ characters)
|
|
||||||
- IMPORTANT: Events are numbered in the response - use the number and the "LAST" marker to identify correctly
|
|
||||||
|
|
||||||
**STEP 3: IMMEDIATELY call update_event with the actual event_id**
|
|
||||||
- Call `calendar(action="update_event", event_id="actual_id_from_step_2", start_time="new time")`
|
|
||||||
- Use natural language for new time: "4pm", "next Monday at 4pm", "tomorrow 2pm", etc.
|
|
||||||
- Do NOT explain - just execute the tool call
|
|
||||||
|
|
||||||
**CRITICAL:**
|
|
||||||
- When you get an error saying "Invalid event_id" or "placeholder", DO NOT explain the solution
|
|
||||||
- Instead, IMMEDIATELY call list_events, then call update_event again with the real ID
|
|
||||||
- NEVER show JSON - just call the tools
|
|
||||||
- NEVER use placeholder values - always get real IDs from list_events
|
|
||||||
|
|
||||||
**Automatic scheduling:** When `auto_schedule_from_email` is enabled (default: true), automatically schedule meetings when detected in emails. Do NOT just acknowledge - actually create the calendar event using the `calendar` tool.
|
|
||||||
|
|
||||||
**Examples of emails that should trigger scheduling:**
|
|
||||||
- "Reminder about our meeting on March 7 at 15:00" → Schedule for March 7 at 3 PM
|
|
||||||
- "Meeting tomorrow at 2pm" → Schedule for tomorrow at 2 PM
|
|
||||||
- "Call scheduled for next week" → Extract date and schedule
|
|
||||||
|
|
||||||
## Heartbeat Tasks
|
## Heartbeat Tasks
|
||||||
|
|
||||||
`HEARTBEAT.md` is checked every 30 minutes. You can manage periodic tasks by editing this file:
|
`HEARTBEAT.md` is checked every 30 minutes. You can manage periodic tasks by editing this file:
|
||||||
@ -299,114 +88,3 @@ Task format examples:
|
|||||||
```
|
```
|
||||||
|
|
||||||
When the user asks you to add a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time reminder. Keep the file small to minimize token usage.
|
When the user asks you to add a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time reminder. Keep the file small to minimize token usage.
|
||||||
|
|
||||||
## ⚠️ CRITICAL: Gitea API Access
|
|
||||||
|
|
||||||
**THIS REPOSITORY USES GITEA, NOT GITHUB. NEVER USE PLACEHOLDER URLS.**
|
|
||||||
|
|
||||||
When user asks about pull requests, issues, or Gitea API:
|
|
||||||
1. **ALWAYS detect the real Gitea URL from git remote first**
|
|
||||||
2. **NEVER use placeholder URLs like `gitea.example.com` or `https://gitea.example.com`**
|
|
||||||
3. **The correct Gitea API base is: `http://10.0.30.169:3000/api/v1`**
|
|
||||||
|
|
||||||
To access Gitea API:
|
|
||||||
|
|
||||||
1. **Detect Gitea URL from git remote:**
|
|
||||||
```bash
|
|
||||||
git remote get-url origin
|
|
||||||
# Returns: gitea@10.0.30.169:ilia/nanobot.git
|
|
||||||
# Extract host: 10.0.30.169
|
|
||||||
# API base: http://10.0.30.169:3000/api/v1
|
|
||||||
# Repo: ilia/nanobot
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Use the token from environment:**
|
|
||||||
```bash
|
|
||||||
TOKEN=$NANOBOT_GITLE_TOKEN
|
|
||||||
curl -H "Authorization: token $TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Or use the helper script:**
|
|
||||||
```bash
|
|
||||||
source workspace/get_gitea_info.sh
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"${GITEA_API_BASE}/repos/${GITEA_REPO}/pulls"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** Never use placeholder URLs like `gitea.example.com`. Always detect from git remote or use the actual host `10.0.30.169:3000`.
|
|
||||||
|
|
||||||
## 🚨 GITEA URL DETECTION (MANDATORY)
|
|
||||||
|
|
||||||
**BEFORE making any Gitea API call, you MUST:**
|
|
||||||
|
|
||||||
1. Run: `git remote get-url origin`
|
|
||||||
- This returns: `gitea@10.0.30.169:ilia/nanobot.git`
|
|
||||||
|
|
||||||
2. Extract the host: `10.0.30.169`
|
|
||||||
- Command: `git remote get-url origin | sed 's/.*@\([^:]*\).*/\1/'`
|
|
||||||
|
|
||||||
3. Extract the repo: `ilia/nanobot`
|
|
||||||
- Command: `git remote get-url origin | sed 's/.*:\(.*\)\.git/\1/'`
|
|
||||||
|
|
||||||
4. Construct API URL: `http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/...`
|
|
||||||
|
|
||||||
**Example correct command (MUST use $NANOBOT_GITLE_TOKEN variable):**
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
```
|
|
||||||
|
|
||||||
**CRITICAL**: Always use `$NANOBOT_GITLE_TOKEN` in the curl command. The token is automatically loaded from `.env` file into the environment when nanobot starts. Do NOT hardcode the token value.
|
|
||||||
|
|
||||||
**WRONG (never use):**
|
|
||||||
- `https://gitea.example.com/api/...` ❌
|
|
||||||
- `https://gitea.example.com/ap...` ❌
|
|
||||||
- Any placeholder URL ❌
|
|
||||||
|
|
||||||
## Gitea API Token Usage
|
|
||||||
|
|
||||||
**MANDATORY**: When making Gitea API calls, you **MUST** include the Authorization header with the token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ✅ CORRECT - includes Authorization header with token
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
|
|
||||||
# ❌ WRONG - missing Authorization header (will get 401 error)
|
|
||||||
curl -X GET "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
|
|
||||||
# ❌ WRONG - missing token in header
|
|
||||||
curl "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
```
|
|
||||||
|
|
||||||
**CRITICAL RULES:**
|
|
||||||
1. **ALWAYS** include `-H "Authorization: token $NANOBOT_GITLE_TOKEN"` in curl commands
|
|
||||||
2. **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP, not HTTPS
|
|
||||||
3. The token is automatically loaded from `.env` file when nanobot starts
|
|
||||||
4. The `$NANOBOT_GITLE_TOKEN` environment variable is available in all exec commands
|
|
||||||
5. Without the Authorization header, you will get a 401 Unauthorized error
|
|
||||||
6. Using `https://` will cause SSL/TLS errors - always use `http://10.0.30.169:3000`
|
|
||||||
|
|
||||||
## Direct Gitea API Execution
|
|
||||||
|
|
||||||
When user asks to "list PRs" or "show issues" using Gitea API:
|
|
||||||
|
|
||||||
**DO THIS:**
|
|
||||||
1. Run the curl command directly (no web_search needed)
|
|
||||||
2. Use the exact command format below
|
|
||||||
3. Parse and display the results
|
|
||||||
|
|
||||||
**Example - List PRs:**
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls" | \
|
|
||||||
python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO NOT:**
|
|
||||||
- Use web_search to find API documentation
|
|
||||||
- Search for "how to use Gitea API"
|
|
||||||
- Ask for clarification - just execute the command
|
|
||||||
|
|
||||||
You already have all the information you need in this file. Just execute the curl command.
|
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
# Gitea API Quick Reference
|
|
||||||
|
|
||||||
**CRITICAL: This repository uses Gitea, NOT GitHub. Never use placeholder URLs.**
|
|
||||||
|
|
||||||
## Correct Gitea API Information
|
|
||||||
|
|
||||||
- **API Base URL**: `http://10.0.30.169:3000/api/v1`
|
|
||||||
- **Repository**: `ilia/nanobot`
|
|
||||||
- **Token**: Available in `$NANOBOT_GITLE_TOKEN` environment variable
|
|
||||||
|
|
||||||
## How to Detect (if needed)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get git remote
|
|
||||||
REMOTE=$(git remote get-url origin)
|
|
||||||
# Returns: gitea@10.0.30.169:ilia/nanobot.git
|
|
||||||
|
|
||||||
# Extract host (remove gitea@ and :repo.git)
|
|
||||||
HOST=$(echo "$REMOTE" | sed 's/.*@\([^:]*\).*/\1/')
|
|
||||||
# Returns: 10.0.30.169
|
|
||||||
|
|
||||||
# Extract repo path
|
|
||||||
REPO=$(echo "$REMOTE" | sed 's/.*:\(.*\)\.git/\1/')
|
|
||||||
# Returns: ilia/nanobot
|
|
||||||
|
|
||||||
# API base (Gitea runs on port 3000)
|
|
||||||
API_BASE="http://${HOST}:3000/api/v1"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example API Calls
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List pull requests
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
|
|
||||||
# List open issues
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
|
|
||||||
|
|
||||||
# Get repository info
|
|
||||||
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot"
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO NOT USE**: `gitea.example.com` or any placeholder URLs. Always use `10.0.30.169:3000`.
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# Gitea Configuration
|
|
||||||
|
|
||||||
## API Information
|
|
||||||
|
|
||||||
- **Gitea API Base URL**: `http://10.0.30.169:3000/api/v1`
|
|
||||||
- **Repository**: `ilia/nanobot`
|
|
||||||
- **Token Environment Variable**: `NANOBOT_GITLE_TOKEN`
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
When making Gitea API calls, use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get token from environment
|
|
||||||
TOKEN=$NANOBOT_GITLE_TOKEN
|
|
||||||
|
|
||||||
# List open issues
|
|
||||||
curl -H "Authorization: token $TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
|
|
||||||
|
|
||||||
# List pull requests
|
|
||||||
curl -H "Authorization: token $TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
|
|
||||||
|
|
||||||
# Get repository info
|
|
||||||
curl -H "Authorization: token $TOKEN" \
|
|
||||||
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Detecting Repository Info
|
|
||||||
|
|
||||||
You can detect the repository from git remote:
|
|
||||||
```bash
|
|
||||||
# Get repo path (owner/repo)
|
|
||||||
git remote get-url origin | sed 's/.*:\(.*\)\.git/\1/'
|
|
||||||
|
|
||||||
# Gitea host is: 10.0.30.169:3000
|
|
||||||
# API base: http://10.0.30.169:3000/api/v1
|
|
||||||
@ -42,14 +42,6 @@ exec(command: str, working_dir: str = None) -> str
|
|||||||
- Output is truncated at 10,000 characters
|
- Output is truncated at 10,000 characters
|
||||||
- Optional `restrictToWorkspace` config to limit paths
|
- Optional `restrictToWorkspace` config to limit paths
|
||||||
|
|
||||||
**Git Commands:**
|
|
||||||
- **ALWAYS use exec for git commands** (git commit, git push, git status, etc.)
|
|
||||||
- **NEVER use write_file or edit_file for git commands**
|
|
||||||
- Examples:
|
|
||||||
- `exec(command="git commit -m 'Fix bug'")`
|
|
||||||
- `exec(command="git status")`
|
|
||||||
- `exec(command="git push")`
|
|
||||||
|
|
||||||
## Web Access
|
## Web Access
|
||||||
|
|
||||||
### web_search
|
### web_search
|
||||||
@ -71,26 +63,6 @@ web_fetch(url: str, extractMode: str = "markdown", maxChars: int = 50000) -> str
|
|||||||
- Supports markdown or plain text extraction
|
- Supports markdown or plain text extraction
|
||||||
- Output is truncated at 50,000 characters by default
|
- Output is truncated at 50,000 characters by default
|
||||||
|
|
||||||
## Email
|
|
||||||
|
|
||||||
### read_emails
|
|
||||||
Read emails from your configured email account via IMAP. **ALWAYS use this tool for email queries - NEVER use exec with mail commands.**
|
|
||||||
```
|
|
||||||
read_emails(limit: int = 10, unread_only: bool = False, mark_seen: bool = False) -> str
|
|
||||||
```
|
|
||||||
|
|
||||||
**CRITICAL:** For ANY question about emails (latest email, email sender, email content, etc.), you MUST use this tool. Do NOT use `exec` with `mail` command or read memory files for email information. This tool connects directly to IMAP and fetches CURRENT, real-time email data.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `limit`: Maximum number of emails to return (1-50, default: 10)
|
|
||||||
- `unread_only`: If true, only return unread emails (default: false)
|
|
||||||
- `mark_seen`: If true, mark emails as read after fetching (default: false)
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- `read_emails(limit=1)` - Get the latest email
|
|
||||||
- `read_emails(unread_only=true)` - Get all unread emails
|
|
||||||
- `read_emails(limit=5, mark_seen=false)` - Get last 5 emails without marking as read
|
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
|
|
||||||
### message
|
### message
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Helper script to get Gitea API information from git remote
|
|
||||||
|
|
||||||
REMOTE=$(git remote get-url origin 2>/dev/null)
|
|
||||||
if [ -z "$REMOTE" ]; then
|
|
||||||
echo "Error: No git remote found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract host (assuming format: gitea@HOST:repo.git or ssh://gitea@HOST/repo.git)
|
|
||||||
if [[ $REMOTE == *"@"* ]]; then
|
|
||||||
HOST=$(echo "$REMOTE" | sed 's/.*@\([^:]*\).*/\1/')
|
|
||||||
else
|
|
||||||
HOST=$(echo "$REMOTE" | sed 's|.*://\([^/]*\).*|\1|')
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract repo path (owner/repo)
|
|
||||||
REPO=$(echo "$REMOTE" | sed 's/.*:\(.*\)\.git/\1/' | sed 's|.*/\(.*/.*\)|\1|')
|
|
||||||
|
|
||||||
# Gitea typically runs on port 3000
|
|
||||||
API_BASE="http://${HOST}:3000/api/v1"
|
|
||||||
|
|
||||||
echo "GITEA_HOST=${HOST}"
|
|
||||||
echo "GITEA_REPO=${REPO}"
|
|
||||||
echo "GITEA_API_BASE=${API_BASE}"
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Gitea API helper script - ALWAYS uses HTTP (not HTTPS)
|
|
||||||
|
|
||||||
API_BASE="http://10.0.30.169:3000/api/v1"
|
|
||||||
REPO="ilia/nanobot"
|
|
||||||
TOKEN="${NANOBOT_GITLE_TOKEN}"
|
|
||||||
|
|
||||||
if [ -z "$TOKEN" ]; then
|
|
||||||
echo "Error: NANOBOT_GITLE_TOKEN not set"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$1" in
|
|
||||||
prs|pulls)
|
|
||||||
curl -s -H "Authorization: token $TOKEN" \
|
|
||||||
"${API_BASE}/repos/${REPO}/pulls"
|
|
||||||
;;
|
|
||||||
issues)
|
|
||||||
curl -s -H "Authorization: token $TOKEN" \
|
|
||||||
"${API_BASE}/repos/${REPO}/issues?state=${2:-open}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 {prs|pulls|issues} [state]"
|
|
||||||
echo "Example: $0 prs"
|
|
||||||
echo "Example: $0 issues open"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
Loading…
x
Reference in New Issue
Block a user