mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6ace16ef | ||
|
|
c78421cf16 | ||
|
|
03be51ade5 | ||
|
|
c757c5466c | ||
|
|
5f4cfbcb16 | ||
|
|
f6d1dba32a | ||
|
|
2ec4044217 | ||
|
|
a6d5e4f3b5 | ||
|
|
ed48325346 | ||
|
|
56443ac6e2 | ||
|
|
21aa900d64 | ||
|
|
b0258e8b20 | ||
|
|
8493560976 | ||
|
|
8d2c31eb6a | ||
|
|
a6a489e0fa | ||
|
|
840ba5af33 | ||
|
|
3403b87641 | ||
|
|
bfbae5a7b3 | ||
|
|
58cce14a07 | ||
|
|
f9b02496c8 | ||
|
|
bfc2a74e4f | ||
|
|
780093d037 | ||
|
|
1873e948c3 | ||
|
|
735a243849 | ||
|
|
edada598c8 | ||
|
|
2527ce5de9 | ||
|
|
44a5ed1bc0 | ||
|
|
4c0e9b9f46 | ||
|
|
839d1ecfb1 | ||
|
|
593b328dbb | ||
|
|
4beca25ceb | ||
|
|
82ffce1474 | ||
|
|
4726ca0478 | ||
|
|
d979597361 | ||
|
|
070aed8ade | ||
|
|
8df100203c | ||
|
|
8fa9eed6a8 | ||
|
|
5692f7a68a | ||
|
|
57f0c859fc | ||
|
|
40282e3b74 | ||
|
|
dacc699293 | ||
|
|
c8638dee46 | ||
|
|
7dc45ff94e | ||
|
|
8248d075db | ||
|
|
d7152cdbdd | ||
|
|
89dc34df88 | ||
|
|
67ce6822ca | ||
|
|
194e9d5f5f | ||
|
|
5005bca353 | ||
|
|
e5dbb15c34 | ||
|
|
c90e433057 | ||
|
|
3ce77633c0 | ||
|
|
00a907c493 | ||
|
|
cf2f589615 | ||
|
|
463f536750 | ||
|
|
00a7de0171 | ||
|
|
efb792ff24 | ||
|
|
d8601478db | ||
|
|
66fc54421c | ||
|
|
6a27c26257 | ||
|
|
3ca82ea880 | ||
|
|
47dcc61e9b | ||
|
|
2bf111f456 | ||
|
|
aa6c1bf300 | ||
|
|
5281e67222 | ||
|
|
dbb53109f4 | ||
|
|
b015515f30 | ||
|
|
be88e14424 | ||
|
|
9e490ef473 | ||
|
|
bfb4246659 | ||
|
|
fbf96a3502 | ||
|
|
2a9e288dfe | ||
|
|
3460ca3cb9 | ||
|
|
9b45fc1172 | ||
|
|
8656549129 | ||
|
|
4c1f127549 | ||
|
|
13c951aa41 | ||
|
|
cd1fb61eb5 | ||
|
|
e79cb816e3 | ||
|
|
64901be67f | ||
|
|
9ce9d2235a | ||
|
|
06d5495b60 | ||
|
|
851a0ff50c | ||
|
|
3596ccf828 | ||
|
|
d1ae73a8a8 | ||
|
|
c661012754 | ||
|
|
0e19ea3062 | ||
|
|
ceae6d7b61 | ||
|
|
34f776b48b | ||
|
|
f7b027a295 | ||
|
|
6c880a6691 | ||
|
|
4636c78100 | ||
|
|
28c8c89a42 | ||
|
|
7899857201 | ||
|
|
9354b80a6e | ||
|
|
638af123ba | ||
|
|
42aa37cfc0 | ||
|
|
03302c751f | ||
|
|
246ea8ef61 | ||
|
|
f60b3c7920 | ||
|
|
e92899607a | ||
|
|
c930aa3713 | ||
|
|
4378944459 | ||
|
|
123384975e | ||
|
|
943191f0c0 | ||
|
|
c915e98c15 | ||
|
|
de4009efbd | ||
|
|
9c6eaf0bed | ||
|
|
c66a0217d2 | ||
|
|
319791cd10 | ||
|
|
a584ffe92d | ||
|
|
1e22932313 | ||
|
|
98dd883ce8 | ||
|
|
35bd1be109 | ||
|
|
596bf5398c | ||
|
|
523bb928bf | ||
|
|
f9afc9389b | ||
|
|
9d6c606cc9 | ||
|
|
44817b75c6 | ||
|
|
c55f7ec5bb | ||
|
|
d7f868b832 | ||
|
|
d481d5fb1a | ||
|
|
bc1df49201 | ||
|
|
7826f8f89c | ||
|
|
b14f82f408 | ||
|
|
6d989de336 | ||
|
|
ddad6c5a7c | ||
|
|
4b1decdb95 | ||
|
|
23dc253f89 | ||
|
|
0e9861558a | ||
|
|
853aecdb97 | ||
|
|
6b8e832ba5 | ||
|
|
6689e2d377 | ||
|
|
160cec2396 | ||
|
|
d2da6df14e | ||
|
|
701ae5563d |
@@ -44,10 +44,38 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Lint with ruff
|
||||
run: uv run ruff check nanobot --select F
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest tests/
|
||||
run: uv run python -m pytest tests/ --cov=nanobot --cov-report=term-missing:skip-covered
|
||||
|
||||
webui:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Install WebUI dependencies
|
||||
working-directory: webui
|
||||
run: bun install
|
||||
|
||||
- name: Lint WebUI
|
||||
working-directory: webui
|
||||
run: bun run lint
|
||||
|
||||
- name: Test WebUI
|
||||
working-directory: webui
|
||||
run: bun run test
|
||||
|
||||
- name: Build WebUI
|
||||
working-directory: webui
|
||||
run: bun run build
|
||||
|
||||
@@ -41,7 +41,6 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
|
||||
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
|
||||
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
|
||||
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
|
||||
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
|
||||
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
|
||||
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
|
||||
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
|
||||
|
||||
+15
-22
@@ -1,15 +1,16 @@
|
||||
FROM node:24-bookworm-slim AS webui-builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY webui/package.json webui/package-lock.json ./webui/
|
||||
WORKDIR /app/webui
|
||||
RUN npm ci
|
||||
COPY webui/ ./
|
||||
RUN mkdir -p /app/nanobot/web && npm run build
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
||||
|
||||
# Install Node.js 20 for the WhatsApp bridge
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap openssh-client && \
|
||||
mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
apt-get purge -y gnupg && \
|
||||
apt-get autoremove -y && \
|
||||
apt-get install -y --no-install-recommends ca-certificates git bubblewrap openssh-client libmagic1 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -17,22 +18,14 @@ WORKDIR /app
|
||||
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
||||
# hook from hatch_build.py even for this metadata-only install.
|
||||
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
||||
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
||||
uv pip install --system --no-cache . && \
|
||||
rm -rf nanobot bridge
|
||||
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
|
||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]" && \
|
||||
rm -rf nanobot
|
||||
|
||||
# Copy the full source and install
|
||||
COPY nanobot/ nanobot/
|
||||
COPY bridge/ bridge/
|
||||
COPY webui/ webui/
|
||||
RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache .
|
||||
|
||||
# Build the WhatsApp bridge
|
||||
WORKDIR /app/bridge
|
||||
RUN git config --global --add url."https://github.com/".insteadOf ssh://git@github.com/ && \
|
||||
git config --global --add url."https://github.com/".insteadOf git@github.com: && \
|
||||
npm install && npm run build
|
||||
WORKDIR /app
|
||||
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
|
||||
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[whatsapp]"
|
||||
|
||||
# Create non-root user and config directory
|
||||
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
|
||||
## 📢 News
|
||||
|
||||
- **2026-06-22** 🚀 Released **v0.2.2** — **The Durability Release** makes nanobot sturdier for daily agent work: segmented WebUI transcripts, first-class Python SDK runtime controls, automation management, richer search/STT providers, and stronger gateway/session/provider reliability. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2) for details.
|
||||
- **2026-06-21** 🧰 Python SDK runtime controls, optional Keenable key, cleaner run hooks.
|
||||
- **2026-06-20** 💬 Telegram rich messages, safer SDK concurrency, smoother Quick Start.
|
||||
- **2026-06-19** 🔎 Firecrawl app, OpenAI image edits, safer session deletion.
|
||||
- **2026-06-18** 💬 Feishu recovery, Keenable search, Mistral polish, workspace-aware git.
|
||||
@@ -64,12 +66,12 @@
|
||||
- **2026-06-15** 📱 Mobile WebUI polish, optional file tools, real API usage.
|
||||
- **2026-06-14** 🖼️ Themed cover, partner links, stronger Codex image streaming.
|
||||
- **2026-06-13** 🗓️ Session-bound automations, sturdier WhatsApp, faster WebUI startup.
|
||||
- **2026-06-12** 💬 Slack allowlisted channels can require mentions.
|
||||
- **2026-06-11** ✂️ Fenced-code message splitting.
|
||||
|
||||
<details>
|
||||
<summary>Earlier news</summary>
|
||||
|
||||
- **2026-06-12** 💬 Slack allowlisted channels can require mentions.
|
||||
- **2026-06-11** ✂️ Fenced-code message splitting.
|
||||
- **2026-06-10** 📜 Segmented transcripts, Exa/Bocha search, StepFun/SiliconFlow ASR.
|
||||
- **2026-06-09** 🎙️ Shared voice input, more STT providers, TeX and email polish.
|
||||
- **2026-06-08** 🧮 Token heatmap fix, safer MCP HTTP probing, docs cleanup.
|
||||
|
||||
+8
-16
@@ -48,7 +48,7 @@ chmod 600 ~/.nanobot/config.json
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["+1234567890"]
|
||||
"allowFrom": ["1234567890"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ chmod 600 ~/.nanobot/config.json
|
||||
**Security Notes:**
|
||||
- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
|
||||
- Get your Telegram user ID from `@userinfobot`
|
||||
- Use full phone numbers with country code for WhatsApp
|
||||
- Use WhatsApp sender IDs as full phone numbers with country code and no leading `+`
|
||||
- Review access logs regularly for unauthorized access attempts
|
||||
|
||||
### 3. Shell Command Execution
|
||||
@@ -107,12 +107,12 @@ File operations have path traversal protection, but:
|
||||
**API Calls:**
|
||||
- All external API calls use HTTPS by default
|
||||
- Timeouts are configured to prevent hanging requests
|
||||
- The OpenAI-compatible API server must set `api.api_key` when binding to `0.0.0.0` or `::`; otherwise startup fails to prevent unauthenticated network access
|
||||
- Consider using a firewall to restrict outbound connections if needed
|
||||
|
||||
**WhatsApp Bridge:**
|
||||
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
|
||||
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
|
||||
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
|
||||
**WhatsApp:**
|
||||
- Keep the neonize session database under `~/.nanobot/whatsapp-auth` secure (mode 0700).
|
||||
- Use `nanobot channels login whatsapp --force` to remove and recreate the local session database when rotating linked devices.
|
||||
|
||||
### 6. Dependency Security
|
||||
|
||||
@@ -127,17 +127,9 @@ pip-audit
|
||||
pip install --upgrade nanobot-ai
|
||||
```
|
||||
|
||||
For Node.js dependencies (WhatsApp bridge):
|
||||
```bash
|
||||
cd bridge
|
||||
npm audit
|
||||
npm audit fix
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Keep `litellm` updated to the latest version for security fixes
|
||||
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability
|
||||
- Run `pip-audit` or `npm audit` regularly
|
||||
- Run `pip-audit` regularly, including optional channel dependencies such as `nanobot-ai[whatsapp]`
|
||||
- Subscribe to security advisories for nanobot and its dependencies
|
||||
|
||||
### 7. Production Deployment
|
||||
@@ -238,7 +230,7 @@ If you suspect a security breach:
|
||||
✅ **Secure Communication**
|
||||
- HTTPS for all external API calls
|
||||
- TLS for Telegram API
|
||||
- WhatsApp bridge: localhost-only binding + optional token auth
|
||||
- WhatsApp session secrets stay in the local session database
|
||||
|
||||
## Known Limitations
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "nanobot-whatsapp-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "WhatsApp bridge for nanobot using Baileys",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsc && node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"ws": "^8.17.1",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"pino": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* nanobot WhatsApp Bridge
|
||||
*
|
||||
* This bridge connects WhatsApp Web to nanobot's Python backend
|
||||
* via WebSocket. It handles authentication, message forwarding,
|
||||
* and reconnection logic.
|
||||
*
|
||||
* Usage:
|
||||
* npm run build && npm start
|
||||
*
|
||||
* Or with custom settings:
|
||||
* BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
|
||||
*/
|
||||
|
||||
// Polyfill crypto for Baileys in ESM
|
||||
import { webcrypto } from 'crypto';
|
||||
if (!globalThis.crypto) {
|
||||
(globalThis as any).crypto = webcrypto;
|
||||
}
|
||||
|
||||
import { BridgeServer } from './server.js';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
|
||||
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
|
||||
const TOKEN = process.env.BRIDGE_TOKEN?.trim();
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('BRIDGE_TOKEN is required. Start the bridge via nanobot so it can provision a local secret automatically.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🐈 nanobot WhatsApp Bridge');
|
||||
console.log('========================\n');
|
||||
|
||||
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n\nShutting down...');
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
server.start().catch((error) => {
|
||||
console.error('Failed to start bridge:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* WebSocket server for Python-Node.js bridge communication.
|
||||
* Security: binds to 127.0.0.1 only; requires BRIDGE_TOKEN auth; rejects browser Origin headers.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { WhatsAppClient, InboundMessage } from './whatsapp.js';
|
||||
|
||||
interface SendCommand {
|
||||
type: 'send';
|
||||
to: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface SendMediaCommand {
|
||||
type: 'send_media';
|
||||
to: string;
|
||||
filePath: string;
|
||||
mimetype: string;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
type BridgeCommand = SendCommand | SendMediaCommand;
|
||||
|
||||
interface BridgeMessage {
|
||||
type: 'message' | 'status' | 'qr' | 'error';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class BridgeServer {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private wa: WhatsAppClient | null = null;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
|
||||
constructor(private port: number, private authDir: string, private token: string) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!this.token.trim()) {
|
||||
throw new Error('BRIDGE_TOKEN is required');
|
||||
}
|
||||
|
||||
// Bind to localhost only — never expose to external network
|
||||
this.wss = new WebSocketServer({
|
||||
host: '127.0.0.1',
|
||||
port: this.port,
|
||||
verifyClient: (info, done) => {
|
||||
const origin = info.origin || info.req.headers.origin;
|
||||
if (origin) {
|
||||
console.warn(`Rejected WebSocket connection with Origin header: ${origin}`);
|
||||
done(false, 403, 'Browser-originated WebSocket connections are not allowed');
|
||||
return;
|
||||
}
|
||||
done(true);
|
||||
},
|
||||
});
|
||||
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
|
||||
console.log('🔒 Token authentication enabled');
|
||||
|
||||
// Initialize WhatsApp client
|
||||
this.wa = new WhatsAppClient({
|
||||
authDir: this.authDir,
|
||||
onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
|
||||
onQR: (qr) => this.broadcast({ type: 'qr', qr }),
|
||||
onStatus: (status) => this.broadcast({ type: 'status', status }),
|
||||
});
|
||||
|
||||
// Handle WebSocket connections
|
||||
this.wss.on('connection', (ws) => {
|
||||
// Require auth handshake as first message
|
||||
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'auth' && msg.token === this.token) {
|
||||
console.log('🔗 Python client authenticated');
|
||||
this.setupClient(ws);
|
||||
} else {
|
||||
ws.close(4003, 'Invalid token');
|
||||
}
|
||||
} catch {
|
||||
ws.close(4003, 'Invalid auth message');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Connect to WhatsApp
|
||||
await this.wa.connect();
|
||||
}
|
||||
|
||||
private setupClient(ws: WebSocket): void {
|
||||
this.clients.add(ws);
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const cmd = JSON.parse(data.toString()) as BridgeCommand;
|
||||
await this.handleCommand(cmd);
|
||||
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
|
||||
} catch (error) {
|
||||
console.error('Error handling command:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Python client disconnected');
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCommand(cmd: BridgeCommand): Promise<void> {
|
||||
if (!this.wa) return;
|
||||
|
||||
if (cmd.type === 'send') {
|
||||
await this.wa.sendMessage(cmd.to, cmd.text);
|
||||
} else if (cmd.type === 'send_media') {
|
||||
await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(msg: BridgeMessage): void {
|
||||
const data = JSON.stringify(msg);
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// Close all client connections
|
||||
for (const client of this.clients) {
|
||||
client.close();
|
||||
}
|
||||
this.clients.clear();
|
||||
|
||||
// Close WebSocket server
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
// Disconnect WhatsApp
|
||||
if (this.wa) {
|
||||
await this.wa.disconnect();
|
||||
this.wa = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
declare module 'qrcode-terminal' {
|
||||
export function generate(text: string, options?: { small?: boolean }): void;
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
/**
|
||||
* WhatsApp client wrapper using Baileys.
|
||||
* Based on OpenClaw's working implementation.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
useMultiFileAuthState,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
downloadMediaMessage,
|
||||
extractMessageContent as baileysExtractMessageContent,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
import { Boom } from '@hapi/boom';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import pino from 'pino';
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { join, basename, resolve, sep } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const VERSION = '0.1.0';
|
||||
|
||||
export interface InboundMessage {
|
||||
id: string;
|
||||
sender: string;
|
||||
pn: string;
|
||||
participant?: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isGroup: boolean;
|
||||
isForwarded?: boolean;
|
||||
wasMentioned?: boolean;
|
||||
isReplyToBot?: boolean;
|
||||
media?: string[];
|
||||
}
|
||||
|
||||
export interface WhatsAppClientOptions {
|
||||
authDir: string;
|
||||
onMessage: (msg: InboundMessage) => void;
|
||||
onQR: (qr: string) => void;
|
||||
onStatus: (status: string) => void;
|
||||
}
|
||||
|
||||
export class WhatsAppClient {
|
||||
private sock: any = null;
|
||||
private options: WhatsAppClientOptions;
|
||||
private reconnecting = false;
|
||||
|
||||
constructor(options: WhatsAppClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private normalizeJid(jid: string | undefined | null): string {
|
||||
return (jid || '').trim().toLowerCase().replace(/:\d+(?=@)/g, '');
|
||||
}
|
||||
|
||||
private selfJids(): Set<string> {
|
||||
return new Set(
|
||||
[this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
|
||||
.map((jid) => this.normalizeJid(jid))
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
private messageContextInfos(msg: any): any[] {
|
||||
const unwrapped = baileysExtractMessageContent(msg?.message);
|
||||
const containers = [msg?.message, unwrapped];
|
||||
const infos = containers.flatMap((message) => [
|
||||
message?.extendedTextMessage?.contextInfo,
|
||||
message?.imageMessage?.contextInfo,
|
||||
message?.videoMessage?.contextInfo,
|
||||
message?.documentMessage?.contextInfo,
|
||||
message?.audioMessage?.contextInfo,
|
||||
]);
|
||||
return infos.filter(Boolean);
|
||||
}
|
||||
|
||||
private botAddressing(msg: any): { wasMentioned: boolean; isReplyToBot: boolean } {
|
||||
if (!msg?.key?.remoteJid?.endsWith('@g.us')) {
|
||||
return { wasMentioned: false, isReplyToBot: false };
|
||||
}
|
||||
|
||||
const selfIds = this.selfJids();
|
||||
const contextInfos = this.messageContextInfos(msg);
|
||||
|
||||
const mentioned = contextInfos.flatMap((info) => (
|
||||
Array.isArray(info?.mentionedJid) ? info.mentionedJid : []
|
||||
));
|
||||
const wasMentioned = mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
|
||||
|
||||
const isReplyToBot = contextInfos.some((info) => {
|
||||
const quotedParticipant = this.normalizeJid(info?.participant);
|
||||
return Boolean(info?.stanzaId && quotedParticipant && selfIds.has(quotedParticipant));
|
||||
});
|
||||
|
||||
return { wasMentioned, isReplyToBot };
|
||||
}
|
||||
|
||||
private isForwarded(msg: any): boolean {
|
||||
return this.messageContextInfos(msg).some((info) => Boolean(info?.isForwarded));
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const logger = pino({ level: 'silent' });
|
||||
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
|
||||
console.log(`Using Baileys version: ${version.join('.')}`);
|
||||
|
||||
// Record startup time — messages older than this will be ignored
|
||||
// to avoid replaying history on reconnect
|
||||
const startupTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Create socket following OpenClaw's pattern
|
||||
this.sock = makeWASocket({
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||
},
|
||||
version,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
browser: ['nanobot', 'cli', VERSION],
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
});
|
||||
|
||||
// Handle WebSocket errors
|
||||
if (this.sock.ws && typeof this.sock.ws.on === 'function') {
|
||||
this.sock.ws.on('error', (err: Error) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle connection updates
|
||||
this.sock.ev.on('connection.update', async (update: any) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
// Display QR code in terminal
|
||||
console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
|
||||
qrcode.generate(qr, { small: true });
|
||||
this.options.onQR(qr);
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
||||
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
||||
|
||||
console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
|
||||
this.options.onStatus('disconnected');
|
||||
|
||||
if (shouldReconnect && !this.reconnecting) {
|
||||
this.reconnecting = true;
|
||||
console.log('Reconnecting in 5 seconds...');
|
||||
setTimeout(() => {
|
||||
this.reconnecting = false;
|
||||
this.connect();
|
||||
}, 5000);
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
console.log('✅ Connected to WhatsApp');
|
||||
this.options.onStatus('connected');
|
||||
}
|
||||
});
|
||||
|
||||
// Save credentials on update
|
||||
this.sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
// Handle incoming messages
|
||||
this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.key.fromMe) continue;
|
||||
if (msg.key.remoteJid === 'status@broadcast') continue;
|
||||
|
||||
// Drop messages older than startup time (avoid replaying history on reconnect)
|
||||
const msgTimestamp = msg.messageTimestamp as number;
|
||||
if (msgTimestamp && msgTimestamp < startupTimestamp) continue;
|
||||
|
||||
// Send read receipt (blue check) immediately
|
||||
try {
|
||||
await this.sock!.readMessages([msg.key]);
|
||||
} catch (e) {
|
||||
// Non-fatal: log but don't block message processing
|
||||
console.error('Failed to send read receipt:', (e as Error).message);
|
||||
}
|
||||
|
||||
const unwrapped = baileysExtractMessageContent(msg.message);
|
||||
if (!unwrapped) continue;
|
||||
|
||||
const content = this.getTextContent(unwrapped);
|
||||
let fallbackContent: string | null = null;
|
||||
const mediaPaths: string[] = [];
|
||||
|
||||
if (unwrapped.imageMessage) {
|
||||
fallbackContent = '[Image]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
|
||||
if (path) mediaPaths.push(path);
|
||||
} else if (unwrapped.documentMessage) {
|
||||
fallbackContent = '[Document]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
|
||||
unwrapped.documentMessage.fileName ?? undefined);
|
||||
if (path) mediaPaths.push(path);
|
||||
} else if (unwrapped.videoMessage) {
|
||||
fallbackContent = '[Video]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
|
||||
if (path) mediaPaths.push(path);
|
||||
} else if (unwrapped.audioMessage) {
|
||||
fallbackContent = '[Voice Message]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined);
|
||||
if (path) mediaPaths.push(path);
|
||||
} else if (unwrapped.contactMessage) {
|
||||
// Single shared contact
|
||||
const displayName = unwrapped.contactMessage.displayName || '';
|
||||
const vcard = unwrapped.contactMessage.vcard || '';
|
||||
fallbackContent = `[Contact: ${displayName}]\n${vcard}`;
|
||||
} else if (unwrapped.contactsArrayMessage) {
|
||||
// Multiple shared contacts
|
||||
const vcards = unwrapped.contactsArrayMessage.contacts || [];
|
||||
const parts = vcards.map((c: any) => {
|
||||
const name = c.displayName || '';
|
||||
const vc = c.vcard || '';
|
||||
return `[Contact: ${name}]\n${vc}`;
|
||||
});
|
||||
fallbackContent = parts.join('\n\n');
|
||||
}
|
||||
|
||||
const isForwarded = this.isForwarded(msg);
|
||||
|
||||
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
|
||||
if (!finalContent && mediaPaths.length === 0) continue;
|
||||
|
||||
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
|
||||
const { wasMentioned, isReplyToBot } = this.botAddressing(msg);
|
||||
|
||||
this.options.onMessage({
|
||||
id: msg.key.id || '',
|
||||
sender: msg.key.remoteJid || '',
|
||||
pn: msg.key.remoteJidAlt || '',
|
||||
...(isGroup && msg.key.participant ? { participant: msg.key.participant } : {}),
|
||||
content: finalContent,
|
||||
timestamp: msg.messageTimestamp as number,
|
||||
isGroup,
|
||||
...(isForwarded ? { isForwarded } : {}),
|
||||
...(isGroup ? { wasMentioned: wasMentioned || isReplyToBot, isReplyToBot } : {}),
|
||||
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
|
||||
try {
|
||||
const mediaDir = join(this.options.authDir, '..', 'media');
|
||||
await mkdir(mediaDir, { recursive: true });
|
||||
|
||||
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
|
||||
|
||||
let outFilename: string;
|
||||
if (fileName) {
|
||||
const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`;
|
||||
} else {
|
||||
const mime = mimetype || 'application/octet-stream';
|
||||
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
|
||||
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
|
||||
}
|
||||
|
||||
const filepath = resolve(mediaDir, outFilename);
|
||||
if (!filepath.startsWith(resolve(mediaDir) + sep)) {
|
||||
throw new Error(`Path traversal blocked: ${outFilename}`);
|
||||
}
|
||||
await writeFile(filepath, buffer);
|
||||
|
||||
return filepath;
|
||||
} catch (err) {
|
||||
console.error('Failed to download media:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getTextContent(message: any): string | null {
|
||||
// Text message
|
||||
if (message.conversation) {
|
||||
return message.conversation;
|
||||
}
|
||||
|
||||
// Extended text (reply, link preview)
|
||||
if (message.extendedTextMessage?.text) {
|
||||
return message.extendedTextMessage.text;
|
||||
}
|
||||
|
||||
// Image with optional caption
|
||||
if (message.imageMessage) {
|
||||
return message.imageMessage.caption || '';
|
||||
}
|
||||
|
||||
// Video with optional caption
|
||||
if (message.videoMessage) {
|
||||
return message.videoMessage.caption || '';
|
||||
}
|
||||
|
||||
// Document with optional caption
|
||||
if (message.documentMessage) {
|
||||
return message.documentMessage.caption || '';
|
||||
}
|
||||
|
||||
// Voice/Audio message
|
||||
if (message.audioMessage) {
|
||||
return `[Voice Message]`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async sendMessage(to: string, text: string): Promise<void> {
|
||||
if (!this.sock) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.sock.sendMessage(to, { text });
|
||||
}
|
||||
|
||||
async sendMedia(
|
||||
to: string,
|
||||
filePath: string,
|
||||
mimetype: string,
|
||||
caption?: string,
|
||||
fileName?: string,
|
||||
): Promise<void> {
|
||||
if (!this.sock) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath);
|
||||
const category = mimetype.split('/')[0];
|
||||
|
||||
if (category === 'image') {
|
||||
await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype });
|
||||
} else if (category === 'video') {
|
||||
await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype });
|
||||
} else if (category === 'audio') {
|
||||
await this.sock.sendMessage(to, { audio: buffer, mimetype });
|
||||
} else {
|
||||
const name = fileName || basename(filePath);
|
||||
await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name });
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.sock) {
|
||||
this.sock.end(undefined);
|
||||
this.sock = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -103,7 +103,8 @@ class WebhookChannel(BaseChannel):
|
||||
msg.content — markdown text (convert to platform format as needed)
|
||||
msg.media — list of local file paths to attach
|
||||
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
||||
msg.metadata — may contain "_progress": True for streaming chunks
|
||||
msg.metadata — channel routing context such as message/thread ids
|
||||
msg.event — typed runtime event for progress/status messages
|
||||
"""
|
||||
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
||||
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
||||
@@ -238,15 +239,15 @@ nanobot channels login <channel_name> --force # re-authenticate
|
||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||
| `is_running` | Returns `self._running`. |
|
||||
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
||||
| `send_reasoning_delta(chat_id, delta, metadata?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||
| `send_reasoning_end(chat_id, metadata?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||
|
||||
### Optional (streaming)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||
|
||||
### Message Types
|
||||
|
||||
@@ -257,10 +258,12 @@ class OutboundMessage:
|
||||
chat_id: str # recipient (same value you passed to _handle_message)
|
||||
content: str # markdown text — convert to platform format as needed
|
||||
media: list[str] # local file paths to attach (images, audio, docs)
|
||||
metadata: dict # may contain: "_progress" (bool) for streaming chunks,
|
||||
# "message_id" for reply threading
|
||||
metadata: dict # channel routing context, e.g. "message_id" for threading
|
||||
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
||||
```
|
||||
|
||||
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
||||
|
||||
## Streaming Support
|
||||
|
||||
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
||||
@@ -279,10 +282,18 @@ If either is missing, the agent falls back to the normal one-shot `send()` path.
|
||||
Override `send_delta` to handle two types of calls:
|
||||
|
||||
```python
|
||||
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
meta = metadata or {}
|
||||
|
||||
if meta.get("_stream_end"):
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
# Streaming finished — do final formatting, cleanup, etc.
|
||||
return
|
||||
|
||||
@@ -290,12 +301,7 @@ async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] |
|
||||
# delta contains a small chunk of text (a few tokens)
|
||||
```
|
||||
|
||||
**Metadata flags:**
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `_stream_delta: True` | A content chunk (delta contains the new text) |
|
||||
| `_stream_end: True` | Streaming finished (delta is empty) |
|
||||
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
||||
|
||||
### Example: Webhook with Streaming
|
||||
|
||||
@@ -310,18 +316,27 @@ class WebhookChannel(BaseChannel):
|
||||
super().__init__(config, bus)
|
||||
self._buffers: dict[str, str] = {}
|
||||
|
||||
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
meta = metadata or {}
|
||||
if meta.get("_stream_end"):
|
||||
text = self._buffers.pop(chat_id, "")
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
text = self._buffers.pop(buffer_key, "")
|
||||
# Final delivery — format and send the complete message
|
||||
await self._deliver(chat_id, text, final=True)
|
||||
return
|
||||
|
||||
self._buffers.setdefault(chat_id, "")
|
||||
self._buffers[chat_id] += delta
|
||||
self._buffers.setdefault(buffer_key, "")
|
||||
self._buffers[buffer_key] += delta
|
||||
# Incremental update — push partial text to the client
|
||||
await self._deliver(chat_id, self._buffers[chat_id], final=False)
|
||||
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
# Non-streaming path — unchanged
|
||||
@@ -350,7 +365,7 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||
|
||||
## Progress, Tool Hints, and Reasoning
|
||||
@@ -359,18 +374,20 @@ Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These
|
||||
|
||||
### Progress and Tool Hints
|
||||
|
||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.metadata` before rendering:
|
||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
||||
|
||||
```python
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
meta = msg.metadata or {}
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
|
||||
if meta.get("_tool_hint"):
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = msg.event
|
||||
|
||||
if isinstance(event, ProgressEvent) and event.tool_hint:
|
||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||
return
|
||||
|
||||
if meta.get("_progress"):
|
||||
if isinstance(event, ProgressEvent):
|
||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||
return
|
||||
@@ -412,32 +429,33 @@ class WebhookChannel(BaseChannel):
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
meta = metadata or {}
|
||||
stream_id = str(meta.get("_stream_id") or chat_id)
|
||||
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta
|
||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
|
||||
buffer_key = stream_id or chat_id
|
||||
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
||||
|
||||
async def send_reasoning_end(
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
meta = metadata or {}
|
||||
stream_id = str(meta.get("_stream_id") or chat_id)
|
||||
text = self._reasoning_buffers.pop(stream_id, "")
|
||||
buffer_key = stream_id or chat_id
|
||||
text = self._reasoning_buffers.pop(buffer_key, "")
|
||||
if text:
|
||||
await self._update_reasoning_block(chat_id, text, final=True)
|
||||
```
|
||||
|
||||
**Reasoning metadata flags:**
|
||||
**Reasoning arguments:**
|
||||
|
||||
| Flag | Meaning |
|
||||
| Argument | Meaning |
|
||||
|------|---------|
|
||||
| `_reasoning_delta: True` | A reasoning/thinking chunk; `delta` contains the new text. |
|
||||
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. |
|
||||
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. |
|
||||
| `_stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
||||
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||
| `send_reasoning_end()` | The current reasoning block is complete. |
|
||||
|
||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||
|
||||
|
||||
+34
-15
@@ -79,6 +79,8 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
|
||||
```
|
||||
|
||||
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
|
||||
>
|
||||
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
|
||||
|
||||
|
||||
**3. Run**
|
||||
@@ -301,9 +303,15 @@ nanobot gateway
|
||||
<details>
|
||||
<summary><b>WhatsApp</b></summary>
|
||||
|
||||
Requires **Node.js ≥18**.
|
||||
Requires the WhatsApp optional dependencies:
|
||||
|
||||
**1. Link device**
|
||||
```bash
|
||||
pip install "nanobot-ai[whatsapp]"
|
||||
# Source checkout:
|
||||
python -m pip install -e ".[whatsapp]"
|
||||
```
|
||||
|
||||
**1. Link device with QR**
|
||||
|
||||
```bash
|
||||
nanobot channels login whatsapp
|
||||
@@ -317,30 +325,41 @@ nanobot channels login whatsapp
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["+1234567890"]
|
||||
"allowFrom": ["1234567890"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run** (two terminals)
|
||||
Optional session database path:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
nanobot channels login whatsapp
|
||||
|
||||
# Terminal 2
|
||||
nanobot gateway
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"databasePath": "~/.nanobot/whatsapp-auth/neonize.db"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> WhatsApp bridge updates are not applied automatically for existing installations. After upgrading nanobot, rebuild the local bridge with:
|
||||
> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
|
||||
**Migrating from the old bridge**
|
||||
|
||||
- Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge.
|
||||
- Re-run `nanobot channels login whatsapp`; old Baileys bridge auth data is not reused by neonize.
|
||||
- Update `allowFrom` entries to the WhatsApp sender ID without a leading `+`.
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
**Optional: static LID mappings**
|
||||
|
||||
Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot
|
||||
learns the LID→phone mapping at runtime (and reuses the ones the bridge persists on
|
||||
disk), but you can also seed mappings up front so the phone number resolves from the
|
||||
learns LID to phone mappings at runtime when both identifiers are present, but you
|
||||
can also seed mappings up front so the phone number resolves from the
|
||||
very first message:
|
||||
|
||||
```json
|
||||
@@ -348,7 +367,7 @@ very first message:
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["+1234567890"],
|
||||
"allowFrom": ["1234567890"],
|
||||
"lidMappings": { "123456789012345": "1234567890" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,18 +57,20 @@ Preset names come from the top-level `modelPresets` config. Switching is runtime
|
||||
|
||||
## Periodic Tasks
|
||||
|
||||
Periodic tasks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers results to your most recently active chat channel. If there are no active tasks, the heartbeat is skipped silently.
|
||||
Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
||||
|
||||
Use heartbeat for recurring checks that should usually stay quiet. User-created cron jobs are different: they run as scheduled turns in the chat/session where they were created and normally deliver the result back to that channel.
|
||||
|
||||
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
|
||||
|
||||
```markdown
|
||||
## Active Tasks
|
||||
|
||||
- Check weather forecast and send a summary
|
||||
- Scan inbox for urgent emails
|
||||
- Check weather forecast and notify me only if storms are expected
|
||||
- Scan inbox for urgent emails and notify me if any are found
|
||||
```
|
||||
|
||||
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
|
||||
The agent can also manage this file itself - ask it to "add a periodic background check" or "check this periodically but only notify me if something changes" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
|
||||
|
||||
You can change the interval or disable the built-in heartbeat in `~/.nanobot/config.json`:
|
||||
|
||||
|
||||
+2
-2
@@ -136,9 +136,9 @@ When `nanobot gateway` starts, it creates workspace-scoped cron storage at `<wor
|
||||
- `dream`, when `agents.defaults.dream.enabled` is true;
|
||||
- `heartbeat`, when `gateway.heartbeat.enabled` is true.
|
||||
|
||||
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends useful results to the most recently active chat target.
|
||||
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends only useful/actionable results to the most recently active chat target. Routine "nothing changed" results are suppressed.
|
||||
|
||||
User-created reminders use the same cron service but are not the same as the protected heartbeat system job.
|
||||
User-created reminders use the same cron service but are not the same as the protected heartbeat system job. They run as scheduled turns in their origin chat/session and normally deliver the result back to that channel.
|
||||
|
||||
## Where to Go Next
|
||||
|
||||
|
||||
+183
-9
@@ -18,6 +18,7 @@ For setup and runtime failures, follow the diagnosis order in [`troubleshooting.
|
||||
| Need | Section |
|
||||
|---|---|
|
||||
| Keep secrets out of `config.json` | [Environment Variables for Secrets](#environment-variables-for-secrets) |
|
||||
| Tune process-level behavior with env vars | [Runtime Environment Variables](#runtime-environment-variables) |
|
||||
| Trace model calls | [Langfuse Observability](#langfuse-observability) |
|
||||
| Configure credentials and endpoints | [Providers](#providers) |
|
||||
| Name and switch model choices | [Model Presets](#model-presets) |
|
||||
@@ -47,6 +48,7 @@ If you are not sure where a setting belongs, start from the task you are trying
|
||||
| Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) |
|
||||
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
|
||||
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
|
||||
| Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
|
||||
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Start each process with explicit paths and run `nanobot status` for the default instance only | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
|
||||
| Observe model calls | `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL` environment variables | Run one model call, then check the matching Langfuse project | [Langfuse Observability](#langfuse-observability) |
|
||||
|
||||
@@ -159,6 +161,36 @@ ANTHROPIC_API_KEY="$(pass show api/anthropic)" nanobot agent
|
||||
ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
||||
```
|
||||
|
||||
## Runtime Environment Variables
|
||||
|
||||
These variables are process-level switches. Set them in the same terminal, service unit, container, or supervisor that starts nanobot.
|
||||
|
||||
### Runtime controls
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `NANOBOT_MAX_CONCURRENT_REQUESTS` | `3` | Maximum concurrently running inbound agent requests. Must be an integer; set `0` or a negative value for unlimited. |
|
||||
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds, around ordinary LLM requests. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
|
||||
| `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. |
|
||||
| `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. |
|
||||
| `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` | unset | Marks that an external workspace sandbox is already enforced. Truthy values (`1`, `true`, `yes`, `on`, `enabled`) use `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` as the label; any other non-false value is treated as the provider name. |
|
||||
| `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` | `unknown` | Display label for the external workspace sandbox when `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` is truthy, for example `macos_app_sandbox` or `bwrap`. |
|
||||
| `NANOBOT_SANDBOX_ENFORCED` | unset | Legacy compatibility alias for `NANOBOT_WORKSPACE_SANDBOX_ENFORCED`. |
|
||||
| `NANOBOT_TMUX_SOCKET_DIR` | `${TMPDIR:-/tmp}/nanobot-tmux-sockets` | Socket directory used by the bundled `tmux` skill scripts. |
|
||||
|
||||
### Installer, build, and WebUI development
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `NANOBOT_BIN_DIR` | `$HOME/.local/bin` | Installer launcher directory on macOS/Linux. |
|
||||
| `NANOBOT_VENV` | `$HOME/.nanobot/venv` | Managed virtual environment path used by the installer fallback. |
|
||||
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. |
|
||||
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
|
||||
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
|
||||
| `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. |
|
||||
|
||||
Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface.
|
||||
|
||||
## Langfuse Observability
|
||||
|
||||
nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK wrapper. This is configured with environment variables, not `config.json`.
|
||||
@@ -198,7 +230,9 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
|
||||
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
|
||||
> - **MiniMax thinking mode**: `providers.minimaxAnthropic` is the config block for `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
|
||||
> - **Kimi Coding Plan**: Use `providers.kimiCoding` with `provider: "kimi_coding"` for Kimi's dedicated Anthropic Messages API endpoint. The endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default, and you can override it with `extraHeaders.User-Agent` if your account requires a different value.
|
||||
> - **VolcEngine / BytePlus Coding Plan**: Subscription endpoints are configured through dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan`, separate from the pay-per-use `volcengine` / `byteplus` providers.
|
||||
> - **OpenCode Zen / Go**: `providers.opencodeZen` and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
|
||||
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
|
||||
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
|
||||
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
||||
@@ -206,11 +240,14 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
||||
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
||||
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
||||
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
| `custom` | Any OpenAI-compatible endpoint | — |
|
||||
| `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `opencode_zen` | LLM gateway (OpenCode Zen coding-agent models) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
||||
| `opencode_go` | LLM gateway (OpenCode Go low-cost coding models) | [opencode.ai/docs/go](https://opencode.ai/docs/go/) |
|
||||
| `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
|
||||
| `skywork` | LLM (Skywork / APIFree API gateway) | [apifree.ai](https://www.apifree.ai) |
|
||||
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
|
||||
@@ -232,6 +269,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||
| `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||
| `xiaomi_mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
|
||||
@@ -595,20 +633,37 @@ nanobot agent -m "Reply with one short sentence."
|
||||
<details>
|
||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. `nanobot provider login` stores the OAuth session outside config. A `providers.openai_codex` block is optional and is only needed for provider-specific settings such as a proxy.
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
nanobot provider login openai-codex
|
||||
```
|
||||
|
||||
**2. Set model** (merge into `~/.nanobot/config.json`):
|
||||
If the machine running nanobot cannot open a graphical browser, copy the printed URL into a real browser. For remote SSH login, open the URL locally, then paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted.
|
||||
|
||||
**2. Optional proxy** (merge into `~/.nanobot/config.json` if Codex OAuth or Codex API traffic must use a proxy):
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai_codex": {
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The proxy applies to Codex OAuth token refresh, interactive token exchange, and Codex Responses API requests. It does not affect other providers; configure `proxy` separately on each supported provider that needs it.
|
||||
|
||||
**3. Set model** (merge into `~/.nanobot/config.json`):
|
||||
```json
|
||||
{
|
||||
"modelPresets": {
|
||||
"codex": {
|
||||
"provider": "openai_codex",
|
||||
"model": "openai-codex/gpt-5.1-codex"
|
||||
"model": "gpt-5.1-codex",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
@@ -619,7 +674,9 @@ nanobot provider login openai-codex
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chat:**
|
||||
Use `reasoningEffort` in the preset to send a Codex reasoning effort such as `"low"`, `"medium"`, `"high"`, or another value supported by the selected model. When `provider` is explicitly `openai_codex`, the model name does not need the `openai-codex/` prefix.
|
||||
|
||||
**4. Chat:**
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
|
||||
@@ -638,7 +695,17 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
||||
<details>
|
||||
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
||||
|
||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
|
||||
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
||||
```bash
|
||||
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
||||
export NANOBOT_GITHUB_DEVICE_CODE_URL="https://ghe.example/login/device/code"
|
||||
export NANOBOT_GITHUB_ACCESS_TOKEN_URL="https://ghe.example/login/oauth/access_token"
|
||||
export NANOBOT_GITHUB_USER_URL="https://api.ghe.example/user"
|
||||
export NANOBOT_COPILOT_TOKEN_URL="https://api.ghe.example/copilot_internal/v2/token"
|
||||
export NANOBOT_COPILOT_BASE_URL="https://copilot-api.ghe.example"
|
||||
```
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
@@ -677,6 +744,72 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenCode Zen / Go</b></summary>
|
||||
|
||||
OpenCode Zen and OpenCode Go are available through nanobot's built-in
|
||||
OpenAI-compatible provider flow. They share the `OPENCODE_API_KEY` environment
|
||||
variable, but use separate provider keys and default base URLs:
|
||||
|
||||
| Provider | Default API base | Model prefix accepted by nanobot |
|
||||
|----------|------------------|-----------------------------------|
|
||||
| `opencode_zen` | `https://opencode.ai/zen/v1` | `opencode/<model-id>` |
|
||||
| `opencode_go` | `https://opencode.ai/zen/go/v1` | `opencode-go/<model-id>` |
|
||||
|
||||
OpenCode Zen:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeZen": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"opencodeZen": {
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "opencodeZen"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode Go:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeGo": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"opencodeGo": {
|
||||
"provider": "opencode_go",
|
||||
"model": "opencode-go/deepseek-v4-flash"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "opencodeGo"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode's own docs list models across `responses`, `messages`,
|
||||
provider-specific model endpoints, and `chat/completions`. nanobot's OpenCode
|
||||
providers use the OpenAI-compatible `chat/completions` path, so pick model IDs
|
||||
from that endpoint family. The `opencode/...` and `opencode-go/...` prefixes are
|
||||
accepted for config readability and stripped before sending the request.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LongCat (OpenAI-compatible)</b></summary>
|
||||
|
||||
@@ -882,6 +1015,29 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
|
||||
}
|
||||
```
|
||||
|
||||
If a custom OpenAI-compatible endpoint exposes a provider-specific thinking toggle, set `thinkingStyle` so nanobot can translate `reasoningEffort` into the right request body. Supported styles are `thinking_type` (`{"thinking":{"type":"enabled"}}`), `enable_thinking` (`{"enable_thinking": true}`), and `reasoning_split` (`{"reasoning_split": true}`):
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"companyProxy": {
|
||||
"apiKey": "${COMPANY_PROXY_API_KEY}",
|
||||
"apiBase": "https://api.your-provider.com/v1",
|
||||
"thinkingStyle": "enable_thinking"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"company": {
|
||||
"provider": "companyProxy",
|
||||
"model": "served-model-name",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Leave `thinkingStyle` unset unless the endpoint explicitly documents one of those wire formats. `extraBody` is still applied last, so advanced users can override the generated value.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="local-providers"></a>
|
||||
@@ -1379,6 +1535,8 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
||||
}
|
||||
```
|
||||
|
||||
Telegram `richMessages` defaults to `false`. Enable it only to opt in to Bot API 10.1 `sendRichMessage` rendering; leave it disabled for Telegram Web clients that show unsupported-message errors for rich messages.
|
||||
|
||||
### Retry Behavior
|
||||
|
||||
Retry is intentionally simple.
|
||||
@@ -1724,9 +1882,9 @@ Use `enabledTools` to register only a subset of tools from an MCP server:
|
||||
|
||||
`enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`).
|
||||
|
||||
- Omit `enabledTools`, or set it to `["*"]`, to register all tools.
|
||||
- Set `enabledTools` to `[]` to register no tools from that server.
|
||||
- Set `enabledTools` to a non-empty list of names to register only that subset.
|
||||
- Omit `enabledTools`, or set it to `["*"]`, to register all capabilities (tools, resources, and prompts).
|
||||
- Set `enabledTools` to `[]` to register no tools from that server. Resources and prompts are also skipped, since they have no per-name filter.
|
||||
- Set `enabledTools` to a non-empty list of names to register only those tools — resources and prompts are not registered.
|
||||
|
||||
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
|
||||
|
||||
@@ -1835,7 +1993,9 @@ The gateway can run a protected heartbeat cron job that periodically checks `HEA
|
||||
}
|
||||
```
|
||||
|
||||
If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and delivers useful results to the most recently active chat target. If the file has no active tasks, the heartbeat is skipped silently.
|
||||
If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and sends only useful/actionable results to the most recently active chat target. If the file has no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
||||
|
||||
This is intentionally different from user-created cron jobs. A cron job created with the `cron` tool runs as a scheduled turn in its origin chat/session and normally delivers the result back to that channel. Use `HEARTBEAT.md` for recurring background checks that should not notify the user on every run.
|
||||
|
||||
The heartbeat job is backed by the same cron service as user-created reminders. It is stored under the active workspace (`<workspace>/cron/jobs.json`) and shows up in `cron(action="list")` as `heartbeat`, but it is system-managed and cannot be removed with the `cron` tool. Disable it through config and restart the gateway if you do not want periodic heartbeat checks.
|
||||
|
||||
@@ -1844,6 +2004,7 @@ The heartbeat job is backed by the same cron service as user-created reminders.
|
||||
| `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. |
|
||||
| `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. |
|
||||
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
|
||||
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
|
||||
|
||||
|
||||
## Subagent Concurrency
|
||||
@@ -1860,9 +2021,22 @@ By default, nanobot only allows one spawned subagent at a time. When the limit i
|
||||
}
|
||||
```
|
||||
|
||||
Subagents also stop immediately when one of their tools returns an execution error. That default keeps failures visible to the parent agent. If your subagent workflows use tools that can fail transiently and should be retried or worked around by the model, disable hard-stop behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"failOnToolError": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. |
|
||||
| `agents.defaults.failOnToolError` | `true` | Stop a spawned subagent when a tool execution fails. Set to `false` to return tool errors to the subagent model so it can recover within the same run. |
|
||||
|
||||
|
||||
## Auto Compact
|
||||
|
||||
@@ -12,6 +12,32 @@ Run the CLI check first. If `nanobot agent -m "Hello!"` fails, fix provider or c
|
||||
|
||||
For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md).
|
||||
|
||||
## Authentication
|
||||
|
||||
Local-only `127.0.0.1` usage does not require an API key. If you bind the API
|
||||
server to all interfaces with `api.host: "0.0.0.0"` or `"::"`, nanobot requires
|
||||
`api.apiKey`; otherwise startup fails to avoid exposing an unauthenticated agent
|
||||
endpoint on the network.
|
||||
|
||||
```json
|
||||
{
|
||||
"api": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8900,
|
||||
"apiKey": "${NANOBOT_API_KEY}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `api.apiKey` is set, send it as a Bearer token on API routes. The health
|
||||
endpoint remains unauthenticated so local probes and load balancers can still
|
||||
check process health.
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8900/v1/models \
|
||||
-H "Authorization: Bearer $NANOBOT_API_KEY"
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
|
||||
|
||||
@@ -15,8 +15,10 @@ Match the recipe to the credential or endpoint you already have:
|
||||
| What you have | Recipe | Must match |
|
||||
|---|---|---|
|
||||
| A gateway key and model IDs that include a model family path, such as `provider/model-name` | [OpenRouter Gateway](#recipe-openrouter-gateway) | API key, provider config key, preset provider, and gateway model ID |
|
||||
| An OpenCode Zen or Go key | [OpenCode Zen or Go](#recipe-opencode-zen-or-go) | `OPENCODE_API_KEY`, the Zen/Go provider key, and a model ID from the matching OpenCode endpoint |
|
||||
| An OpenAI platform API key and OpenAI model ID | [OpenAI Direct](#recipe-openai-direct) | `OPENAI_API_KEY`, `provider: "openai"`, and an OpenAI model available to that account |
|
||||
| An Anthropic API key and Anthropic model ID | [Anthropic Direct](#recipe-anthropic-direct) | `ANTHROPIC_API_KEY`, `provider: "anthropic"`, and a non-gateway model ID |
|
||||
| A Kimi Coding Plan key | [Kimi Coding Plan](#recipe-kimi-coding-plan) | `KIMI_CODING_API_KEY`, `provider: "kimi_coding"`, and `model: "kimi-for-coding"` |
|
||||
| An OpenAI-compatible `/v1` endpoint that is not a named nanobot provider | [Custom OpenAI-Compatible Provider](#recipe-custom-openai-compatible-provider) | `apiBase`, optional API key, and the model ID served by that endpoint |
|
||||
| Ollama already running locally | [Ollama Local Model](#recipe-ollama-local-model) | Ollama `apiBase`, pulled model name, and local server availability |
|
||||
| vLLM, LM Studio, or another local OpenAI-compatible server | [vLLM or LM Studio](#recipe-vllm-or-lm-studio) | Local `/v1` base URL, any required key, and served model name |
|
||||
@@ -94,6 +96,79 @@ nanobot agent -m "Hello!"
|
||||
|
||||
If this fails with `401` or `unauthorized`, check that `OPENROUTER_API_KEY` is visible in the same terminal or service that starts nanobot. If it fails with `model not found`, choose a model ID that OpenRouter lists for your account.
|
||||
|
||||
## Recipe: OpenCode Zen or Go
|
||||
|
||||
This recipe applies when your credential comes from OpenCode Zen or OpenCode Go.
|
||||
Both providers use `OPENCODE_API_KEY`; pick the provider block that matches the
|
||||
subscription or balance you want to use.
|
||||
|
||||
OpenCode Zen:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeZen": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "OpenCode Zen",
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode Go:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeGo": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "OpenCode Go",
|
||||
"provider": "opencode_go",
|
||||
"model": "opencode-go/deepseek-v4-flash",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
OpenCode's docs list models across multiple endpoint types. The `opencode_zen`
|
||||
and `opencode_go` providers in nanobot use the OpenAI-compatible
|
||||
`chat/completions` path. If a model fails with `model not found` or an endpoint
|
||||
shape error, choose a model that OpenCode lists under `chat/completions` for the
|
||||
matching Zen or Go endpoint.
|
||||
|
||||
## Recipe: OpenAI Direct
|
||||
|
||||
This recipe applies when you have an OpenAI API key and want to call OpenAI directly instead of through a gateway.
|
||||
@@ -198,6 +273,43 @@ If you use an Anthropic-compatible proxy, keep the preset provider as `anthropic
|
||||
|
||||
Do not configure Anthropic-compatible endpoints as arbitrary custom provider names; named custom providers use the OpenAI-compatible request format.
|
||||
|
||||
## Recipe: Kimi Coding Plan
|
||||
|
||||
This recipe applies when your key comes from Kimi's Coding Plan endpoint. Nanobot uses a dedicated `kimi_coding` provider for this Anthropic Messages API endpoint; do not configure it as a generic `custom` provider.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"kimiCoding": {
|
||||
"apiKey": "${KIMI_CODING_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"kimiCoding": {
|
||||
"label": "Kimi Coding",
|
||||
"provider": "kimi_coding",
|
||||
"model": "kimi-for-coding",
|
||||
"maxTokens": 4096,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "kimiCoding"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
The default base URL is `https://api.kimi.com/coding/v1`. This endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default. If your account requires a different value, override it with `providers.kimiCoding.extraHeaders.User-Agent`.
|
||||
|
||||
## Recipe: Custom OpenAI-Compatible Provider
|
||||
|
||||
This recipe applies to an OpenAI-compatible service that is not a named nanobot provider.
|
||||
|
||||
@@ -17,6 +17,7 @@ The docs show concrete provider names so the JSON is copyable, not because nanob
|
||||
| If you have... | Configure... |
|
||||
|---|---|
|
||||
| An API key from a hosted provider or gateway | That provider's `providers.<name>.apiKey`, then a preset with that provider name and a model ID from that service. |
|
||||
| An OpenCode Zen or Go key | `providers.opencodeZen.apiKey` or `providers.opencodeGo.apiKey`, then a preset with `provider: "opencode_zen"` or `provider: "opencode_go"`. |
|
||||
| A company proxy or regional endpoint | The matching provider block plus `apiBase` if the proxy gives you a URL. |
|
||||
| A local OpenAI-compatible server | A local provider block such as `ollama`, `vllm`, `lmStudio`, or `custom`, usually with `apiBase`. |
|
||||
| An OAuth-based account | Run the matching `nanobot provider login ...` command, then select that provider explicitly in a preset. |
|
||||
@@ -60,9 +61,12 @@ These fields answer different questions:
|
||||
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
||||
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
||||
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
||||
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
|
||||
|
||||
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
||||
|
||||
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
||||
|
||||
## Common Provider Patterns
|
||||
|
||||
### OpenRouter Gateway
|
||||
@@ -94,6 +98,62 @@ Gateway-style setup for model IDs served through OpenRouter.
|
||||
|
||||
Use the model ID exactly as OpenRouter lists it.
|
||||
|
||||
### OpenCode Zen and Go
|
||||
|
||||
OpenCode Zen and OpenCode Go are OpenCode-managed gateways for coding-agent models.
|
||||
They share `OPENCODE_API_KEY`, but use separate provider config keys and default base
|
||||
URLs in nanobot.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeZen": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 65536
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For OpenCode Go, switch the provider block and preset:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeGo": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"provider": "opencode_go",
|
||||
"model": "opencode-go/deepseek-v4-flash",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode documents model IDs with `opencode/<model-id>` for Zen and
|
||||
`opencode-go/<model-id>` for Go. nanobot accepts those prefixes and strips them
|
||||
before sending the request to OpenCode. Use model IDs that OpenCode lists under
|
||||
the `chat/completions` endpoint; models listed only under `responses`,
|
||||
`messages`, or provider-specific endpoints are not handled by this
|
||||
OpenAI-compatible provider path.
|
||||
|
||||
### Anthropic Direct
|
||||
|
||||
```json
|
||||
@@ -236,6 +296,8 @@ If you have more than one custom OpenAI-compatible endpoint, give each endpoint
|
||||
|
||||
Custom provider keys are treated as direct OpenAI-compatible providers. `apiBase` is required because nanobot cannot know the endpoint URL. `apiKey` is optional for local servers or private proxies that do not require one. Choose a name that does not conflict with a built-in provider name or alias, such as `openai`, `openai-codex`, `github-copilot`, or `lm-studio`. Do not set `apiType` on custom provider keys; `apiType` is only for `providers.openai`.
|
||||
|
||||
If your custom endpoint documents a nonstandard thinking toggle, set `providers.<name>.thinkingStyle` to `thinking_type`, `enable_thinking`, or `reasoning_split`; nanobot then maps `reasoningEffort` onto that provider-specific request body. Leave it unset for ordinary OpenAI-compatible endpoints.
|
||||
|
||||
This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`.
|
||||
|
||||
### Ollama
|
||||
@@ -363,6 +425,32 @@ nanobot provider login github-copilot
|
||||
|
||||
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
|
||||
|
||||
For OpenAI Codex, add `providers.openai_codex.proxy` only when Codex OAuth/token refresh or Codex API requests must use a proxy:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai_codex": {
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"codex": {
|
||||
"provider": "openai_codex",
|
||||
"model": "gpt-5.1-codex",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you run the login command on a remote/headless machine and open the authorization URL in a local browser, paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted. See [`configuration.md#providers`](./configuration.md#providers) for the full OAuth provider notes.
|
||||
|
||||
## Provider Resolution
|
||||
|
||||
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
|
||||
|
||||
+2
-3
@@ -326,11 +326,10 @@ python -m pip install -e .
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If you use WhatsApp, rebuild the local bridge after upgrading:
|
||||
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.nanobot/bridge
|
||||
nanobot channels login whatsapp
|
||||
python -m pip install -e ".[whatsapp]"
|
||||
```
|
||||
|
||||
## First-Run Troubleshooting
|
||||
|
||||
+6
-1
@@ -118,7 +118,12 @@ to perform that task.
|
||||
|
||||
Automations are scheduled agent turns. They should be created from the chat,
|
||||
channel, or session where they are supposed to run so nanobot keeps the correct
|
||||
target context.
|
||||
target context. When an automation runs, it normally delivers the result back to
|
||||
that linked chat.
|
||||
|
||||
For recurring background checks that should stay quiet unless there is something
|
||||
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
|
||||
instead of creating a chat automation.
|
||||
|
||||
Use the Automations view to:
|
||||
|
||||
|
||||
@@ -34,6 +34,26 @@ class AutoCompact:
|
||||
ts = datetime.fromisoformat(ts)
|
||||
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
|
||||
|
||||
def _has_compactable_idle_tail(self, key: str) -> bool:
|
||||
session = self.sessions.get_or_create(key)
|
||||
tail = list(session.messages[session.last_consolidated:])
|
||||
if not tail:
|
||||
return False
|
||||
probe = Session(
|
||||
key=session.key,
|
||||
messages=tail,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
result = probe.retain_recent_legal_suffix(
|
||||
self._RECENT_SUFFIX_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||
return bool(messages_to_remove)
|
||||
|
||||
@staticmethod
|
||||
def _format_summary(text: str, last_active: datetime) -> str:
|
||||
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
|
||||
@@ -52,7 +72,8 @@ class AutoCompact:
|
||||
continue
|
||||
if key in active_session_keys:
|
||||
continue
|
||||
if self._is_expired(info.get("updated_at"), now):
|
||||
updated_at = info.get("updated_at")
|
||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||
self._archiving.add(key)
|
||||
schedule_background(self._archive(key))
|
||||
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Model-message governance for agent runner requests.
|
||||
|
||||
This module owns model-facing message shaping and tool-result content normalization.
|
||||
It may return copied messages or persisted-result placeholders, but it must not
|
||||
mutate an existing session history list in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import (
|
||||
estimate_message_tokens,
|
||||
estimate_prompt_tokens_chain,
|
||||
find_legal_message_start,
|
||||
maybe_persist_tool_result,
|
||||
truncate_text,
|
||||
)
|
||||
from nanobot.utils.runtime import ensure_nonempty_tool_result
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
SNIP_SAFETY_BUFFER = 1024
|
||||
MICROCOMPACT_KEEP_RECENT = 10
|
||||
MICROCOMPACT_MIN_CHARS = 500
|
||||
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
|
||||
COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||
PLACEHOLDER_TEXTS = frozenset({
|
||||
"[Previous assistant message omitted.]",
|
||||
})
|
||||
|
||||
|
||||
def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
||||
"""Whether a persisted OpenAI-style tool_call carries a usable name.
|
||||
|
||||
Mirrors ``ToolCallRequest.has_valid_name`` for the dict shape stored in
|
||||
message history: a degenerate call with ``name=None`` / ``""`` cannot be
|
||||
executed and is rejected by upstream APIs if replayed.
|
||||
"""
|
||||
if not isinstance(tool_call, dict):
|
||||
return False
|
||||
fn = tool_call.get("function")
|
||||
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
|
||||
return isinstance(name, str) and bool(name)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContextGovernanceConfig:
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
tools: Any
|
||||
workspace: Path | None
|
||||
session_key: str | None
|
||||
max_tool_result_chars: int
|
||||
context_window_tokens: int | None = None
|
||||
context_block_limit: int | None = None
|
||||
max_tokens: int | None = None
|
||||
inflight_start_index: int = 0
|
||||
|
||||
|
||||
class ContextGovernor:
|
||||
"""Prepare model-copy messages while preserving persisted history."""
|
||||
|
||||
def prepare_for_model(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = self.strip_placeholder_assistant_messages(messages)
|
||||
updated = self.strip_malformed_tool_calls(updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
updated = self.backfill_missing_tool_results(updated)
|
||||
updated = self.apply_tool_result_budget(config, updated)
|
||||
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
||||
updated = self.snip_history(config, updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
return self.backfill_missing_tool_results(updated)
|
||||
|
||||
@staticmethod
|
||||
def input_budget(config: ContextGovernanceConfig) -> int:
|
||||
if not config.context_window_tokens:
|
||||
return 0
|
||||
|
||||
provider_max_tokens = getattr(
|
||||
getattr(config.provider, "generation", None),
|
||||
"max_tokens",
|
||||
4096,
|
||||
)
|
||||
max_output = config.max_tokens if isinstance(config.max_tokens, int) else (
|
||||
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
|
||||
)
|
||||
budget = config.context_block_limit or (
|
||||
config.context_window_tokens - max_output - SNIP_SAFETY_BUFFER
|
||||
)
|
||||
return budget if budget > 0 else 0
|
||||
|
||||
@staticmethod
|
||||
def normalize_tool_result(
|
||||
config: ContextGovernanceConfig,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Any,
|
||||
) -> Any:
|
||||
result = ensure_nonempty_tool_result(tool_name, result)
|
||||
if tool_name in TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
|
||||
return result
|
||||
try:
|
||||
content = maybe_persist_tool_result(
|
||||
config.workspace,
|
||||
config.session_key,
|
||||
tool_call_id,
|
||||
result,
|
||||
max_chars=config.max_tool_result_chars,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Tool result persist failed for {} in {}; using raw result",
|
||||
tool_call_id,
|
||||
config.session_key or "default",
|
||||
)
|
||||
content = result
|
||||
if isinstance(content, str) and len(content) > config.max_tool_result_chars:
|
||||
return truncate_text(content, config.max_tool_result_chars)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def strip_placeholder_assistant_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Remove assistant messages that are compaction placeholders.
|
||||
|
||||
Messages like ``[Previous assistant message omitted.]`` carry no useful
|
||||
context for the model and can cause it to repeatedly attempt tool calls
|
||||
that previously failed, producing malformed responses in a loop.
|
||||
Consecutive same-role messages that result from removal are handled
|
||||
downstream by the provider's merge-consecutive logic. Only the
|
||||
model-facing copy is repaired; the persisted transcript is untouched
|
||||
(a copy is returned, or the same list object when nothing changes).
|
||||
"""
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant":
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
text = content if isinstance(content, str) else ""
|
||||
is_placeholder = text.strip() in PLACEHOLDER_TEXTS
|
||||
has_tool_calls = bool(msg.get("tool_calls"))
|
||||
if is_placeholder and not has_tool_calls:
|
||||
if updated is None:
|
||||
updated = list(messages[:idx])
|
||||
logger.debug(
|
||||
"Stripping placeholder assistant message from history: {!r}",
|
||||
text[:60],
|
||||
)
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def strip_malformed_tool_calls(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop persisted assistant tool_calls whose name is missing/non-string.
|
||||
|
||||
A degenerate tool call (``name=None`` or ``""``) that slipped into the
|
||||
saved history before this guard existed gets replayed on every turn and
|
||||
makes upstream APIs reject the whole request
|
||||
(``messages.content.N.tool_use.name: Input should be a valid string``),
|
||||
permanently wedging the session. Removing the bad call here lets the
|
||||
existing orphan-result cleanup drop its now-dangling tool result, so a
|
||||
polluted session self-heals on its next turn. The persisted transcript
|
||||
is left untouched; only the model-facing copy is repaired (a copy is
|
||||
returned, or the same list object when nothing changes).
|
||||
"""
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant":
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
calls = msg.get("tool_calls")
|
||||
if not calls:
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
|
||||
if len(kept) == len(calls):
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
logger.warning(
|
||||
"Stripping {} malformed tool_call(s) with missing/non-string "
|
||||
"name from assistant history before request",
|
||||
len(calls) - len(kept),
|
||||
)
|
||||
repaired = dict(msg)
|
||||
if kept:
|
||||
repaired["tool_calls"] = kept
|
||||
else:
|
||||
repaired.pop("tool_calls", None)
|
||||
# An assistant turn with neither content nor any valid tool call is
|
||||
# itself invalid upstream; drop it entirely in that case.
|
||||
has_content = bool(repaired.get("content"))
|
||||
if not kept and not has_content:
|
||||
continue
|
||||
updated.append(repaired)
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def drop_orphan_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop tool results that have no matching assistant tool_call earlier in history."""
|
||||
declared: set[str] = set()
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
declared.add(str(tc["id"]))
|
||||
if role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid and str(tid) not in declared:
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(dict(msg))
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def backfill_missing_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert synthetic error results for assistant tool_calls with missing tool outputs."""
|
||||
declared: list[tuple[int, str, str]] = []
|
||||
fulfilled: set[str] = set()
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
name = ""
|
||||
func = tc.get("function")
|
||||
if isinstance(func, dict):
|
||||
name = func.get("name", "")
|
||||
declared.append((idx, str(tc["id"]), name))
|
||||
elif role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid:
|
||||
fulfilled.add(str(tid))
|
||||
|
||||
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
|
||||
if not missing:
|
||||
return messages
|
||||
|
||||
updated = list(messages)
|
||||
offset = 0
|
||||
for assistant_idx, call_id, name in missing:
|
||||
insert_at = assistant_idx + 1 + offset
|
||||
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
|
||||
insert_at += 1
|
||||
updated.insert(insert_at, {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": BACKFILL_CONTENT,
|
||||
})
|
||||
offset += 1
|
||||
return updated
|
||||
|
||||
def apply_tool_result_budget(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = messages
|
||||
for idx, message in enumerate(messages):
|
||||
if message.get("role") != "tool":
|
||||
continue
|
||||
normalized = self.normalize_tool_result(
|
||||
config,
|
||||
str(message.get("tool_call_id") or f"tool_{idx}"),
|
||||
str(message.get("name") or "tool"),
|
||||
message.get("content"),
|
||||
)
|
||||
if normalized != message.get("content"):
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = normalized
|
||||
return updated
|
||||
|
||||
def compact_inflight_overflow(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compact in-flight tool results only when the request would overflow."""
|
||||
budget = self.input_budget(config)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
tools = config.tools.get_definitions()
|
||||
updated = self._apply_recorded_compactions(messages, compacted_tool_call_ids)
|
||||
estimate, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
updated,
|
||||
tools,
|
||||
)
|
||||
if estimate <= budget:
|
||||
return updated
|
||||
|
||||
target = int(budget * INFLIGHT_COMPACT_TARGET_RATIO)
|
||||
candidates = self._inflight_compaction_candidates(
|
||||
config,
|
||||
updated,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
if not candidates:
|
||||
return updated
|
||||
|
||||
for candidate_idx, (idx, tool_call_id) in enumerate(candidates):
|
||||
is_newest_candidate = candidate_idx == len(candidates) - 1
|
||||
if is_newest_candidate and estimate <= budget:
|
||||
break
|
||||
if tool_call_id in compacted_tool_call_ids:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
compacted_tool_call_ids.add(tool_call_id)
|
||||
self._compact_tool_result_at(updated, idx)
|
||||
estimate, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
updated,
|
||||
tools,
|
||||
)
|
||||
if estimate <= target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"In-flight context compaction for {}: prompt={} budget={} target={} via {}, ids={}",
|
||||
config.session_key or "default",
|
||||
estimate,
|
||||
budget,
|
||||
target,
|
||||
source,
|
||||
len(compacted_tool_call_ids),
|
||||
)
|
||||
return updated
|
||||
|
||||
def snip_history(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not messages or not config.context_window_tokens:
|
||||
return messages
|
||||
|
||||
budget = self.input_budget(config)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
tools = config.tools.get_definitions()
|
||||
estimate, _ = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
messages,
|
||||
tools,
|
||||
)
|
||||
if estimate <= budget:
|
||||
return messages
|
||||
|
||||
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
|
||||
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
|
||||
if not non_system:
|
||||
return messages
|
||||
|
||||
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
||||
fixed_tokens, _ = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
system_messages,
|
||||
tools,
|
||||
)
|
||||
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
|
||||
kept: list[dict[str, Any]] = []
|
||||
kept_tokens = 0
|
||||
for message in reversed(non_system):
|
||||
msg_tokens = estimate_message_tokens(message)
|
||||
if kept and kept_tokens + msg_tokens > remaining_budget:
|
||||
break
|
||||
kept.append(message)
|
||||
kept_tokens += msg_tokens
|
||||
kept.reverse()
|
||||
|
||||
return system_messages + self._legal_history_tail(kept, non_system)
|
||||
|
||||
@staticmethod
|
||||
def _summary_for(message: dict[str, Any]) -> str:
|
||||
name = message.get("name", "tool")
|
||||
return f"[{name} result omitted from context]"
|
||||
|
||||
def _legal_history_tail(
|
||||
self,
|
||||
kept: list[dict[str, Any]],
|
||||
non_system: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
fallback = kept if kept else (non_system[-1:] if non_system else [])
|
||||
kept = self._user_tail(kept) or self._user_tail(non_system, last=True) or fallback
|
||||
|
||||
start = find_legal_message_start(kept)
|
||||
return kept[start:] if start else kept
|
||||
|
||||
@staticmethod
|
||||
def _user_tail(messages: list[dict[str, Any]], *, last: bool = False) -> list[dict[str, Any]]:
|
||||
indexes = range(len(messages) - 1, -1, -1) if last else range(len(messages))
|
||||
for idx in indexes:
|
||||
if messages[idx].get("role") == "user":
|
||||
return messages[idx:]
|
||||
return []
|
||||
|
||||
def _apply_recorded_compactions(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not compacted_tool_call_ids:
|
||||
return messages
|
||||
updated = messages
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
|
||||
continue
|
||||
summary = self._summary_for(msg)
|
||||
if msg.get("content") == summary:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = summary
|
||||
return updated
|
||||
|
||||
def _inflight_compaction_candidates(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[tuple[int, str]]:
|
||||
compactable: list[tuple[int, str]] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if idx < config.inflight_start_index:
|
||||
continue
|
||||
if msg.get("role") != "tool" or msg.get("name") not in COMPACTABLE_TOOLS:
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) in compacted_tool_call_ids:
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) < MICROCOMPACT_MIN_CHARS:
|
||||
continue
|
||||
compactable.append((idx, str(tool_call_id)))
|
||||
|
||||
if not compactable:
|
||||
return []
|
||||
primary_count = max(0, len(compactable) - MICROCOMPACT_KEEP_RECENT)
|
||||
primary = compactable[:primary_count]
|
||||
# Hard overflow beats the keep-recent preference. Return recent results
|
||||
# after stale ones so the newest result is naturally last.
|
||||
fallback = compactable[primary_count:]
|
||||
return primary + fallback
|
||||
|
||||
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
||||
messages[idx]["content"] = self._summary_for(messages[idx])
|
||||
+51
-29
@@ -31,6 +31,13 @@ from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
RetryWaitEvent,
|
||||
StreamDeltaEvent,
|
||||
StreamedResponseEvent,
|
||||
StreamEndEvent,
|
||||
outbound_message_for_event,
|
||||
)
|
||||
from nanobot.bus.progress import build_bus_progress_callback
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import (
|
||||
@@ -57,7 +64,11 @@ from nanobot.session.goal_state import (
|
||||
sustained_goal_active,
|
||||
)
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.manager import (
|
||||
Session,
|
||||
SessionManager,
|
||||
replay_max_messages_for_context,
|
||||
)
|
||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
@@ -190,6 +201,7 @@ class AgentLoop:
|
||||
context_window_tokens: int | None = None,
|
||||
context_block_limit: int | None = None,
|
||||
max_tool_result_chars: int | None = None,
|
||||
fail_on_tool_error: bool | None = None,
|
||||
provider_retry_mode: str = "standard",
|
||||
tool_hint_max_length: int | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
@@ -200,7 +212,6 @@ class AgentLoop:
|
||||
timezone: str | None = None,
|
||||
session_ttl_minutes: int = 0,
|
||||
consolidation_ratio: float = 0.5,
|
||||
max_messages: int = 120,
|
||||
hooks: list[AgentHook] | None = None,
|
||||
unified_session: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
@@ -214,6 +225,7 @@ class AgentLoop:
|
||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||
runtime_events: RuntimeEventBus | None = None,
|
||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||
restart_mode: str = "auto",
|
||||
):
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
@@ -223,6 +235,7 @@ class AgentLoop:
|
||||
self.runtime_events = runtime_events or RuntimeEventBus()
|
||||
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
|
||||
self.channels_config = channels_config
|
||||
self.restart_mode = restart_mode
|
||||
self.provider = provider
|
||||
self._provider_snapshot_loader = provider_snapshot_loader
|
||||
self._preset_snapshot_loader = preset_snapshot_loader
|
||||
@@ -287,10 +300,11 @@ class AgentLoop:
|
||||
disabled_skills=disabled_skills,
|
||||
max_iterations=self.max_iterations,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
fail_on_tool_error=fail_on_tool_error,
|
||||
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
||||
)
|
||||
self._unified_session = unified_session
|
||||
self._max_messages = max_messages if max_messages > 0 else 120
|
||||
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
|
||||
self._running = False
|
||||
self._mcp_servers = mcp_servers or {}
|
||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
||||
@@ -377,6 +391,7 @@ class AgentLoop:
|
||||
context_window_tokens=context_window_tokens,
|
||||
context_block_limit=defaults.context_block_limit,
|
||||
max_tool_result_chars=defaults.max_tool_result_chars,
|
||||
fail_on_tool_error=defaults.fail_on_tool_error,
|
||||
provider_retry_mode=defaults.provider_retry_mode,
|
||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
@@ -387,10 +402,10 @@ class AgentLoop:
|
||||
disabled_skills=defaults.disabled_skills,
|
||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||
consolidation_ratio=defaults.consolidation_ratio,
|
||||
max_messages=defaults.max_messages,
|
||||
tools_config=config.tools,
|
||||
model_presets=preset_helpers.configured_model_presets(config),
|
||||
model_preset=defaults.model_preset,
|
||||
restart_mode=config.gateway.restart_mode,
|
||||
provider_snapshot_loader=provider_snapshot_loader,
|
||||
preset_snapshot_loader=preset_snapshot_loader,
|
||||
**extra,
|
||||
@@ -418,6 +433,7 @@ class AgentLoop:
|
||||
self.runner.provider = provider
|
||||
self.subagents.set_provider(provider, model)
|
||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||
self._sync_replay_max_messages()
|
||||
self._provider_signature = snapshot.signature
|
||||
if publish_update and self._runtime_model_publisher is not None:
|
||||
self._runtime_model_publisher(
|
||||
@@ -431,6 +447,9 @@ class AgentLoop:
|
||||
)
|
||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
||||
|
||||
def _sync_replay_max_messages(self) -> None:
|
||||
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
|
||||
|
||||
def _refresh_provider_snapshot(self) -> None:
|
||||
if self._provider_snapshot_loader is None:
|
||||
return
|
||||
@@ -555,14 +574,12 @@ class AgentLoop:
|
||||
"""Build a retry-wait callback that publishes to the message bus."""
|
||||
|
||||
async def _on_retry_wait(content: str) -> None:
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_retry_wait"] = True
|
||||
await self.bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=content,
|
||||
metadata=meta,
|
||||
event=RetryWaitEvent(content=content),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -987,26 +1004,31 @@ class AgentLoop:
|
||||
return f"{stream_base_id}:{stream_segment}"
|
||||
|
||||
async def on_stream(delta: str) -> None:
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_stream_delta"] = True
|
||||
meta["_stream_id"] = _current_stream_id()
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
content=delta,
|
||||
metadata=meta,
|
||||
))
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
event=StreamDeltaEvent(
|
||||
content=delta,
|
||||
stream_id=_current_stream_id(),
|
||||
),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
async def on_stream_end(*, resuming: bool = False) -> None:
|
||||
nonlocal stream_segment
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_stream_end"] = True
|
||||
meta["_resuming"] = resuming
|
||||
meta["_stream_id"] = _current_stream_id()
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="",
|
||||
metadata=meta,
|
||||
))
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
event=StreamEndEvent(
|
||||
stream_id=_current_stream_id(),
|
||||
resuming=resuming,
|
||||
),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
stream_segment += 1
|
||||
|
||||
response = await self._process_message(
|
||||
@@ -1180,7 +1202,6 @@ class AgentLoop:
|
||||
_hist_kwargs: dict[str, Any] = {
|
||||
"max_messages": self._max_messages,
|
||||
"max_tokens": self._replay_token_budget(),
|
||||
"include_timestamps": True,
|
||||
"extend_to_user": is_subagent,
|
||||
}
|
||||
history = session.get_history(**_hist_kwargs)
|
||||
@@ -1360,9 +1381,10 @@ class AgentLoop:
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
|
||||
event = None
|
||||
meta = dict(msg.metadata or {})
|
||||
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
|
||||
meta["_streamed"] = True
|
||||
event = StreamedResponseEvent()
|
||||
if turn_latency_ms is not None:
|
||||
meta["latency_ms"] = int(turn_latency_ms)
|
||||
|
||||
@@ -1370,6 +1392,7 @@ class AgentLoop:
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=final_content,
|
||||
event=event,
|
||||
metadata=meta,
|
||||
)
|
||||
|
||||
@@ -1459,7 +1482,6 @@ class AgentLoop:
|
||||
_hist_kwargs: dict[str, Any] = {
|
||||
"max_messages": self._max_messages,
|
||||
"max_tokens": self._replay_token_budget(),
|
||||
"include_timestamps": True,
|
||||
"extend_to_user": False,
|
||||
}
|
||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
||||
|
||||
+7
-13
@@ -33,7 +33,6 @@ if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryStore — pure file I/O layer
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -479,6 +478,9 @@ class MemoryStore:
|
||||
def set_last_dream_cursor(self, cursor: int) -> None:
|
||||
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
|
||||
|
||||
def get_latest_cursor(self) -> int:
|
||||
return max(self._next_cursor() - 1, 0)
|
||||
|
||||
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
||||
"""Build the Dream prompt with unprocessed history context.
|
||||
|
||||
@@ -709,17 +711,12 @@ class Consolidator:
|
||||
@staticmethod
|
||||
def _full_unconsolidated_history(
|
||||
session: Session,
|
||||
*,
|
||||
include_timestamps: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the whole unconsolidated tail for consolidation decisions."""
|
||||
unconsolidated_count = len(session.messages) - session.last_consolidated
|
||||
if unconsolidated_count <= 0:
|
||||
return []
|
||||
return session.get_history(
|
||||
max_messages=unconsolidated_count,
|
||||
include_timestamps=include_timestamps,
|
||||
)
|
||||
return session.get_history(max_messages=unconsolidated_count)
|
||||
|
||||
@staticmethod
|
||||
def _replay_overflow_boundary(
|
||||
@@ -794,7 +791,7 @@ class Consolidator:
|
||||
session: Session,
|
||||
) -> tuple[int, str]:
|
||||
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||
history = self._full_unconsolidated_history(session, include_timestamps=True)
|
||||
history = self._full_unconsolidated_history(session)
|
||||
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
|
||||
# Include archived summary in estimation so the budget accounts for it.
|
||||
meta = session.metadata.get("_last_summary")
|
||||
@@ -1008,7 +1005,6 @@ class Consolidator:
|
||||
|
||||
messages_to_summarize = list(session.messages[session.last_consolidated:])
|
||||
if not messages_to_summarize:
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -1020,12 +1016,11 @@ class Consolidator:
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
messages_to_keep = probe.messages
|
||||
messages_to_remove = dropped[already_consolidated:]
|
||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||
|
||||
if not messages_to_remove and not messages_to_keep:
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -1048,7 +1043,6 @@ class Consolidator:
|
||||
|
||||
session.messages = messages_to_keep
|
||||
session.last_consolidated = 0
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
|
||||
if messages_to_remove:
|
||||
|
||||
+138
-250
@@ -13,8 +13,12 @@ from typing import Any, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context_governance import (
|
||||
ContextGovernanceConfig,
|
||||
ContextGovernor,
|
||||
)
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.file_edit_events import (
|
||||
StreamingFileEditTracker,
|
||||
@@ -32,10 +36,8 @@ from nanobot.utils.helpers import (
|
||||
estimate_message_tokens,
|
||||
estimate_prompt_tokens_chain,
|
||||
extract_reasoning,
|
||||
find_legal_message_start,
|
||||
maybe_persist_tool_result,
|
||||
strip_reasoning_tags,
|
||||
strip_think,
|
||||
truncate_text,
|
||||
)
|
||||
from nanobot.utils.progress_events import (
|
||||
invoke_file_edit_progress,
|
||||
@@ -48,7 +50,6 @@ from nanobot.utils.runtime import (
|
||||
build_finalization_retry_message,
|
||||
build_goal_continue_message,
|
||||
build_length_recovery_message,
|
||||
ensure_nonempty_tool_result,
|
||||
is_blank_text,
|
||||
repeated_external_lookup_error,
|
||||
repeated_workspace_violation_error,
|
||||
@@ -66,17 +67,6 @@ _MAX_EMPTY_RETRIES = 2
|
||||
_MAX_LENGTH_RECOVERIES = 3
|
||||
_MAX_INJECTIONS_PER_TURN = 3
|
||||
_MAX_INJECTION_CYCLES = 5
|
||||
_SNIP_SAFETY_BUFFER = 1024
|
||||
_MICROCOMPACT_KEEP_RECENT = 10
|
||||
_MICROCOMPACT_MIN_CHARS = 500
|
||||
_COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||
_TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||
|
||||
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
||||
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
||||
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
||||
@@ -134,6 +124,7 @@ class AgentRunner:
|
||||
|
||||
def __init__(self, provider: LLMProvider):
|
||||
self.provider = provider
|
||||
self.context_governor = ContextGovernor()
|
||||
|
||||
@staticmethod
|
||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
||||
@@ -366,6 +357,19 @@ class AgentRunner:
|
||||
length_recovery_count = 0
|
||||
had_injections = False
|
||||
injection_cycles = 0
|
||||
compacted_tool_call_ids: set[str] = set()
|
||||
governance_config = ContextGovernanceConfig(
|
||||
provider=self.provider,
|
||||
model=spec.model,
|
||||
tools=spec.tools,
|
||||
workspace=spec.workspace,
|
||||
session_key=spec.session_key,
|
||||
max_tool_result_chars=spec.max_tool_result_chars,
|
||||
context_window_tokens=spec.context_window_tokens,
|
||||
context_block_limit=spec.context_block_limit,
|
||||
max_tokens=spec.max_tokens,
|
||||
inflight_start_index=len(spec.initial_messages),
|
||||
)
|
||||
|
||||
for iteration in range(spec.max_iterations):
|
||||
try:
|
||||
@@ -373,14 +377,11 @@ class AgentRunner:
|
||||
# may repair or compact historical messages for the model, but
|
||||
# those synthetic edits must not shift the append boundary used
|
||||
# later when the caller saves only the new turn.
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = self._microcompact(messages_for_model)
|
||||
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
|
||||
messages_for_model = self._snip_history(spec, messages_for_model)
|
||||
# Snipping may have created new orphans; clean them up.
|
||||
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = self.context_governor.prepare_for_model(
|
||||
governance_config,
|
||||
messages,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Context governance failed on turn {} for {}; applying minimal repair",
|
||||
@@ -388,8 +389,18 @@ class AgentRunner:
|
||||
spec.session_key or "default",
|
||||
)
|
||||
try:
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
|
||||
messages
|
||||
)
|
||||
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
|
||||
messages_for_model
|
||||
)
|
||||
messages_for_model = ContextGovernor.drop_orphan_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
except Exception:
|
||||
messages_for_model = messages
|
||||
context = AgentHookContext(
|
||||
@@ -462,8 +473,8 @@ class AgentRunner:
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"name": tool_call.name,
|
||||
"content": self._normalize_tool_result(
|
||||
spec,
|
||||
"content": self.context_governor.normalize_tool_result(
|
||||
governance_config,
|
||||
tool_call.id,
|
||||
tool_call.name,
|
||||
result,
|
||||
@@ -722,6 +733,8 @@ class AgentRunner:
|
||||
messages: list[dict[str, Any]],
|
||||
hook: AgentHook,
|
||||
context: AgentHookContext,
|
||||
*,
|
||||
malformed_retry: bool = False,
|
||||
):
|
||||
timeout_s: float | None = spec.llm_timeout_s
|
||||
if timeout_s is None:
|
||||
@@ -770,16 +783,24 @@ class AgentRunner:
|
||||
await live_file_edits.update(delta)
|
||||
|
||||
if wants_streaming:
|
||||
thinking_buf = ""
|
||||
|
||||
async def _stream(delta: str) -> None:
|
||||
if delta:
|
||||
context.streamed_content = True
|
||||
await hook.on_stream(context, delta)
|
||||
|
||||
async def _thinking(delta: str) -> None:
|
||||
nonlocal thinking_buf
|
||||
if not delta:
|
||||
return
|
||||
context.streamed_reasoning = True
|
||||
await hook.emit_reasoning(delta)
|
||||
prev_clean = strip_reasoning_tags(thinking_buf)
|
||||
thinking_buf += delta
|
||||
new_clean = strip_reasoning_tags(thinking_buf)
|
||||
incremental = new_clean[len(prev_clean):]
|
||||
if incremental:
|
||||
context.streamed_reasoning = True
|
||||
await hook.emit_reasoning(incremental)
|
||||
|
||||
async def _stream_recover() -> None:
|
||||
await hook.on_stream_end(context, resuming=True)
|
||||
@@ -856,8 +877,94 @@ class AgentRunner:
|
||||
)
|
||||
if progress_state and progress_state.get("reasoning_open"):
|
||||
await hook.emit_reasoning_end()
|
||||
dropped, all_dropped, original_finish_reason = (
|
||||
self._drop_malformed_tool_calls(response)
|
||||
)
|
||||
if (
|
||||
all_dropped
|
||||
and original_finish_reason in ("tool_calls", "function_call")
|
||||
and not malformed_retry
|
||||
):
|
||||
logger.warning(
|
||||
"Retrying LLM request after all {} malformed tool call(s) were dropped",
|
||||
dropped,
|
||||
)
|
||||
retry_messages = self._malformed_tool_call_retry_messages(
|
||||
messages, response.content,
|
||||
)
|
||||
return await self._request_model(
|
||||
spec, retry_messages, hook, context,
|
||||
malformed_retry=True,
|
||||
)
|
||||
if (
|
||||
all_dropped
|
||||
and original_finish_reason in ("tool_calls", "function_call")
|
||||
and malformed_retry
|
||||
):
|
||||
logger.warning(
|
||||
"Malformed tool calls persisted after retry; falling back to no-tools request",
|
||||
)
|
||||
fallback_messages = self._malformed_tool_call_retry_messages(
|
||||
messages, response.content,
|
||||
)
|
||||
return await self._request_no_tools(spec, fallback_messages)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _drop_malformed_tool_calls(
|
||||
response: LLMResponse,
|
||||
) -> tuple[int, bool, str | None]:
|
||||
"""Strip tool calls whose name is missing/non-string from the response.
|
||||
|
||||
Returns (dropped_count, all_dropped, original_finish_reason).
|
||||
|
||||
A degenerate call (name=None or "") cannot be executed, and if it were
|
||||
persisted into the assistant message it would be replayed on every
|
||||
subsequent turn, causing upstream validation errors
|
||||
(``tool_use.name: Input should be a valid string``) that permanently
|
||||
wedge the session. Dropping it here keeps it out of execution, the
|
||||
assistant message, and the saved history in one place.
|
||||
"""
|
||||
calls = getattr(response, "tool_calls", None)
|
||||
if not calls:
|
||||
return (0, False, getattr(response, "finish_reason", None))
|
||||
valid = [tc for tc in calls if tc.has_valid_name()]
|
||||
if len(valid) == len(calls):
|
||||
return (0, False, getattr(response, "finish_reason", None))
|
||||
dropped = len(calls) - len(valid)
|
||||
original_finish_reason = getattr(response, "finish_reason", None)
|
||||
logger.warning(
|
||||
"Dropped {} malformed tool call(s) with missing/non-string name "
|
||||
"from LLM response (finish_reason={!r})",
|
||||
dropped,
|
||||
original_finish_reason,
|
||||
)
|
||||
response.tool_calls = valid
|
||||
if not valid:
|
||||
response.finish_reason = "stop"
|
||||
return (dropped, not valid, original_finish_reason)
|
||||
|
||||
@staticmethod
|
||||
def _malformed_tool_call_retry_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
assistant_text: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
retry_messages = list(messages)
|
||||
note = (
|
||||
"The previous model response attempted to call tools, but every tool call "
|
||||
"was malformed: the tool_use blocks had missing or non-string tool names. "
|
||||
"Do not answer with a promise to use tools. Either call the required tools again "
|
||||
"using valid tool names from the provided tool list and JSON object inputs, or give "
|
||||
"a final answer only if no tool is required."
|
||||
)
|
||||
if assistant_text:
|
||||
note += (
|
||||
f"\n\nPrevious assistant text before the malformed calls:\n"
|
||||
f"{assistant_text}"
|
||||
)
|
||||
retry_messages.append({"role": "user", "content": note})
|
||||
return retry_messages
|
||||
|
||||
async def _request_finalization_retry(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
@@ -1159,7 +1266,7 @@ class AgentRunner:
|
||||
return payload, event, exc
|
||||
return payload, event, None
|
||||
|
||||
if isinstance(result, str) and result.startswith("Error"):
|
||||
if is_tool_error_result(tool_call.name, result):
|
||||
if file_edit_trackers and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
@@ -1325,225 +1432,6 @@ class AgentRunner:
|
||||
return
|
||||
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
|
||||
|
||||
def _normalize_tool_result(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Any,
|
||||
) -> Any:
|
||||
result = ensure_nonempty_tool_result(tool_name, result)
|
||||
if tool_name in _TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
|
||||
# Exempt tools bound their own output; skip generic offload and truncation.
|
||||
return result
|
||||
try:
|
||||
content = maybe_persist_tool_result(
|
||||
spec.workspace,
|
||||
spec.session_key,
|
||||
tool_call_id,
|
||||
result,
|
||||
max_chars=spec.max_tool_result_chars,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Tool result persist failed for {} in {}; using raw result",
|
||||
tool_call_id,
|
||||
spec.session_key or "default",
|
||||
)
|
||||
content = result
|
||||
if isinstance(content, str) and len(content) > spec.max_tool_result_chars:
|
||||
return truncate_text(content, spec.max_tool_result_chars)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _drop_orphan_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop tool results that have no matching assistant tool_call earlier in the history."""
|
||||
declared: set[str] = set()
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
declared.add(str(tc["id"]))
|
||||
if role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid and str(tid) not in declared:
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(dict(msg))
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _backfill_missing_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert synthetic error results for orphaned tool_use blocks."""
|
||||
declared: list[tuple[int, str, str]] = [] # (assistant_idx, call_id, name)
|
||||
fulfilled: set[str] = set()
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
name = ""
|
||||
func = tc.get("function")
|
||||
if isinstance(func, dict):
|
||||
name = func.get("name", "")
|
||||
declared.append((idx, str(tc["id"]), name))
|
||||
elif role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid:
|
||||
fulfilled.add(str(tid))
|
||||
|
||||
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
|
||||
if not missing:
|
||||
return messages
|
||||
|
||||
updated = list(messages)
|
||||
offset = 0
|
||||
for assistant_idx, call_id, name in missing:
|
||||
insert_at = assistant_idx + 1 + offset
|
||||
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
|
||||
insert_at += 1
|
||||
updated.insert(insert_at, {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": _BACKFILL_CONTENT,
|
||||
})
|
||||
offset += 1
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _microcompact(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Replace old compactable tool results with one-line summaries."""
|
||||
compactable_indices: list[int] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") == "tool" and msg.get("name") in _COMPACTABLE_TOOLS:
|
||||
compactable_indices.append(idx)
|
||||
|
||||
if len(compactable_indices) <= _MICROCOMPACT_KEEP_RECENT:
|
||||
return messages
|
||||
|
||||
stale = compactable_indices[: len(compactable_indices) - _MICROCOMPACT_KEEP_RECENT]
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx in stale:
|
||||
msg = messages[idx]
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) < _MICROCOMPACT_MIN_CHARS:
|
||||
continue
|
||||
name = msg.get("name", "tool")
|
||||
summary = f"[{name} result omitted from context]"
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = summary
|
||||
|
||||
return updated if updated is not None else messages
|
||||
|
||||
def _apply_tool_result_budget(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = messages
|
||||
for idx, message in enumerate(messages):
|
||||
if message.get("role") != "tool":
|
||||
continue
|
||||
normalized = self._normalize_tool_result(
|
||||
spec,
|
||||
str(message.get("tool_call_id") or f"tool_{idx}"),
|
||||
str(message.get("name") or "tool"),
|
||||
message.get("content"),
|
||||
)
|
||||
if normalized != message.get("content"):
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = normalized
|
||||
return updated
|
||||
|
||||
def _snip_history(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not messages or not spec.context_window_tokens:
|
||||
return messages
|
||||
|
||||
provider_max_tokens = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
|
||||
max_output = spec.max_tokens if isinstance(spec.max_tokens, int) else (
|
||||
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
|
||||
)
|
||||
budget = spec.context_block_limit or (
|
||||
spec.context_window_tokens - max_output - _SNIP_SAFETY_BUFFER
|
||||
)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
estimate, _ = estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
spec.model,
|
||||
messages,
|
||||
spec.tools.get_definitions(),
|
||||
)
|
||||
if estimate <= budget:
|
||||
return messages
|
||||
|
||||
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
|
||||
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
|
||||
if not non_system:
|
||||
return messages
|
||||
|
||||
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
||||
fixed_tokens, _ = estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
spec.model,
|
||||
system_messages,
|
||||
spec.tools.get_definitions(),
|
||||
)
|
||||
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
|
||||
kept: list[dict[str, Any]] = []
|
||||
kept_tokens = 0
|
||||
for message in reversed(non_system):
|
||||
msg_tokens = estimate_message_tokens(message)
|
||||
if kept and kept_tokens + msg_tokens > remaining_budget:
|
||||
break
|
||||
kept.append(message)
|
||||
kept_tokens += msg_tokens
|
||||
kept.reverse()
|
||||
|
||||
if kept:
|
||||
for i, message in enumerate(kept):
|
||||
if message.get("role") == "user":
|
||||
kept = kept[i:]
|
||||
break
|
||||
else:
|
||||
# Recover nearest user message from outside the kept window;
|
||||
# GLM rejects system→assistant (error 1214). Budget is
|
||||
# intentionally exceeded — oversized beats invalid.
|
||||
for idx in range(len(non_system) - 1, -1, -1):
|
||||
if non_system[idx].get("role") == "user":
|
||||
kept = non_system[idx:]
|
||||
break
|
||||
# If no user exists at all, _enforce_role_alternation
|
||||
# will insert a synthetic one as a safety net.
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
if not kept:
|
||||
kept = non_system[-min(len(non_system), 4) :]
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
return system_messages + kept
|
||||
|
||||
def _partition_tool_batches(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
|
||||
@@ -86,6 +86,7 @@ class SubagentManager:
|
||||
disabled_skills: list[str] | None = None,
|
||||
max_iterations: int | None = None,
|
||||
max_concurrent_subagents: int | None = None,
|
||||
fail_on_tool_error: bool | None = None,
|
||||
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
||||
):
|
||||
defaults = AgentDefaults()
|
||||
@@ -107,6 +108,11 @@ class SubagentManager:
|
||||
if max_concurrent_subagents is not None
|
||||
else defaults.max_concurrent_subagents
|
||||
)
|
||||
self.fail_on_tool_error = (
|
||||
fail_on_tool_error
|
||||
if fail_on_tool_error is not None
|
||||
else defaults.fail_on_tool_error
|
||||
)
|
||||
self.runner = AgentRunner(provider)
|
||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
@@ -251,7 +257,7 @@ class SubagentManager:
|
||||
max_iterations_message="Task completed but no final response was generated.",
|
||||
finalize_on_max_iterations=False,
|
||||
error_message=None,
|
||||
fail_on_tool_error=True,
|
||||
fail_on_tool_error=self.fail_on_tool_error,
|
||||
checkpoint_callback=_on_checkpoint,
|
||||
session_key=sess_key,
|
||||
workspace=root,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Agent tools module."""
|
||||
|
||||
from nanobot.agent.tools.base import Schema, Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
@@ -25,6 +25,7 @@ __all__ = [
|
||||
"Tool",
|
||||
"ToolContext",
|
||||
"ToolLoader",
|
||||
"ToolResult",
|
||||
"ToolRegistry",
|
||||
"tool_parameters",
|
||||
"tool_parameters_schema",
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import tool_parameters
|
||||
from nanobot.agent.tools.base import ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.filesystem import _FsTool
|
||||
from nanobot.agent.tools.schema import (
|
||||
ArraySchema,
|
||||
@@ -289,8 +289,8 @@ class ApplyPatchTool(_FsTool):
|
||||
_format_summary(summary) for summary in summaries
|
||||
)
|
||||
except PermissionError as exc:
|
||||
return f"Error: {exc}"
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
except _PatchError as exc:
|
||||
return f"Error applying patch: {exc}"
|
||||
return ToolResult.error(f"Error applying patch: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error applying patch: {exc}"
|
||||
return ToolResult.error(f"Error applying patch: {exc}")
|
||||
|
||||
@@ -128,6 +128,21 @@ class Schema(ABC):
|
||||
return Schema.validate_json_schema_value(value, self.to_json_schema(), path)
|
||||
|
||||
|
||||
class ToolResult(str):
|
||||
"""String-compatible tool output with structured status."""
|
||||
|
||||
is_error: bool
|
||||
|
||||
def __new__(cls, content: str, *, is_error: bool = False) -> ToolResult:
|
||||
obj = str.__new__(cls, content)
|
||||
obj.is_error = is_error
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def error(cls, content: str) -> ToolResult:
|
||||
return cls(content, is_error=True)
|
||||
|
||||
|
||||
class Tool(ABC):
|
||||
"""Agent capability: read files, run commands, etc."""
|
||||
|
||||
@@ -193,9 +208,13 @@ class Tool(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
"""Run the tool; returns a string or list of content blocks."""
|
||||
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def error(content: str) -> ToolResult:
|
||||
return ToolResult.error(content)
|
||||
|
||||
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(obj, dict):
|
||||
return obj
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.schema import (
|
||||
ArraySchema,
|
||||
BooleanSchema,
|
||||
@@ -136,4 +136,4 @@ class CliAppsTool(Tool):
|
||||
restrict_to_workspace=access.restrict_to_workspace,
|
||||
)
|
||||
except CliAppError as exc:
|
||||
return f"Error: {exc.message}"
|
||||
return ToolResult.error(f"Error: {exc.message}")
|
||||
|
||||
+10
-10
@@ -6,7 +6,7 @@ from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.schema import (
|
||||
IntegerSchema,
|
||||
@@ -99,7 +99,7 @@ class CronTool(Tool, ContextAware):
|
||||
try:
|
||||
ZoneInfo(tz)
|
||||
except (KeyError, Exception):
|
||||
return f"Error: unknown timezone '{tz}'"
|
||||
return ToolResult.error(f"Error: unknown timezone '{tz}'")
|
||||
return None
|
||||
|
||||
def _display_timezone(self, schedule: CronSchedule) -> str:
|
||||
@@ -148,7 +148,7 @@ class CronTool(Tool, ContextAware):
|
||||
) -> str:
|
||||
if action == "add":
|
||||
if self._in_cron_context.get():
|
||||
return "Error: cannot schedule new jobs from within a cron job execution"
|
||||
return ToolResult.error("Error: cannot schedule new jobs from within a cron job execution")
|
||||
return self._add_job(name, message, every_seconds, cron_expr, tz, at)
|
||||
elif action == "list":
|
||||
return self._list_jobs()
|
||||
@@ -166,20 +166,20 @@ class CronTool(Tool, ContextAware):
|
||||
at: str | None,
|
||||
) -> str:
|
||||
if not message:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: cron action='add' requires a non-empty 'message' parameter "
|
||||
"describing what to do when the job triggers "
|
||||
"(e.g. the reminder text). Retry including message=\"...\"."
|
||||
)
|
||||
session_key = self._session_key.get()
|
||||
if not session_key:
|
||||
return "Error: scheduled cron jobs must be created from a chat session"
|
||||
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
||||
origin_channel = self._origin_channel.get()
|
||||
origin_chat_id = self._origin_chat_id.get()
|
||||
if not origin_channel or not origin_chat_id:
|
||||
return "Error: scheduled cron jobs must be created from a chat session"
|
||||
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
||||
if tz and not cron_expr:
|
||||
return "Error: tz can only be used with cron_expr"
|
||||
return ToolResult.error("Error: tz can only be used with cron_expr")
|
||||
if tz:
|
||||
if err := self._validate_timezone(tz):
|
||||
return err
|
||||
@@ -199,7 +199,7 @@ class CronTool(Tool, ContextAware):
|
||||
try:
|
||||
dt = datetime.fromisoformat(at)
|
||||
except ValueError:
|
||||
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS"
|
||||
return ToolResult.error(f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS")
|
||||
if dt.tzinfo is None:
|
||||
if err := self._validate_timezone(self._default_timezone):
|
||||
return err
|
||||
@@ -208,7 +208,7 @@ class CronTool(Tool, ContextAware):
|
||||
schedule = CronSchedule(kind="at", at_ms=at_ms)
|
||||
delete_after = True
|
||||
else:
|
||||
return "Error: either every_seconds, cron_expr, or at is required"
|
||||
return ToolResult.error("Error: either every_seconds, cron_expr, or at is required")
|
||||
|
||||
job = self._cron.add_job(
|
||||
name=name or message[:30],
|
||||
@@ -279,7 +279,7 @@ class CronTool(Tool, ContextAware):
|
||||
|
||||
def _remove_job(self, job_id: str | None) -> str:
|
||||
if not job_id:
|
||||
return "Error: job_id is required for remove"
|
||||
return ToolResult.error("Error: job_id is required for remove")
|
||||
result = self._cron.remove_job(job_id)
|
||||
if result == "removed":
|
||||
return f"Removed job {job_id}"
|
||||
|
||||
@@ -9,7 +9,7 @@ from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import current_request_session_key
|
||||
from nanobot.agent.tools.schema import (
|
||||
BooleanSchema,
|
||||
@@ -492,11 +492,12 @@ class WriteStdinTool(Tool):
|
||||
max_output_chars=output_limit,
|
||||
owner_session_key=current_request_session_key(),
|
||||
)
|
||||
return format_session_poll(session_id, poll)
|
||||
result = format_session_poll(session_id, poll)
|
||||
return ToolResult.error(result) if poll.timed_out else result
|
||||
except KeyError:
|
||||
return f"Error: exec session not found: {session_id}"
|
||||
return ToolResult.error(f"Error: exec session not found: {session_id!r}")
|
||||
except Exception as exc:
|
||||
return f"Error writing to exec session: {exc}"
|
||||
return ToolResult.error(f"Error writing to exec session: {exc}")
|
||||
|
||||
async def _wait_for_output(
|
||||
self,
|
||||
@@ -532,13 +533,14 @@ class WriteStdinTool(Tool):
|
||||
joined = "".join(aggregate)
|
||||
if wait_for in joined:
|
||||
poll.output = joined
|
||||
return format_session_poll(session_id, poll)
|
||||
result = format_session_poll(session_id, poll)
|
||||
return ToolResult.error(result) if poll.timed_out else result
|
||||
if poll.done or remaining_ms <= 0:
|
||||
poll.output = "".join(aggregate)
|
||||
result = format_session_poll(session_id, poll)
|
||||
if wait_for not in poll.output:
|
||||
result += f"\nWait target not observed: {wait_for!r}"
|
||||
return result
|
||||
return ToolResult.error(result) if poll.timed_out else result
|
||||
|
||||
|
||||
@tool_parameters(tool_parameters_schema())
|
||||
@@ -606,4 +608,4 @@ class ListExecSessionsTool(Tool):
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception as exc:
|
||||
return f"Error listing exec sessions: {exc}"
|
||||
return ToolResult.error(f"Error listing exec sessions: {exc}")
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||
from nanobot.agent.tools.schema import (
|
||||
@@ -268,19 +268,19 @@ class ReadFileTool(_FsTool):
|
||||
) -> Any:
|
||||
try:
|
||||
if not path:
|
||||
return "Error reading file: Unknown path"
|
||||
return ToolResult.error("Error reading file: Unknown path")
|
||||
|
||||
# Device path blacklist
|
||||
if _is_blocked_device(path):
|
||||
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
|
||||
return ToolResult.error(f"Error: Reading {path} is blocked (device path that could hang or produce infinite output).")
|
||||
|
||||
fp = self._resolve_read(path)
|
||||
if _is_blocked_device(fp):
|
||||
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
|
||||
return ToolResult.error(f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output).")
|
||||
if not fp.exists():
|
||||
return f"Error: File not found: {path}"
|
||||
return ToolResult.error(f"Error: File not found: {path}")
|
||||
if not fp.is_file():
|
||||
return f"Error: Not a file: {path}"
|
||||
return ToolResult.error(f"Error: Not a file: {path}")
|
||||
|
||||
# PDF support
|
||||
if fp.suffix.lower() == ".pdf":
|
||||
@@ -343,7 +343,7 @@ class ReadFileTool(_FsTool):
|
||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
||||
if mime and mime.startswith("image/"):
|
||||
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
||||
return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported."
|
||||
return ToolResult.error(f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported.")
|
||||
|
||||
# Normalize CRLF -> LF before line-splitting. Primarily a Windows
|
||||
# concern (git checkouts with autocrlf, editors saving CRLF) but
|
||||
@@ -357,7 +357,7 @@ class ReadFileTool(_FsTool):
|
||||
if offset < 1:
|
||||
offset = 1
|
||||
if offset > total:
|
||||
return f"Error: offset {offset} is beyond end of file ({total} lines)"
|
||||
return ToolResult.error(f"Error: offset {offset} is beyond end of file ({total} lines)")
|
||||
|
||||
start = offset - 1
|
||||
end = min(start + (limit or self._DEFAULT_LIMIT), total)
|
||||
@@ -381,20 +381,20 @@ class ReadFileTool(_FsTool):
|
||||
self._file_states.record_read(fp, offset=offset, limit=limit)
|
||||
return result
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error reading file: {e}"
|
||||
return ToolResult.error(f"Error reading file: {e}")
|
||||
|
||||
def _read_pdf(self, fp: Path, pages: str | None) -> str:
|
||||
try:
|
||||
import fitz # pymupdf
|
||||
except ImportError:
|
||||
return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf"
|
||||
return ToolResult.error("Error: PDF reading requires pymupdf. Install with: pip install pymupdf")
|
||||
|
||||
try:
|
||||
doc = fitz.open(str(fp))
|
||||
except Exception as e:
|
||||
return f"Error reading PDF: {e}"
|
||||
return ToolResult.error(f"Error reading PDF: {e}")
|
||||
|
||||
total_pages = len(doc)
|
||||
if pages:
|
||||
@@ -402,10 +402,10 @@ class ReadFileTool(_FsTool):
|
||||
start, end = _parse_page_range(pages, total_pages)
|
||||
except (ValueError, IndexError):
|
||||
doc.close()
|
||||
return f"Error: Invalid page range '{pages}'. Use format like '1-5'."
|
||||
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
|
||||
if start > end or start >= total_pages:
|
||||
doc.close()
|
||||
return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)."
|
||||
return ToolResult.error(f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages).")
|
||||
else:
|
||||
start = 0
|
||||
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
|
||||
@@ -437,10 +437,10 @@ class ReadFileTool(_FsTool):
|
||||
result = extract_text(fp)
|
||||
|
||||
if result is None:
|
||||
return f"Error: Unsupported file format: {fp.suffix}"
|
||||
return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
|
||||
|
||||
if result.startswith("[error:"):
|
||||
return f"Error reading {fp.suffix.upper()} file: {result}"
|
||||
return ToolResult.error(f"Error reading {fp.suffix.upper()} file: {result}")
|
||||
|
||||
if not result:
|
||||
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
|
||||
@@ -492,9 +492,9 @@ class WriteFileTool(_FsTool):
|
||||
self._file_states.record_write(fp)
|
||||
return f"Successfully wrote {len(content)} characters to {fp}"
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error writing file: {e}"
|
||||
return ToolResult.error(f"Error writing file: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -830,11 +830,11 @@ class EditFileTool(_FsTool):
|
||||
if new_text is None:
|
||||
raise ValueError("Unknown new_text")
|
||||
if occurrence is not None and occurrence < 1:
|
||||
return "Error: occurrence must be >= 1."
|
||||
return ToolResult.error("Error: occurrence must be >= 1.")
|
||||
if line_hint is not None and line_hint < 1:
|
||||
return "Error: line_hint must be >= 1."
|
||||
return ToolResult.error("Error: line_hint must be >= 1.")
|
||||
if expected_replacements is not None and expected_replacements < 1:
|
||||
return "Error: expected_replacements must be >= 1."
|
||||
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
||||
|
||||
fp = self._resolve_write(path)
|
||||
|
||||
@@ -853,14 +853,14 @@ class EditFileTool(_FsTool):
|
||||
except OSError:
|
||||
fsize = 0
|
||||
if fsize > self._MAX_EDIT_FILE_SIZE:
|
||||
return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB."
|
||||
return ToolResult.error(f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB.")
|
||||
|
||||
# Create-file: old_text='' but file exists and not empty → reject
|
||||
if old_text == "":
|
||||
raw = fp.read_bytes()
|
||||
content = raw.decode("utf-8")
|
||||
if content.strip():
|
||||
return f"Error: Cannot create file — {path} already exists and is not empty."
|
||||
return ToolResult.error(f"Error: Cannot create file — {path} already exists and is not empty.")
|
||||
fp.write_text(new_text, encoding="utf-8")
|
||||
self._file_states.record_write(fp)
|
||||
return f"Successfully edited {fp}"
|
||||
@@ -878,15 +878,15 @@ class EditFileTool(_FsTool):
|
||||
return self._not_found_msg(old_text, content, path)
|
||||
count = len(matches)
|
||||
if replace_all and occurrence is not None:
|
||||
return "Error: occurrence cannot be used with replace_all=true."
|
||||
return ToolResult.error("Error: occurrence cannot be used with replace_all=true.")
|
||||
if replace_all and line_hint is not None:
|
||||
return "Error: line_hint cannot be used with replace_all=true."
|
||||
return ToolResult.error("Error: line_hint cannot be used with replace_all=true.")
|
||||
if occurrence is not None and line_hint is not None:
|
||||
return "Error: line_hint cannot be used with occurrence."
|
||||
return ToolResult.error("Error: line_hint cannot be used with occurrence.")
|
||||
if count > 1 and not replace_all:
|
||||
if occurrence is not None:
|
||||
if occurrence > count:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: occurrence {occurrence} is out of range; "
|
||||
f"old_text appears {count} times."
|
||||
)
|
||||
@@ -894,7 +894,7 @@ class EditFileTool(_FsTool):
|
||||
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
|
||||
distance = abs(nearest.line - line_hint)
|
||||
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: line_hint {line_hint} is ambiguous; "
|
||||
f"old_text appears {count} times."
|
||||
)
|
||||
@@ -910,7 +910,7 @@ class EditFileTool(_FsTool):
|
||||
"or set replace_all=true."
|
||||
)
|
||||
elif occurrence is not None and occurrence > count:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: occurrence {occurrence} is out of range; "
|
||||
f"old_text appears {count} time."
|
||||
)
|
||||
@@ -928,7 +928,7 @@ class EditFileTool(_FsTool):
|
||||
else:
|
||||
selected = [matches[occurrence - 1 if occurrence else 0]]
|
||||
if expected_replacements is not None and len(selected) != expected_replacements:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: expected {expected_replacements} replacements but "
|
||||
f"would make {len(selected)}."
|
||||
)
|
||||
@@ -954,9 +954,9 @@ class EditFileTool(_FsTool):
|
||||
msg = f"{warning}\n{msg}"
|
||||
return msg
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error editing file: {e}"
|
||||
return ToolResult.error(f"Error editing file: {e}")
|
||||
|
||||
def _file_not_found_msg(self, path: str, fp: Path) -> str:
|
||||
"""Build an error message with 'Did you mean ...?' suggestions."""
|
||||
@@ -969,7 +969,7 @@ class EditFileTool(_FsTool):
|
||||
parts = [f"Error: File not found: {path}"]
|
||||
if suggestions:
|
||||
parts.append("Did you mean: " + ", ".join(suggestions) + "?")
|
||||
return "\n".join(parts)
|
||||
return ToolResult.error("\n".join(parts))
|
||||
|
||||
@staticmethod
|
||||
def _not_found_msg(old_text: str, content: str, path: str) -> str:
|
||||
@@ -985,18 +985,18 @@ class EditFileTool(_FsTool):
|
||||
hint_text = ""
|
||||
if hints:
|
||||
hint_text = "\nPossible cause: " + ", ".join(hints) + "."
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: old_text not found in {path}."
|
||||
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
|
||||
)
|
||||
|
||||
if hints:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: old_text not found in {path}. "
|
||||
f"Possible cause: {', '.join(hints)}. "
|
||||
"Copy the exact text from read_file and try again."
|
||||
)
|
||||
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
|
||||
return ToolResult.error(f"Error: old_text not found in {path}. No similar text found. Verify the file content.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1051,9 +1051,9 @@ class ListDirTool(_FsTool):
|
||||
raise ValueError("Unknown path")
|
||||
dp = self._resolve(path)
|
||||
if not dp.exists():
|
||||
return f"Error: Directory not found: {path}"
|
||||
return ToolResult.error(f"Error: Directory not found: {path}")
|
||||
if not dp.is_dir():
|
||||
return f"Error: Not a directory: {path}"
|
||||
return ToolResult.error(f"Error: Not a directory: {path}")
|
||||
|
||||
cap = max_entries or self._DEFAULT_MAX
|
||||
items: list[str] = []
|
||||
@@ -1084,6 +1084,6 @@ class ListDirTool(_FsTool):
|
||||
result += f"\n\n(truncated, showing first {cap} of {total} entries)"
|
||||
return result
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error listing directory: {e}"
|
||||
return ToolResult.error(f"Error listing directory: {e}")
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.schema import (
|
||||
ArraySchema,
|
||||
IntegerSchema,
|
||||
@@ -172,11 +172,11 @@ class ImageGenerationTool(Tool):
|
||||
) -> str:
|
||||
client = self._provider_client()
|
||||
if client is None:
|
||||
return f"Error: unsupported image generation provider '{self.config.provider}'"
|
||||
return ToolResult.error(f"Error: unsupported image generation provider '{self.config.provider}'")
|
||||
|
||||
requested = count or 1
|
||||
if requested > self.config.max_images_per_turn:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: count exceeds tools.imageGeneration.maxImagesPerTurn "
|
||||
f"({self.config.max_images_per_turn})"
|
||||
)
|
||||
@@ -206,4 +206,4 @@ class ImageGenerationTool(Tool):
|
||||
break
|
||||
return generated_image_tool_result(artifacts)
|
||||
except (ArtifactError, ImageGenerationError, OSError) as exc:
|
||||
return f"Error: {exc}"
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.base import Tool, ToolResult
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
_SKIP_MODULES = frozenset({
|
||||
@@ -96,6 +96,8 @@ class ToolLoader:
|
||||
if not tool_cls.enabled(ctx):
|
||||
continue
|
||||
tool = tool_cls.create(ctx)
|
||||
if is_plugin_source:
|
||||
tool = _LegacyErrorPrefixTool(tool)
|
||||
if registry.has(tool.name):
|
||||
if is_plugin_source and tool.name in builtin_names:
|
||||
logger.warning(
|
||||
@@ -114,3 +116,67 @@ class ToolLoader:
|
||||
except Exception:
|
||||
logger.exception("Failed to register tool: %s", cls_label)
|
||||
return registered
|
||||
|
||||
|
||||
class _LegacyErrorPrefixTool(Tool):
|
||||
"""Compatibility wrapper for external tools using the old error-string contract."""
|
||||
|
||||
_plugin_discoverable = False
|
||||
|
||||
def __init__(self, wrapped: Tool) -> None:
|
||||
self._wrapped = wrapped
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._wrapped.name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self._wrapped.description
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return self._wrapped.parameters
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return self._wrapped.read_only
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return self._wrapped.exclusive
|
||||
|
||||
@property
|
||||
def concurrency_safe(self) -> bool:
|
||||
return self._wrapped.concurrency_safe
|
||||
|
||||
@property
|
||||
def config_key(self) -> str:
|
||||
return getattr(self._wrapped, "config_key", "")
|
||||
|
||||
def set_context(self, ctx: Any) -> None:
|
||||
set_context = getattr(self._wrapped, "set_context", None)
|
||||
if callable(set_context):
|
||||
set_context(ctx)
|
||||
|
||||
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._wrapped.cast_params(params)
|
||||
|
||||
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
||||
return self._wrapped.validate_params(params)
|
||||
|
||||
def to_schema(self) -> dict[str, Any]:
|
||||
return self._wrapped.to_schema()
|
||||
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
result = await self._wrapped.execute(**kwargs)
|
||||
if (
|
||||
isinstance(result, str)
|
||||
and not isinstance(result, ToolResult)
|
||||
and result.startswith("Error:")
|
||||
):
|
||||
return ToolResult.error(result)
|
||||
return result
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._wrapped, name)
|
||||
|
||||
@@ -20,7 +20,7 @@ from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
||||
@@ -150,12 +150,12 @@ class LongTaskTool(Tool, _GoalToolsMixin):
|
||||
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
|
||||
sess = self._session()
|
||||
if sess is None:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: long_task requires an active chat session (missing routing context)."
|
||||
)
|
||||
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
||||
if isinstance(prior, dict) and prior.get("status") == "active":
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: a sustained goal is already active. "
|
||||
"Use complete_goal when finished, or ask the user before replacing it."
|
||||
)
|
||||
@@ -230,7 +230,7 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
|
||||
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
|
||||
sess = self._session()
|
||||
if sess is None:
|
||||
return "Error: complete_goal requires an active chat session."
|
||||
return ToolResult.error("Error: complete_goal requires an active chat session.")
|
||||
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
||||
if not isinstance(prior, dict) or prior.get("status") != "active":
|
||||
return "No active goal to complete."
|
||||
|
||||
+175
-36
@@ -1,6 +1,7 @@
|
||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -13,7 +14,7 @@ from weakref import WeakKeyDictionary
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.base import Tool, ToolResult
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
@@ -165,12 +166,31 @@ async def _probe_http_url(url: str, timeout: float = 3.0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _redact_url(url: str) -> str:
|
||||
"""Strip credentials and query/fragment before logging an MCP URL.
|
||||
|
||||
Server URLs may embed secrets (``https://user:token@host/sse`` or a
|
||||
``?token=`` query). Some deployments also put opaque tokens in the path, so
|
||||
log only the origin and a path placeholder.
|
||||
"""
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
hostname = parts.hostname or ""
|
||||
netloc = f"[{hostname}]" if ":" in hostname else hostname
|
||||
if parts.port:
|
||||
netloc = f"{netloc}:{parts.port}"
|
||||
path = "/..." if parts.path and parts.path != "/" else parts.path
|
||||
return urllib.parse.urlunsplit((parts.scheme, netloc, path, "", ""))
|
||||
except Exception:
|
||||
return "<redacted-url>"
|
||||
|
||||
|
||||
async def _validate_mcp_request_url(request: httpx.Request) -> None:
|
||||
"""Validate each outgoing MCP HTTP request, including redirect targets."""
|
||||
ok, error = validate_url_target(str(request.url))
|
||||
if not ok:
|
||||
raise httpx.RequestError(
|
||||
f"Blocked unsafe MCP URL {request.url} ({error})",
|
||||
f"Blocked unsafe MCP URL {_redact_url(str(request.url))} ({error})",
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -313,6 +333,52 @@ class _MCPWrapperBase(Tool):
|
||||
return True
|
||||
|
||||
|
||||
def _image_block_data_url(block: Any, types: Any) -> str | None:
|
||||
"""Return a base64 ``data:`` URL for an MCP image-bearing content block.
|
||||
|
||||
Handles ``ImageContent`` directly and ``EmbeddedResource`` wrapping a binary
|
||||
blob with an ``image/*`` MIME type. Returns ``None`` for anything else.
|
||||
``getattr`` guards keep this safe when the installed/faked ``mcp`` SDK does
|
||||
not expose a given type.
|
||||
"""
|
||||
image_cls = getattr(types, "ImageContent", None)
|
||||
if image_cls is not None and isinstance(block, image_cls):
|
||||
mime = getattr(block, "mimeType", None) or "image/png"
|
||||
return f"data:{mime};base64,{block.data}"
|
||||
|
||||
embedded_cls = getattr(types, "EmbeddedResource", None)
|
||||
blob_cls = getattr(types, "BlobResourceContents", None)
|
||||
if embedded_cls is not None and isinstance(block, embedded_cls):
|
||||
resource = getattr(block, "resource", None)
|
||||
if blob_cls is not None and isinstance(resource, blob_cls):
|
||||
mime = getattr(resource, "mimeType", None) or ""
|
||||
if isinstance(mime, str) and mime.startswith("image/"):
|
||||
return f"data:{mime};base64,{resource.blob}"
|
||||
return None
|
||||
|
||||
|
||||
def _mcp_image_tool_result(text_parts: list[str], artifacts: list[dict[str, Any]]) -> str:
|
||||
"""Build the compact tool result for an MCP call that returned image(s).
|
||||
|
||||
The base64 stays out of the model context entirely — only artifact paths and
|
||||
metadata are returned, so the result is small and the channel can deliver the
|
||||
saved file via the message tool.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"artifacts": artifacts,
|
||||
"next_step": (
|
||||
"These images were returned by an MCP tool and saved as local artifacts. "
|
||||
"Call the message tool with the artifact 'path' values in the media "
|
||||
"parameter to deliver the images to the user. Do not paste base64 or raw "
|
||||
"paths into your reply unless the user asks for debug details."
|
||||
),
|
||||
}
|
||||
text = "\n".join(part for part in text_parts if part)
|
||||
if text:
|
||||
payload["text"] = text
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
class MCPToolWrapper(_MCPWrapperBase):
|
||||
"""Wraps a single MCP server tool as a nanobot Tool."""
|
||||
|
||||
@@ -340,8 +406,6 @@ class MCPToolWrapper(_MCPWrapperBase):
|
||||
return self._parameters
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
from mcp import types
|
||||
|
||||
retried_transient = False
|
||||
refreshed_session = False
|
||||
while True:
|
||||
@@ -396,17 +460,66 @@ class MCPToolWrapper(_MCPWrapperBase):
|
||||
)
|
||||
return f"(MCP tool call failed: {type(exc).__name__})"
|
||||
else:
|
||||
# Success — extract result
|
||||
parts = []
|
||||
for block in result.content:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
else:
|
||||
parts.append(str(block))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
# Success — extract text and persist any image content as artifacts.
|
||||
rendered = self._render_call_result(result.content, kwargs)
|
||||
if getattr(result, "isError", False):
|
||||
return ToolResult.error(rendered)
|
||||
return rendered
|
||||
|
||||
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
|
||||
|
||||
def _render_call_result(self, content: Any, arguments: Mapping[str, Any]) -> str:
|
||||
"""Turn MCP content blocks into a tool result string.
|
||||
|
||||
Text is concatenated as before. Image blocks are decoded and saved as
|
||||
local artifacts (mirroring the built-in image generation tool) so the
|
||||
model can deliver them via the message tool instead of trying to forward
|
||||
base64 — which would be truncated and bloat the context window.
|
||||
"""
|
||||
from mcp import types
|
||||
|
||||
text_parts: list[str] = []
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
for block in content:
|
||||
if isinstance(block, types.TextContent):
|
||||
text_parts.append(block.text)
|
||||
continue
|
||||
data_url = _image_block_data_url(block, types)
|
||||
if data_url is not None:
|
||||
stored = self._store_image_block(data_url, arguments)
|
||||
if stored is not None:
|
||||
artifacts.append(stored)
|
||||
else:
|
||||
text_parts.append("(MCP tool returned an image that could not be stored)")
|
||||
continue
|
||||
text_parts.append(str(block))
|
||||
|
||||
if artifacts:
|
||||
return _mcp_image_tool_result(text_parts, artifacts)
|
||||
return "\n".join(text_parts) or "(no output)"
|
||||
|
||||
def _store_image_block(
|
||||
self, data_url: str, arguments: Mapping[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist one image data URL as an artifact; return its metadata or None."""
|
||||
from nanobot.utils.artifacts import ArtifactError, store_generated_image_artifact
|
||||
|
||||
try:
|
||||
return store_generated_image_artifact(
|
||||
data_url,
|
||||
prompt=str(arguments.get("prompt") or ""),
|
||||
model=str(arguments.get("model") or ""),
|
||||
save_dir="generated",
|
||||
provider=f"mcp:{self._server_name}",
|
||||
)
|
||||
except (ArtifactError, OSError) as exc:
|
||||
logger.warning(
|
||||
"MCP tool '{}' returned an image that could not be stored: {}",
|
||||
self._name,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class MCPResourceWrapper(_MCPWrapperBase):
|
||||
"""Wraps an MCP resource URI as a read-only nanobot Tool."""
|
||||
@@ -683,7 +796,7 @@ async def connect_mcp_servers(
|
||||
logger.warning(
|
||||
"MCP server '{}': blocked unsafe URL {} ({})",
|
||||
name,
|
||||
cfg.url,
|
||||
_redact_url(cfg.url),
|
||||
error,
|
||||
)
|
||||
await server_stack.aclose()
|
||||
@@ -704,7 +817,7 @@ async def connect_mcp_servers(
|
||||
read, write = await server_stack.enter_async_context(stdio_client(params))
|
||||
elif transport_type == "sse":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, cfg.url)
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
@@ -731,7 +844,7 @@ async def connect_mcp_servers(
|
||||
)
|
||||
elif transport_type == "streamableHttp":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, cfg.url)
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
@@ -797,31 +910,57 @@ async def connect_mcp_servers(
|
||||
", ".join(available_wrapped_names) or "(none)",
|
||||
)
|
||||
|
||||
try:
|
||||
resources_result = await session.list_resources()
|
||||
for resource in resources_result.resources:
|
||||
wrapper = MCPResourceWrapper(
|
||||
session, name, resource, resource_timeout=cfg.tool_timeout
|
||||
)
|
||||
registry.register(wrapper)
|
||||
registered_count += 1
|
||||
# Only register resources and prompts when no tool restriction is
|
||||
# active. enabledTools is a per-*tool* allowlist; resources and
|
||||
# prompts have no equivalent name filter, so they must be skipped
|
||||
# whenever the operator specified a tool subset. An empty list
|
||||
# (deny-all) or a list of specific tool names both indicate that
|
||||
# the operator intended to restrict capabilities — registering
|
||||
# unrestricted resource/prompt wrappers would violate that intent.
|
||||
# The default ["*"] (allow-all) means no restriction was intended.
|
||||
register_extras = allow_all_tools
|
||||
if register_extras:
|
||||
try:
|
||||
resources_result = await session.list_resources()
|
||||
for resource in resources_result.resources:
|
||||
wrapper = MCPResourceWrapper(
|
||||
session, name, resource, resource_timeout=cfg.tool_timeout
|
||||
)
|
||||
registry.register(wrapper)
|
||||
registered_count += 1
|
||||
logger.debug(
|
||||
"MCP: registered resource '{}' from server '{}'",
|
||||
wrapper.name,
|
||||
name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"MCP: registered resource '{}' from server '{}'", wrapper.name, name
|
||||
"MCP server '{}': resources not supported or failed: {}", name, e
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("MCP server '{}': resources not supported or failed: {}", name, e)
|
||||
|
||||
try:
|
||||
prompts_result = await session.list_prompts()
|
||||
for prompt in prompts_result.prompts:
|
||||
wrapper = MCPPromptWrapper(
|
||||
session, name, prompt, prompt_timeout=cfg.tool_timeout
|
||||
try:
|
||||
prompts_result = await session.list_prompts()
|
||||
for prompt in prompts_result.prompts:
|
||||
wrapper = MCPPromptWrapper(
|
||||
session, name, prompt, prompt_timeout=cfg.tool_timeout
|
||||
)
|
||||
registry.register(wrapper)
|
||||
registered_count += 1
|
||||
logger.debug(
|
||||
"MCP: registered prompt '{}' from server '{}'",
|
||||
wrapper.name,
|
||||
name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"MCP server '{}': prompts not supported or failed: {}", name, e
|
||||
)
|
||||
registry.register(wrapper)
|
||||
registered_count += 1
|
||||
logger.debug("MCP: registered prompt '{}' from server '{}'", wrapper.name, name)
|
||||
except Exception as e:
|
||||
logger.debug("MCP server '{}': prompts not supported or failed: {}", name, e)
|
||||
else:
|
||||
logger.info(
|
||||
"MCP server '{}': skipping resource/prompt registration "
|
||||
"(enabledTools does not include '*' — only tools allowed)",
|
||||
name,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
||||
@@ -198,7 +198,7 @@ class MessageTool(Tool, ContextAware):
|
||||
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
|
||||
for row in buttons
|
||||
):
|
||||
return "Error: buttons must be a list of list of strings"
|
||||
return ToolResult.error("Error: buttons must be a list of list of strings")
|
||||
default_channel = self._default_channel.get()
|
||||
default_chat_id = self._default_chat_id.get()
|
||||
channel = channel or default_channel
|
||||
@@ -210,7 +210,7 @@ class MessageTool(Tool, ContextAware):
|
||||
and str(explicit_chat_id).strip() != ""
|
||||
and str(explicit_chat_id).strip() != str(default_chat_id).strip()
|
||||
):
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: chat_id does not match the active WebSocket conversation. "
|
||||
"Omit chat_id (and usually channel) so delivery uses the current "
|
||||
"conversation id from context — WebSocket client_id strings "
|
||||
@@ -229,16 +229,16 @@ class MessageTool(Tool, ContextAware):
|
||||
message_id = None
|
||||
|
||||
if not channel or not chat_id:
|
||||
return "Error: No target channel/chat specified"
|
||||
return ToolResult.error("Error: No target channel/chat specified")
|
||||
|
||||
if not self._send_callback:
|
||||
return "Error: Message sending not configured"
|
||||
return ToolResult.error("Error: Message sending not configured")
|
||||
|
||||
if media:
|
||||
try:
|
||||
media = self._resolve_media(media)
|
||||
except (OSError, PermissionError, ValueError) as e:
|
||||
return f"Error: media path is not allowed: {str(e)}"
|
||||
return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
|
||||
|
||||
metadata = dict(self._default_metadata.get()) if same_target else {}
|
||||
if message_id:
|
||||
@@ -270,4 +270,4 @@ class MessageTool(Tool, ContextAware):
|
||||
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
|
||||
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
||||
except Exception as e:
|
||||
return f"Error sending message: {str(e)}"
|
||||
return ToolResult.error(f"Error sending message: {str(e)}")
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.base import Tool, ToolResult
|
||||
|
||||
|
||||
def is_tool_error_result(name: str, result: Any) -> bool:
|
||||
return isinstance(result, ToolResult) and result.is_error
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
@@ -100,22 +104,26 @@ class ToolRegistry:
|
||||
suggestion = self._suggest_name(str(name))
|
||||
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
|
||||
return None, params, (
|
||||
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
|
||||
ToolResult.error(
|
||||
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
|
||||
)
|
||||
)
|
||||
|
||||
params = self._coerce_params(tool, params)
|
||||
if not isinstance(params, dict):
|
||||
return tool, params, (
|
||||
f"Error: Tool '{name}' parameters must be a JSON object, got "
|
||||
f"{type(params).__name__}. Use named parameters like "
|
||||
'tool_name(param1="value1", param2="value2") matching the tool schema.'
|
||||
ToolResult.error(
|
||||
f"Error: Tool '{name}' parameters must be a JSON object, got "
|
||||
f"{type(params).__name__}. Use named parameters like "
|
||||
'tool_name(param1="value1", param2="value2") matching the tool schema.'
|
||||
)
|
||||
)
|
||||
|
||||
cast_params = tool.cast_params(params)
|
||||
errors = tool.validate_params(cast_params)
|
||||
if errors:
|
||||
return tool, cast_params, (
|
||||
f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
||||
ToolResult.error(f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors))
|
||||
)
|
||||
return tool, cast_params, None
|
||||
|
||||
@@ -159,16 +167,16 @@ class ToolRegistry:
|
||||
hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||
tool, params, error = self.prepare_call(name, params)
|
||||
if error:
|
||||
return error + hint
|
||||
return ToolResult.error(str(error) + hint)
|
||||
|
||||
try:
|
||||
assert tool is not None # guarded by prepare_call()
|
||||
result = await tool.execute(**params)
|
||||
if isinstance(result, str) and result.startswith("Error"):
|
||||
return result + hint
|
||||
if is_tool_error_result(name, result):
|
||||
return ToolResult.error(str(result) + hint)
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error executing {name}: {str(e)}" + hint
|
||||
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]:
|
||||
|
||||
@@ -9,6 +9,7 @@ from contextlib import suppress
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Iterable, TypeVar
|
||||
|
||||
from nanobot.agent.tools.base import ToolResult
|
||||
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
||||
|
||||
_DEFAULT_HEAD_LIMIT = 250
|
||||
@@ -218,12 +219,12 @@ class FindFilesTool(_SearchTool):
|
||||
try:
|
||||
target = self._resolve(path or ".")
|
||||
if not target.exists():
|
||||
return f"Error: Path not found: {path}"
|
||||
return ToolResult.error(f"Error: Path not found: {path}")
|
||||
if not (target.is_dir() or target.is_file()):
|
||||
return f"Error: Unsupported path: {path}"
|
||||
return ToolResult.error(f"Error: Unsupported path: {path}")
|
||||
|
||||
if sort not in {"path", "modified"}:
|
||||
return "Error: sort must be 'path' or 'modified'"
|
||||
return ToolResult.error("Error: sort must be 'path' or 'modified'")
|
||||
|
||||
limit = (
|
||||
_DEFAULT_FILE_HEAD_LIMIT
|
||||
@@ -271,9 +272,9 @@ class FindFilesTool(_SearchTool):
|
||||
result += "\n\n" + note
|
||||
return result
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error finding files: {e}"
|
||||
return ToolResult.error(f"Error finding files: {e}")
|
||||
|
||||
|
||||
class GrepTool(_SearchTool):
|
||||
@@ -425,16 +426,16 @@ class GrepTool(_SearchTool):
|
||||
try:
|
||||
target = self._resolve(path or ".")
|
||||
if not target.exists():
|
||||
return f"Error: Path not found: {path}"
|
||||
return ToolResult.error(f"Error: Path not found: {path}")
|
||||
if not (target.is_dir() or target.is_file()):
|
||||
return f"Error: Unsupported path: {path}"
|
||||
return ToolResult.error(f"Error: Unsupported path: {path}")
|
||||
|
||||
flags = re.IGNORECASE if case_insensitive else 0
|
||||
try:
|
||||
needle = re.escape(pattern) if fixed_strings else pattern
|
||||
regex = re.compile(needle, flags)
|
||||
except re.error as e:
|
||||
return f"Error: invalid regex pattern: {e}"
|
||||
return ToolResult.error(f"Error: invalid regex pattern: {e}")
|
||||
|
||||
if head_limit is not None:
|
||||
limit = None if head_limit == 0 else head_limit
|
||||
@@ -579,6 +580,6 @@ class GrepTool(_SearchTool):
|
||||
result += "\n\n" + "\n".join(notes)
|
||||
return result
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error searching files: {e}"
|
||||
return ToolResult.error(f"Error searching files: {e}")
|
||||
|
||||
+27
-24
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.base import Tool, ToolResult
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||
from nanobot.config_base import Base
|
||||
@@ -216,7 +216,7 @@ class MyTool(Tool, ContextAware):
|
||||
@staticmethod
|
||||
def _validate_key(key: str | None, label: str = "key") -> str | None:
|
||||
if not key or not key.strip():
|
||||
return f"Error: '{label}' cannot be empty or whitespace"
|
||||
return ToolResult.error(f"Error: '{label}' cannot be empty or whitespace")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -321,7 +321,7 @@ class MyTool(Tool, ContextAware):
|
||||
if action in ("inspect", "check"):
|
||||
return self._inspect(key)
|
||||
if not self._modify_allowed:
|
||||
return "Error: set is disabled (tools.my.allow_set is false)"
|
||||
return ToolResult.error("Error: set is disabled (tools.my.allow_set is false)")
|
||||
if action in ("modify", "set"):
|
||||
return self._modify(key, value)
|
||||
return f"Unknown action: {action}"
|
||||
@@ -333,7 +333,7 @@ class MyTool(Tool, ContextAware):
|
||||
return self._inspect_all()
|
||||
top = key.split(".")[0]
|
||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||
return f"Error: '{top}' is not accessible"
|
||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||
obj, err = self._resolve_path(key)
|
||||
if err:
|
||||
# "scratchpad" alias for _runtime_vars
|
||||
@@ -343,12 +343,12 @@ class MyTool(Tool, ContextAware):
|
||||
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||
if "." not in key and key in self._runtime_state._runtime_vars:
|
||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||
return f"Error: {err}"
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
# Guard against mock auto-generated attributes
|
||||
if "." not in key and not _has_real_attr(self._runtime_state, key):
|
||||
if key in self._runtime_state._runtime_vars:
|
||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||
return f"Error: '{key}' not found"
|
||||
return ToolResult.error(f"Error: '{key}' not found")
|
||||
return self._format_value(obj, key)
|
||||
|
||||
def _inspect_all(self) -> str:
|
||||
@@ -379,21 +379,21 @@ class MyTool(Tool, ContextAware):
|
||||
top = key.split(".")[0]
|
||||
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
|
||||
self._audit("modify", f"BLOCKED {key}")
|
||||
return f"Error: '{key}' is protected and cannot be modified"
|
||||
return ToolResult.error(f"Error: '{key}' is protected and cannot be modified")
|
||||
if top in self.READ_ONLY:
|
||||
self._audit("modify", f"READ_ONLY {key}")
|
||||
return f"Error: '{key}' is read-only and cannot be modified"
|
||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||
if "." in key:
|
||||
parent_path, leaf = key.rsplit(".", 1)
|
||||
if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
|
||||
self._audit("modify", f"BLOCKED leaf '{leaf}'")
|
||||
return f"Error: '{leaf}' is not accessible"
|
||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||
return f"Error: '{leaf}' is not accessible"
|
||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
||||
parent, err = self._resolve_path(parent_path)
|
||||
if err:
|
||||
return f"Error: {err}"
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
if isinstance(parent, dict):
|
||||
parent[leaf] = value
|
||||
else:
|
||||
@@ -408,11 +408,11 @@ class MyTool(Tool, ContextAware):
|
||||
|
||||
def _modify_model_preset(self, value: Any) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return "Error: 'model_preset' must be a non-empty string"
|
||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||
name = value.strip()
|
||||
result = self._modify_free("model_preset", name)
|
||||
if result.startswith("Error:"):
|
||||
return result if result.endswith((".", "!", "?")) else f"{result}."
|
||||
if isinstance(result, ToolResult) and result.is_error:
|
||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
||||
return (
|
||||
f"{result}; model is now {self._runtime_state.model!r}; "
|
||||
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
||||
@@ -422,22 +422,25 @@ class MyTool(Tool, ContextAware):
|
||||
spec = self.RESTRICTED[key]
|
||||
expected = spec["type"]
|
||||
if expected is int and isinstance(value, bool):
|
||||
return f"Error: '{key}' must be {expected.__name__}, got bool"
|
||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
|
||||
if not isinstance(value, expected):
|
||||
try:
|
||||
value = expected(value)
|
||||
except (ValueError, TypeError):
|
||||
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
|
||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
||||
old = getattr(self._runtime_state, key)
|
||||
if "min" in spec and value < spec["min"]:
|
||||
return f"Error: '{key}' must be >= {spec['min']}"
|
||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
||||
if "max" in spec and value > spec["max"]:
|
||||
return f"Error: '{key}' must be <= {spec['max']}"
|
||||
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
|
||||
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
||||
return f"Error: '{key}' must be at least {spec['min_len']} characters"
|
||||
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
|
||||
setattr(self._runtime_state, key, value)
|
||||
if key == "model":
|
||||
self._runtime_state._active_preset = None
|
||||
sync_replay = getattr(self._runtime_state, "_sync_replay_max_messages", None)
|
||||
if key == "context_window_tokens" and callable(sync_replay):
|
||||
sync_replay()
|
||||
if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"):
|
||||
self._runtime_state._sync_subagent_runtime_limits()
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
@@ -455,25 +458,25 @@ class MyTool(Tool, ContextAware):
|
||||
"modify",
|
||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||
)
|
||||
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
||||
try:
|
||||
setattr(self._runtime_state, key, value)
|
||||
except (ValueError, KeyError) as e:
|
||||
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
||||
self._audit("modify", f"REJECTED {key}: {message}")
|
||||
return f"Error: {message}"
|
||||
return ToolResult.error(f"Error: {message}")
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
if callable(value):
|
||||
self._audit("modify", f"REJECTED callable {key}")
|
||||
return "Error: cannot store callable values"
|
||||
return ToolResult.error("Error: cannot store callable values")
|
||||
err = self._validate_json_safe(value)
|
||||
if err:
|
||||
self._audit("modify", f"REJECTED {key}: {err}")
|
||||
return f"Error: {err}"
|
||||
return ToolResult.error(f"Error: {err}")
|
||||
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||
return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first."
|
||||
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
||||
old = self._runtime_state._runtime_vars.get(key)
|
||||
self._runtime_state._runtime_vars[key] = value
|
||||
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
||||
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,7 +16,7 @@ from typing import Any
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import current_request_session_key
|
||||
from nanobot.agent.tools.exec_session import (
|
||||
DEFAULT_EXEC_SESSION_MANAGER,
|
||||
@@ -73,51 +74,55 @@ class _PreparedCommand:
|
||||
login: bool
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
command=StringSchema("The shell command to execute"),
|
||||
cmd=StringSchema("Compatibility alias for command"),
|
||||
working_dir=StringSchema("Optional working directory for the command"),
|
||||
workdir=StringSchema("Compatibility alias for working_dir"),
|
||||
timeout=IntegerSchema(
|
||||
60,
|
||||
description=(
|
||||
"Timeout in seconds. Increase for long-running commands "
|
||||
"like compilation or installation (default 60, max 600)."
|
||||
),
|
||||
minimum=1,
|
||||
maximum=600,
|
||||
_EXEC_TOOL_PARAMETERS = tool_parameters_schema(
|
||||
command=StringSchema("The shell command to execute"),
|
||||
working_dir=StringSchema("Optional working directory for the command"),
|
||||
timeout=IntegerSchema(
|
||||
60,
|
||||
description=(
|
||||
"Timeout in seconds. Increase for long-running commands "
|
||||
"like compilation or installation (default 60, max 600)."
|
||||
),
|
||||
shell=StringSchema(
|
||||
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
||||
nullable=True,
|
||||
minimum=1,
|
||||
maximum=600,
|
||||
),
|
||||
shell=StringSchema(
|
||||
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
||||
nullable=True,
|
||||
),
|
||||
login=BooleanSchema(
|
||||
description="Whether to run bash/zsh with login shell semantics (default false).",
|
||||
default=False,
|
||||
nullable=True,
|
||||
),
|
||||
yield_time_ms=IntegerSchema(
|
||||
description=(
|
||||
"Optional milliseconds to wait before returning output. "
|
||||
"When set, a still-running command returns a session_id that "
|
||||
"can be polled or written to with write_stdin. Omit this field "
|
||||
"to keep one-shot exec behavior."
|
||||
),
|
||||
login=BooleanSchema(
|
||||
description="Whether to run bash/zsh with login shell semantics (default true).",
|
||||
default=True,
|
||||
nullable=True,
|
||||
minimum=0,
|
||||
maximum=MAX_YIELD_MS,
|
||||
nullable=True,
|
||||
),
|
||||
max_output_chars=IntegerSchema(
|
||||
description=(
|
||||
"Maximum output characters to return when yield_time_ms is used "
|
||||
"(default 10000, max 50000)."
|
||||
),
|
||||
yield_time_ms=IntegerSchema(
|
||||
description=(
|
||||
"Optional milliseconds to wait before returning output. "
|
||||
"When set, a still-running command returns a session_id that "
|
||||
"can be polled or written to with write_stdin. Omit this field "
|
||||
"to keep one-shot exec behavior."
|
||||
),
|
||||
minimum=0,
|
||||
maximum=MAX_YIELD_MS,
|
||||
nullable=True,
|
||||
),
|
||||
max_output_chars=IntegerSchema(
|
||||
description=(
|
||||
"Maximum output characters to return when yield_time_ms is used "
|
||||
"(default 10000, max 50000)."
|
||||
),
|
||||
minimum=1000,
|
||||
maximum=MAX_OUTPUT_CHARS,
|
||||
nullable=True,
|
||||
),
|
||||
max_output_tokens=IntegerSchema(
|
||||
minimum=1000,
|
||||
maximum=MAX_OUTPUT_CHARS,
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
_EXEC_TOOL_COMPAT_PARAMETERS = deepcopy(_EXEC_TOOL_PARAMETERS)
|
||||
_EXEC_TOOL_COMPAT_PARAMETERS["properties"].update(
|
||||
{
|
||||
"cmd": StringSchema("Compatibility alias for command").to_json_schema(),
|
||||
"workdir": StringSchema("Compatibility alias for working_dir").to_json_schema(),
|
||||
"max_output_tokens": IntegerSchema(
|
||||
description=(
|
||||
"Compatibility alias for max_output_chars. The current runtime "
|
||||
"uses a character budget."
|
||||
@@ -125,9 +130,12 @@ class _PreparedCommand:
|
||||
minimum=1000,
|
||||
maximum=MAX_OUTPUT_CHARS,
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
).to_json_schema(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool_parameters(_EXEC_TOOL_PARAMETERS)
|
||||
class ExecTool(Tool):
|
||||
"""Tool to execute shell commands."""
|
||||
_scopes = {"core", "subagent"}
|
||||
@@ -244,6 +252,18 @@ class ExecTool(Tool):
|
||||
def exclusive(self) -> bool:
|
||||
return True
|
||||
|
||||
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._cast_object(params, _EXEC_TOOL_COMPAT_PARAMETERS)
|
||||
|
||||
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
||||
if not isinstance(params, dict):
|
||||
return [f"parameters must be an object, got {type(params).__name__}"]
|
||||
return Schema.validate_json_schema_value(
|
||||
params,
|
||||
{**_EXEC_TOOL_COMPAT_PARAMETERS, "type": "object"},
|
||||
"",
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self, command: str | None = None, cmd: str | None = None,
|
||||
working_dir: str | None = None, workdir: str | None = None,
|
||||
@@ -256,7 +276,7 @@ class ExecTool(Tool):
|
||||
command = command or cmd
|
||||
working_dir = working_dir or workdir
|
||||
if not command:
|
||||
return "Error: Missing command. Provide command or cmd."
|
||||
return ToolResult.error("Error: Missing command. Provide command or cmd.")
|
||||
if max_output_chars is None:
|
||||
max_output_chars = max_output_tokens
|
||||
|
||||
@@ -283,7 +303,7 @@ class ExecTool(Tool):
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self._kill_process(process)
|
||||
return f"Error: Command timed out after {prepared.timeout} seconds"
|
||||
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
|
||||
except asyncio.CancelledError:
|
||||
await self._kill_process(process)
|
||||
raise
|
||||
@@ -314,7 +334,7 @@ class ExecTool(Tool):
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
return ToolResult.error(f"Error executing command: {str(e)}")
|
||||
|
||||
async def _execute_session(
|
||||
self,
|
||||
@@ -339,9 +359,10 @@ class ExecTool(Tool):
|
||||
MAX_OUTPUT_CHARS,
|
||||
),
|
||||
)
|
||||
return format_session_poll(session_id, poll)
|
||||
result = format_session_poll(session_id, poll)
|
||||
return ToolResult.error(result) if poll.timed_out else result
|
||||
except Exception as exc:
|
||||
return f"Error executing command: {exc}"
|
||||
return ToolResult.error(f"Error executing command: {exc}")
|
||||
|
||||
def _resolve_timeout(self, timeout: int | None) -> int | None:
|
||||
"""Resolve the effective hard timeout in seconds (None = no limit).
|
||||
@@ -383,12 +404,12 @@ class ExecTool(Tool):
|
||||
requested = Path(cwd).expanduser().resolve()
|
||||
resolved_root = Path(workspace_root).expanduser().resolve()
|
||||
except Exception:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: working_dir could not be resolved"
|
||||
+ _WORKSPACE_BOUNDARY_NOTE
|
||||
)
|
||||
if not is_path_within(requested, resolved_root):
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: working_dir is outside the configured workspace"
|
||||
+ _WORKSPACE_BOUNDARY_NOTE
|
||||
)
|
||||
@@ -432,7 +453,7 @@ class ExecTool(Tool):
|
||||
env=env,
|
||||
timeout=effective_timeout,
|
||||
shell_program=shell_program,
|
||||
login=True if login is None else login,
|
||||
login=False if login is None else login,
|
||||
)
|
||||
|
||||
def _compose_path(self, current_path: str) -> str:
|
||||
@@ -461,7 +482,7 @@ class ExecTool(Tool):
|
||||
async def _spawn(
|
||||
command: str, cwd: str, env: dict[str, str],
|
||||
shell_program: str | None = None,
|
||||
login: bool = True,
|
||||
login: bool = False,
|
||||
*,
|
||||
stdin: int = asyncio.subprocess.DEVNULL,
|
||||
) -> asyncio.subprocess.Process:
|
||||
@@ -504,24 +525,24 @@ class ExecTool(Tool):
|
||||
if not shell:
|
||||
return None, None
|
||||
if _IS_WINDOWS:
|
||||
return None, "Error: shell parameter is not supported on Windows"
|
||||
return None, ToolResult.error("Error: shell parameter is not supported on Windows")
|
||||
if "\0" in shell or "\n" in shell or "\r" in shell:
|
||||
return None, "Error: shell contains invalid characters"
|
||||
return None, ToolResult.error("Error: shell contains invalid characters")
|
||||
allowed = {"sh", "bash", "zsh"}
|
||||
path = Path(shell).expanduser()
|
||||
if path.is_absolute():
|
||||
if path.name not in allowed:
|
||||
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||
return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
|
||||
if not path.is_file() or not os.access(path, os.X_OK):
|
||||
return None, f"Error: shell is not executable: {shell}"
|
||||
return None, ToolResult.error(f"Error: shell is not executable: {shell}")
|
||||
return str(path), None
|
||||
if "/" in shell or "\\" in shell:
|
||||
return None, "Error: shell must be a shell name or absolute path"
|
||||
return None, ToolResult.error("Error: shell must be a shell name or absolute path")
|
||||
if shell not in allowed:
|
||||
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||
return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
|
||||
resolved = shutil.which(shell)
|
||||
if not resolved:
|
||||
return None, f"Error: shell not found: {shell}"
|
||||
return None, ToolResult.error(f"Error: shell not found: {shell}")
|
||||
return resolved, None
|
||||
|
||||
@staticmethod
|
||||
@@ -541,8 +562,9 @@ class ExecTool(Tool):
|
||||
def _build_env(self) -> dict[str, str]:
|
||||
"""Build a minimal environment for subprocess execution.
|
||||
|
||||
On Unix, only HOME/LANG/TERM are passed; ``bash -l`` sources the
|
||||
user's profile which sets PATH and other essentials.
|
||||
On Unix, only HOME/LANG/TERM are passed by default. If callers request
|
||||
``login=True``, bash/zsh may source the user's profile and add PATH or
|
||||
other variables.
|
||||
|
||||
On Windows, ``cmd.exe`` has no login-profile mechanism, so a curated
|
||||
set of system variables (including PATH) is forwarded. API keys and
|
||||
@@ -602,15 +624,15 @@ class ExecTool(Tool):
|
||||
# exempt specific commands (e.g. "rm -rf" inside a build directory)
|
||||
# from the hardcoded deny list via configuration.
|
||||
explicitly_allowed = bool(self.allow_patterns) and any(
|
||||
re.search(p, lower) for p in self.allow_patterns
|
||||
re.fullmatch(p, lower) for p in self.allow_patterns
|
||||
)
|
||||
if not explicitly_allowed:
|
||||
for pattern in self.deny_patterns:
|
||||
if re.search(pattern, lower):
|
||||
return "Error: Command blocked by deny pattern filter"
|
||||
return ToolResult.error("Error: Command blocked by deny pattern filter")
|
||||
|
||||
if self.allow_patterns:
|
||||
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
||||
return ToolResult.error("Error: Command blocked by allowlist filter (not in allowlist)")
|
||||
|
||||
from nanobot.security.network import contains_internal_url
|
||||
if contains_internal_url(
|
||||
@@ -620,12 +642,12 @@ class ExecTool(Tool):
|
||||
),
|
||||
):
|
||||
# The runner turns this marker into a non-retryable security hint.
|
||||
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||
return ToolResult.error("Error: Command blocked by safety guard (internal/private URL detected)")
|
||||
|
||||
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
|
||||
if should_restrict:
|
||||
if "..\\" in cmd or "../" in cmd:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: Command blocked by safety guard (path traversal detected)"
|
||||
+ _WORKSPACE_BOUNDARY_NOTE
|
||||
)
|
||||
@@ -660,7 +682,7 @@ class ExecTool(Tool):
|
||||
if not allowed and resolved_workspace is not None:
|
||||
allowed = is_path_within(p, resolved_workspace)
|
||||
if p.is_absolute() and not allowed:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: Command blocked by safety guard (path outside working dir)"
|
||||
+ _WORKSPACE_BOUNDARY_NOTE
|
||||
)
|
||||
|
||||
+47
-29
@@ -14,7 +14,7 @@ import httpx
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.schema import (
|
||||
BooleanSchema,
|
||||
IntegerSchema,
|
||||
@@ -36,6 +36,24 @@ _VOLCENGINE_TIME_RANGES = {"OneDay", "OneWeek", "OneMonth", "OneYear"}
|
||||
_VOLCENGINE_DATE_RANGE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
# Single source of truth for selectable search providers (CLI wizard + WebUI).
|
||||
# "credential" describes what each provider needs: none / api_key / base_url /
|
||||
# optional_api_key.
|
||||
SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
||||
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
||||
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
||||
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
|
||||
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
|
||||
{"name": "jina", "label": "Jina", "credential": "api_key"},
|
||||
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
|
||||
{"name": "exa", "label": "Exa", "credential": "api_key"},
|
||||
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
||||
{"name": "bocha", "label": "Bocha", "credential": "api_key"},
|
||||
{"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"},
|
||||
{"name": "keenable", "label": "Keenable", "credential": "optional_api_key"},
|
||||
)
|
||||
|
||||
|
||||
class WebSearchConfig(Base):
|
||||
"""Web search configuration."""
|
||||
provider: str = "duckduckgo"
|
||||
@@ -377,13 +395,13 @@ class WebSearchTool(Tool):
|
||||
elif provider == "keenable":
|
||||
return await self._search_keenable(query, n)
|
||||
else:
|
||||
return f"Error: unknown search provider '{provider}'"
|
||||
return ToolResult.error(f"Error: unknown search provider '{provider}'")
|
||||
|
||||
async def _search_olostep(self, query: str, n: int) -> str:
|
||||
try:
|
||||
from olostep import AsyncOlostep, Olostep_BaseError
|
||||
except ImportError:
|
||||
return "Error: olostep package not installed. Run: pip install olostep"
|
||||
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
|
||||
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
|
||||
@@ -427,9 +445,9 @@ class WebSearchTool(Tool):
|
||||
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
|
||||
return _format_results(query, items, n)
|
||||
except Olostep_BaseError as e:
|
||||
return f"Olostep search error: {type(e).__name__}: {e}"
|
||||
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
|
||||
except Exception as e:
|
||||
return f"Olostep search error: {type(e).__name__}: {e}"
|
||||
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
|
||||
|
||||
async def _search_brave(self, query: str, n: int) -> str:
|
||||
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
|
||||
@@ -463,13 +481,13 @@ class WebSearchTool(Tool):
|
||||
return _format_results(query, items, n)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: Brave search rate limited after retry. "
|
||||
"Retry later or reduce consecutive web_search calls."
|
||||
)
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
|
||||
async def _search_tavily(self, query: str, n: int) -> str:
|
||||
api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "")
|
||||
@@ -487,7 +505,7 @@ class WebSearchTool(Tool):
|
||||
r.raise_for_status()
|
||||
return _format_results(query, r.json().get("results", []), n)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
|
||||
async def _search_keenable(self, query: str, n: int) -> str:
|
||||
api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "")
|
||||
@@ -522,10 +540,10 @@ class WebSearchTool(Tool):
|
||||
return _format_results(query, items, n)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
return "Error: Keenable search rate limited. Try again later or reduce search frequency."
|
||||
return f"Error: Keenable search failed ({e.response.status_code}): {e}"
|
||||
return ToolResult.error("Error: Keenable search rate limited. Try again later or reduce search frequency.")
|
||||
return ToolResult.error(f"Error: Keenable search failed ({e.response.status_code}): {e}")
|
||||
except Exception as e:
|
||||
return f"Error: Keenable search failed: {e}"
|
||||
return ToolResult.error(f"Error: Keenable search failed: {e}")
|
||||
|
||||
async def _search_searxng(self, query: str, n: int) -> str:
|
||||
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip()
|
||||
@@ -535,7 +553,7 @@ class WebSearchTool(Tool):
|
||||
endpoint = f"{base_url.rstrip('/')}/search"
|
||||
is_valid, error_msg = _validate_url(endpoint)
|
||||
if not is_valid:
|
||||
return f"Error: invalid SearXNG URL: {error_msg}"
|
||||
return ToolResult.error(f"Error: invalid SearXNG URL: {error_msg}")
|
||||
try:
|
||||
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
||||
r = await client.get(
|
||||
@@ -547,7 +565,7 @@ class WebSearchTool(Tool):
|
||||
r.raise_for_status()
|
||||
return _format_results(query, r.json().get("results", []), n)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
|
||||
async def _search_jina(self, query: str, n: int) -> str:
|
||||
api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "")
|
||||
@@ -598,7 +616,7 @@ class WebSearchTool(Tool):
|
||||
]
|
||||
return _format_results(query, items, n)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
|
||||
async def _search_exa(self, query: str, n: int) -> str:
|
||||
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "")
|
||||
@@ -645,10 +663,10 @@ class WebSearchTool(Tool):
|
||||
return _format_results(query, items, n)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
return "Error: Exa search rate limited. Try again later or reduce search frequency."
|
||||
return f"Error: Exa search failed ({e.response.status_code}): {e}"
|
||||
return ToolResult.error("Error: Exa search rate limited. Try again later or reduce search frequency.")
|
||||
return ToolResult.error(f"Error: Exa search failed ({e.response.status_code}): {e}")
|
||||
except Exception as e:
|
||||
return f"Error: Exa search failed: {e}"
|
||||
return ToolResult.error(f"Error: Exa search failed: {e}")
|
||||
|
||||
async def _search_volcengine(
|
||||
self,
|
||||
@@ -672,7 +690,7 @@ class WebSearchTool(Tool):
|
||||
normalized_time_range = _normalize_volcengine_time_range(time_range) if time_range else None
|
||||
normalized_auth_level = _normalize_volcengine_auth_level(auth_level) if auth_level is not None else None
|
||||
except ValueError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"Query": query,
|
||||
@@ -705,18 +723,18 @@ class WebSearchTool(Tool):
|
||||
data = r.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
return "Error: Volcengine search rate limited. Try again later or reduce search frequency."
|
||||
return f"Error: Volcengine search failed ({e.response.status_code}): {e}"
|
||||
return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
|
||||
return ToolResult.error(f"Error: Volcengine search failed ({e.response.status_code}): {e}")
|
||||
except Exception as e:
|
||||
return f"Error: Volcengine search failed: {e}"
|
||||
return ToolResult.error(f"Error: Volcengine search failed: {e}")
|
||||
|
||||
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
|
||||
if error:
|
||||
if isinstance(error, dict):
|
||||
code = error.get("Code") or error.get("code") or "unknown"
|
||||
message = error.get("Message") or error.get("message") or error
|
||||
return f"Error: Volcengine search error {code}: {message}"
|
||||
return f"Error: Volcengine search error: {error}"
|
||||
return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
|
||||
return ToolResult.error(f"Error: Volcengine search error: {error}")
|
||||
|
||||
result = data.get("Result") or data
|
||||
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
|
||||
@@ -759,7 +777,7 @@ class WebSearchTool(Tool):
|
||||
# We run it in a thread to avoid blocking the loop
|
||||
from ddgs import DDGS
|
||||
|
||||
ddgs = DDGS(timeout=10)
|
||||
ddgs = DDGS(timeout=10, proxy=self.proxy)
|
||||
raw = await asyncio.wait_for(
|
||||
asyncio.to_thread(ddgs.text, query, max_results=n),
|
||||
timeout=self.config.timeout,
|
||||
@@ -773,7 +791,7 @@ class WebSearchTool(Tool):
|
||||
return _format_results(query, items, n)
|
||||
except Exception as e:
|
||||
logger.warning("DuckDuckGo search failed: {}", e)
|
||||
return f"Error: DuckDuckGo search failed ({e})"
|
||||
return ToolResult.error(f"Error: DuckDuckGo search failed ({e})")
|
||||
|
||||
async def _search_bocha(self, query: str, n: int, freshness: str = "noLimit") -> str:
|
||||
api_key = self.config.api_key or os.environ.get("BOCHA_API_KEY", "")
|
||||
@@ -801,7 +819,7 @@ class WebSearchTool(Tool):
|
||||
timeout=self.config.timeout,
|
||||
)
|
||||
if r.status_code == 429:
|
||||
return "Error: Bocha search rate-limited (HTTP 429). Wait and retry."
|
||||
return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
wrapped_data = data.get("data") if isinstance(data, dict) else None
|
||||
@@ -821,9 +839,9 @@ class WebSearchTool(Tool):
|
||||
]
|
||||
return _format_results(query, items, n)
|
||||
except httpx.HTTPStatusError as e:
|
||||
return f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}"
|
||||
return ToolResult.error(f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}")
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
|
||||
+22
-1
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hmac
|
||||
import json as _json
|
||||
import time
|
||||
import uuid
|
||||
@@ -392,7 +393,10 @@ async def handle_health(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
def create_app(
|
||||
agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0
|
||||
agent_loop,
|
||||
model_name: str = "nanobot",
|
||||
request_timeout: float = 120.0,
|
||||
api_key: str = "",
|
||||
) -> web.Application:
|
||||
"""Create the aiohttp application.
|
||||
|
||||
@@ -400,6 +404,7 @@ def create_app(
|
||||
agent_loop: An initialized AgentLoop instance.
|
||||
model_name: Model name reported in responses.
|
||||
request_timeout: Per-request timeout in seconds.
|
||||
api_key: Optional API key for Bearer-token authentication.
|
||||
"""
|
||||
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||
app["agent_loop"] = agent_loop
|
||||
@@ -407,6 +412,22 @@ def create_app(
|
||||
app["request_timeout"] = request_timeout
|
||||
app["session_locks"] = {} # per-user locks, keyed by session_key
|
||||
|
||||
@web.middleware
|
||||
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
||||
if not api_key:
|
||||
return await handler(request)
|
||||
# Allow unauthenticated health checks.
|
||||
if request.path == "/health":
|
||||
return await handler(request)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>")
|
||||
if not hmac.compare_digest(auth[len("Bearer "):], api_key):
|
||||
return _error_json(401, "Invalid API key")
|
||||
return await handler(request)
|
||||
|
||||
app.middlewares.append(auth_middleware)
|
||||
|
||||
app.router.add_post("/v1/chat/completions", handle_chat_completions)
|
||||
app.router.add_get("/v1/models", handle_models)
|
||||
app.router.add_get("/health", handle_health)
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.bus.outbound_events import OutboundEvent
|
||||
|
||||
# Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI
|
||||
# payloads. Value is JSON-serializable with at least ``kind``; rich clients may
|
||||
@@ -39,9 +42,9 @@ class InboundMessage:
|
||||
class OutboundMessage:
|
||||
"""Message to send to a chat channel.
|
||||
|
||||
``metadata`` can carry routing (``message_id``, …), trace flags (``_progress``),
|
||||
and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI
|
||||
channels may ignore unknown keys.
|
||||
``event`` carries internal runtime/UI semantics. ``metadata`` is reserved
|
||||
for channel routing context (``message_id``, thread ids, etc.) and optional
|
||||
``OUTBOUND_META_AGENT_UI`` blobs for rich clients.
|
||||
"""
|
||||
|
||||
channel: str
|
||||
@@ -51,3 +54,4 @@ class OutboundMessage:
|
||||
media: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
buttons: list[list[str]] = field(default_factory=list)
|
||||
event: "OutboundEvent | None" = None
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Typed outbound events carried by :class:`OutboundMessage`.
|
||||
|
||||
The message bus still transports :class:`nanobot.bus.events.OutboundMessage`
|
||||
because channels need chat routing fields. Runtime/UI semantics live on the
|
||||
message's explicit ``event`` field rather than in reserved metadata flags.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
|
||||
|
||||
class OutboundEvent:
|
||||
"""Marker base for internal outbound runtime events."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProgressEvent(OutboundEvent):
|
||||
content: str = ""
|
||||
tool_hint: bool = False
|
||||
reasoning: bool = False
|
||||
reasoning_delta: bool = False
|
||||
reasoning_end: bool = False
|
||||
stream_id: str | None = None
|
||||
tool_events: list[dict[str, Any]] | None = None
|
||||
file_edit_events: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryWaitEvent(OutboundEvent):
|
||||
content: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamDeltaEvent(OutboundEvent):
|
||||
content: str = ""
|
||||
stream_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamEndEvent(OutboundEvent):
|
||||
content: str = ""
|
||||
stream_id: str | None = None
|
||||
resuming: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamedResponseEvent(OutboundEvent):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnEndEvent(OutboundEvent):
|
||||
latency_ms: int | None = None
|
||||
goal_state: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GoalStatusEvent(OutboundEvent):
|
||||
status: str
|
||||
started_at: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GoalStateSyncEvent(OutboundEvent):
|
||||
goal_state: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionUpdatedEvent(OutboundEvent):
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeModelUpdatedEvent(OutboundEvent):
|
||||
model: str | None
|
||||
model_preset: str | None = None
|
||||
|
||||
|
||||
def outbound_message_for_event(
|
||||
*,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
event: OutboundEvent,
|
||||
content: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> OutboundMessage:
|
||||
"""Build an :class:`OutboundMessage` for a typed event."""
|
||||
|
||||
return OutboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
content=_event_content(event) if content is None else content,
|
||||
event=event,
|
||||
metadata=dict(metadata or {}),
|
||||
)
|
||||
|
||||
|
||||
def outbound_event_from_message(msg: OutboundMessage) -> OutboundEvent | None:
|
||||
"""Return the typed outbound event carried by *msg*, if any."""
|
||||
|
||||
if msg.event is not None:
|
||||
return msg.event
|
||||
return _legacy_event_from_metadata(msg)
|
||||
|
||||
|
||||
def replace_outbound_event(
|
||||
msg: OutboundMessage,
|
||||
event: OutboundEvent,
|
||||
*,
|
||||
content: str | None = None,
|
||||
) -> OutboundMessage:
|
||||
"""Return *msg* with a new event and optional content."""
|
||||
|
||||
return replace(
|
||||
msg,
|
||||
content=_event_content(event) if content is None else content,
|
||||
event=event,
|
||||
)
|
||||
|
||||
|
||||
def _event_content(event: OutboundEvent) -> str:
|
||||
if isinstance(event, ProgressEvent | RetryWaitEvent | StreamDeltaEvent | StreamEndEvent):
|
||||
return event.content
|
||||
return ""
|
||||
|
||||
|
||||
def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
|
||||
"""Bridge pre-typed outbound metadata flags into typed events.
|
||||
|
||||
New code should set ``OutboundMessage.event`` directly. The fallback keeps
|
||||
older in-process extensions and channel plugins from losing runtime events
|
||||
while they migrate off reserved metadata flags.
|
||||
"""
|
||||
|
||||
meta = msg.metadata or {}
|
||||
if meta.get("_runtime_model_updated"):
|
||||
return RuntimeModelUpdatedEvent(
|
||||
model=_metadata_str(meta, "model"),
|
||||
model_preset=_metadata_str(meta, "model_preset"),
|
||||
)
|
||||
if meta.get("_goal_state_sync"):
|
||||
goal_state = meta.get("goal_state")
|
||||
return GoalStateSyncEvent(goal_state if isinstance(goal_state, dict) else {"active": False})
|
||||
if meta.get("_goal_status"):
|
||||
status = meta.get("goal_status")
|
||||
if not isinstance(status, str) or not status:
|
||||
return None
|
||||
return GoalStatusEvent(
|
||||
status=status,
|
||||
started_at=_metadata_float(meta, "started_at", "goal_started_at"),
|
||||
)
|
||||
if meta.get("_turn_end"):
|
||||
goal_state = meta.get("goal_state")
|
||||
return TurnEndEvent(
|
||||
latency_ms=_metadata_int(meta, "latency_ms"),
|
||||
goal_state=goal_state if isinstance(goal_state, dict) else None,
|
||||
)
|
||||
if meta.get("_session_updated"):
|
||||
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
|
||||
if meta.get("_retry_wait"):
|
||||
return RetryWaitEvent(content=msg.content)
|
||||
if meta.get("_stream_end"):
|
||||
return StreamEndEvent(
|
||||
content=msg.content,
|
||||
stream_id=_metadata_str(meta, "_stream_id"),
|
||||
resuming=bool(meta.get("_resuming")),
|
||||
)
|
||||
if meta.get("_stream_delta"):
|
||||
return StreamDeltaEvent(
|
||||
content=msg.content,
|
||||
stream_id=_metadata_str(meta, "_stream_id"),
|
||||
)
|
||||
if meta.get("_streamed"):
|
||||
return StreamedResponseEvent()
|
||||
if (
|
||||
meta.get("_progress")
|
||||
or meta.get("_reasoning_delta")
|
||||
or meta.get("_reasoning_end")
|
||||
or meta.get("_reasoning")
|
||||
or meta.get("_file_edit_events")
|
||||
or meta.get("_tool_events")
|
||||
):
|
||||
tool_events = meta.get("_tool_events")
|
||||
file_edit_events = meta.get("_file_edit_events")
|
||||
return ProgressEvent(
|
||||
content=msg.content,
|
||||
tool_hint=bool(meta.get("_tool_hint")),
|
||||
reasoning=bool(meta.get("_reasoning")),
|
||||
reasoning_delta=bool(meta.get("_reasoning_delta")),
|
||||
reasoning_end=bool(meta.get("_reasoning_end")),
|
||||
stream_id=_metadata_str(meta, "_stream_id"),
|
||||
tool_events=tool_events if isinstance(tool_events, list) else None,
|
||||
file_edit_events=file_edit_events if isinstance(file_edit_events, list) else None,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_str(meta: Mapping[str, Any], key: str) -> str | None:
|
||||
value = meta.get(key)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _metadata_int(meta: Mapping[str, Any], key: str) -> int | None:
|
||||
value = meta.get(key)
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_float(meta: Mapping[str, Any], *keys: str) -> float | None:
|
||||
for key in keys:
|
||||
value = meta.get(key)
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
if isinstance(value, int | float):
|
||||
return float(value)
|
||||
return None
|
||||
+12
-15
@@ -10,7 +10,8 @@ from __future__ import annotations
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
@@ -29,23 +30,19 @@ def build_bus_progress_callback(
|
||||
reasoning: bool = False,
|
||||
reasoning_end: bool = False,
|
||||
) -> None:
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_progress"] = True
|
||||
meta["_tool_hint"] = tool_hint
|
||||
if reasoning:
|
||||
meta["_reasoning_delta"] = True
|
||||
if reasoning_end:
|
||||
meta["_reasoning_end"] = True
|
||||
if tool_events:
|
||||
meta["_tool_events"] = tool_events
|
||||
if file_edit_events:
|
||||
meta["_file_edit_events"] = file_edit_events
|
||||
await bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=content,
|
||||
metadata=meta,
|
||||
event=ProgressEvent(
|
||||
content=content,
|
||||
tool_hint=tool_hint,
|
||||
reasoning_delta=reasoning,
|
||||
reasoning_end=reasoning_end,
|
||||
tool_events=tool_events,
|
||||
file_edit_events=file_edit_events,
|
||||
),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+37
-17
@@ -101,20 +101,33 @@ class BaseChannel(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
"""Deliver a streaming text chunk.
|
||||
|
||||
Override in subclasses to enable streaming. Implementations should
|
||||
raise on delivery failure so the channel manager can retry.
|
||||
|
||||
Streaming contract: ``_stream_delta`` is a chunk, ``_stream_end`` ends
|
||||
the current segment, and stateful implementations must key buffers by
|
||||
``_stream_id`` rather than only by ``chat_id``.
|
||||
Stateful implementations should key buffers by ``stream_id`` rather
|
||||
than only by ``chat_id`` when it is provided.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def send_reasoning_delta(
|
||||
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
"""Stream a chunk of model reasoning/thinking content.
|
||||
|
||||
@@ -123,15 +136,17 @@ class BaseChannel(ABC):
|
||||
subtext, WebUI italic bubble, ...) override to render reasoning
|
||||
as a subordinate trace that updates in place as the model thinks.
|
||||
|
||||
Streaming contract mirrors :meth:`send_delta`: ``_reasoning_delta``
|
||||
is a chunk, ``_reasoning_end`` ends the current reasoning segment,
|
||||
and stateful implementations should key buffers by ``_stream_id``
|
||||
rather than only by ``chat_id``.
|
||||
Streaming contract mirrors :meth:`send_delta`: stateful implementations
|
||||
should key buffers by ``stream_id`` rather than only by ``chat_id``.
|
||||
"""
|
||||
return
|
||||
|
||||
async def send_reasoning_end(
|
||||
self, chat_id: str, metadata: dict[str, Any] | None = None
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
"""Mark the end of a reasoning stream segment.
|
||||
|
||||
@@ -165,13 +180,18 @@ class BaseChannel(ABC):
|
||||
"""
|
||||
if not msg.content:
|
||||
return
|
||||
meta = dict(msg.metadata or {})
|
||||
meta.setdefault("_reasoning_delta", True)
|
||||
await self.send_reasoning_delta(msg.chat_id, msg.content, meta)
|
||||
end_meta = dict(meta)
|
||||
end_meta.pop("_reasoning_delta", None)
|
||||
end_meta["_reasoning_end"] = True
|
||||
await self.send_reasoning_end(msg.chat_id, end_meta)
|
||||
stream_id = getattr(msg.event, "stream_id", None)
|
||||
await self.send_reasoning_delta(
|
||||
msg.chat_id,
|
||||
msg.content,
|
||||
msg.metadata,
|
||||
stream_id=stream_id,
|
||||
)
|
||||
await self.send_reasoning_end(
|
||||
msg.chat_id,
|
||||
msg.metadata,
|
||||
stream_id=stream_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
|
||||
@@ -94,11 +94,23 @@ class NanobotDingTalkHandler(CallbackHandler):
|
||||
for item in rich_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text":
|
||||
t = item.get("text", "").strip()
|
||||
if t:
|
||||
content = (content + " " + t).strip() if content else t
|
||||
elif item.get("downloadCode"):
|
||||
# A rich-text item may carry text and/or a downloadCode; the
|
||||
# DingTalk SDK treats them independently, so handle both.
|
||||
t = item.get("text", "").strip()
|
||||
if t:
|
||||
fmt = item.get("type", "")
|
||||
if fmt == "bold":
|
||||
formatted = f"**{t}**"
|
||||
elif fmt == "italic":
|
||||
formatted = f"*{t}*"
|
||||
elif fmt == "inlineCode":
|
||||
formatted = f"`{t}`"
|
||||
elif fmt == "pre":
|
||||
formatted = f"```\n{t}\n```"
|
||||
else:
|
||||
formatted = t
|
||||
content = (content + " " + formatted).strip() if content else formatted
|
||||
if item.get("downloadCode"):
|
||||
dc = item["downloadCode"]
|
||||
fname = item.get("fileName") or "file"
|
||||
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
|
||||
@@ -214,7 +226,9 @@ class DingTalkChannel(BaseChannel):
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._http = httpx.AsyncClient()
|
||||
self._http = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(10.0, connect=10.0, read=30.0, write=30.0, pool=10.0)
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
"Initializing Stream Client with Client ID: {}...",
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.command.builtin import build_help_text
|
||||
@@ -458,7 +459,7 @@ class DiscordChannel(BaseChannel):
|
||||
self.logger.warning("client not ready; dropping outbound message")
|
||||
return
|
||||
|
||||
is_progress = bool((msg.metadata or {}).get("_progress"))
|
||||
is_progress = isinstance(msg.event, ProgressEvent)
|
||||
|
||||
try:
|
||||
await client.send_outbound(msg)
|
||||
@@ -471,7 +472,14 @@ class DiscordChannel(BaseChannel):
|
||||
await self._clear_reactions(msg.chat_id)
|
||||
|
||||
async def send_delta(
|
||||
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
"""Progressive Discord delivery: send once, then edit until the stream ends."""
|
||||
client = self._client
|
||||
@@ -479,10 +487,7 @@ class DiscordChannel(BaseChannel):
|
||||
self.logger.warning("client not ready; dropping stream delta")
|
||||
return
|
||||
|
||||
meta = metadata or {}
|
||||
stream_id = meta.get("_stream_id")
|
||||
|
||||
if meta.get("_stream_end"):
|
||||
if stream_end:
|
||||
buf = self._stream_bufs.get(chat_id)
|
||||
if not buf or buf.message is None or not buf.text:
|
||||
return
|
||||
|
||||
@@ -23,6 +23,7 @@ from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
@@ -199,6 +200,8 @@ class EmailChannel(BaseChannel):
|
||||
except Exception:
|
||||
self.logger.exception("Polling error")
|
||||
|
||||
if not self._running:
|
||||
break
|
||||
await asyncio.sleep(poll_seconds)
|
||||
|
||||
async def stop(self) -> None:
|
||||
@@ -216,7 +219,7 @@ class EmailChannel(BaseChannel):
|
||||
return
|
||||
|
||||
# Skip progress messages to prevent sending an empty email after each tool call
|
||||
if (msg.metadata or {}).get("_progress"):
|
||||
if isinstance(msg.event, ProgressEvent):
|
||||
self.logger.debug("Skip progress message to {}", msg.chat_id)
|
||||
return
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
@@ -1797,14 +1798,19 @@ class FeishuChannel(BaseChannel):
|
||||
return self._stream_update_text_sync(card_id, content, sequence), sequence
|
||||
|
||||
async def send_delta(
|
||||
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
|
||||
|
||||
Supported metadata keys:
|
||||
_stream_end: Finalize the streaming card.
|
||||
_tool_hint: Delta is a formatted tool hint (for display only).
|
||||
message_id: Original message id (used with _stream_end for reaction cleanup).
|
||||
message_id: Original message id (used with stream end for reaction cleanup).
|
||||
chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards.
|
||||
"""
|
||||
if not self._client:
|
||||
@@ -1815,14 +1821,14 @@ class FeishuChannel(BaseChannel):
|
||||
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
|
||||
|
||||
# --- stream end: final update or fallback ---
|
||||
if meta.get("_stream_end"):
|
||||
if stream_end:
|
||||
message_id = meta.get("message_id")
|
||||
# Only finalize the OnIt -> DONE reaction transition on the truly
|
||||
# final stream end. _resuming=True means the agent will keep
|
||||
# final stream end. resuming=True means the agent will keep
|
||||
# working (more tool-call rounds), so leave the reaction state
|
||||
# in place — otherwise the OnIt indicator disappears prematurely
|
||||
# and the DONE reaction fires after every tool call.
|
||||
if message_id and not meta.get("_resuming"):
|
||||
if message_id and not resuming:
|
||||
reaction_id = self._reaction_ids.pop(message_id, None)
|
||||
if reaction_id:
|
||||
await self._remove_reaction(message_id, reaction_id)
|
||||
@@ -1965,7 +1971,9 @@ class FeishuChannel(BaseChannel):
|
||||
# Handle tool hint messages. When a streaming card is active for
|
||||
# this chat, inline the hint into the card instead of sending a
|
||||
# separate message so the user experience stays cohesive.
|
||||
if msg.metadata.get("_tool_hint"):
|
||||
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
|
||||
|
||||
if progress_event and progress_event.tool_hint:
|
||||
hint = (msg.content or "").strip()
|
||||
if not hint:
|
||||
return
|
||||
@@ -1976,6 +1984,7 @@ class FeishuChannel(BaseChannel):
|
||||
await self.send_delta(
|
||||
msg.chat_id,
|
||||
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
return
|
||||
# No active streaming card — send as a regular interactive card
|
||||
@@ -2009,7 +2018,7 @@ class FeishuChannel(BaseChannel):
|
||||
reply_message_id: str | None = None
|
||||
_msg_id = msg.metadata.get("message_id")
|
||||
has_thread_id = msg.metadata.get("thread_id")
|
||||
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
|
||||
if self.config.reply_to_message and progress_event is None:
|
||||
reply_message_id = _msg_id
|
||||
# For topic group messages, always reply to keep context in thread
|
||||
elif has_thread_id:
|
||||
|
||||
+157
-48
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -12,6 +13,16 @@ from typing import TYPE_CHECKING, Any
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
ProgressEvent,
|
||||
RetryWaitEvent,
|
||||
RuntimeModelUpdatedEvent,
|
||||
StreamDeltaEvent,
|
||||
StreamedResponseEvent,
|
||||
StreamEndEvent,
|
||||
outbound_event_from_message,
|
||||
replace_outbound_event,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import Config
|
||||
@@ -266,7 +277,7 @@ class ChannelManager:
|
||||
|
||||
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
|
||||
metadata = msg.metadata or {}
|
||||
if metadata.get("_progress"):
|
||||
if isinstance(outbound_event_from_message(msg), ProgressEvent):
|
||||
return False
|
||||
fingerprint = self._fingerprint_content(msg.content)
|
||||
if not fingerprint:
|
||||
@@ -305,57 +316,59 @@ class ChannelManager:
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
if (
|
||||
msg.metadata.get("_reasoning_delta")
|
||||
or msg.metadata.get("_reasoning_end")
|
||||
or msg.metadata.get("_reasoning")
|
||||
event = outbound_event_from_message(msg)
|
||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
||||
if progress_event and (
|
||||
progress_event.reasoning_delta
|
||||
or progress_event.reasoning_end
|
||||
or progress_event.reasoning
|
||||
):
|
||||
# Reasoning rides its own plugin channel: only delivered
|
||||
# when the destination channel opts in via ``show_reasoning``
|
||||
# and overrides the streaming primitives. Channels without
|
||||
# a low-emphasis UI affordance keep the base no-op and the
|
||||
# content silently drops here. ``_reasoning`` (one-shot)
|
||||
# is accepted for backward compatibility with hooks that
|
||||
# haven't migrated to delta/end yet.
|
||||
# content silently drops here.
|
||||
channel = self.channels.get(msg.channel)
|
||||
if channel is not None and channel.show_reasoning:
|
||||
await self._send_with_retry(channel, msg)
|
||||
continue
|
||||
|
||||
if msg.metadata.get("_progress"):
|
||||
if msg.metadata.get("_tool_hint") and not self._should_send_progress(
|
||||
if progress_event:
|
||||
if progress_event.tool_hint and not self._should_send_progress(
|
||||
msg.channel, tool_hint=True,
|
||||
):
|
||||
continue
|
||||
if not msg.metadata.get("_tool_hint") and not self._should_send_progress(
|
||||
if not progress_event.tool_hint and not self._should_send_progress(
|
||||
msg.channel, tool_hint=False,
|
||||
):
|
||||
continue
|
||||
|
||||
if msg.metadata.get("_retry_wait"):
|
||||
if isinstance(event, RetryWaitEvent):
|
||||
continue
|
||||
|
||||
if (
|
||||
msg.metadata.get("_runtime_model_updated")
|
||||
isinstance(event, RuntimeModelUpdatedEvent)
|
||||
and msg.channel == "websocket"
|
||||
and "websocket" not in self.channels
|
||||
):
|
||||
continue
|
||||
|
||||
# Coalesce consecutive _stream_delta messages for the same (channel, chat_id)
|
||||
# Coalesce consecutive stream delta messages for the same (channel, chat_id)
|
||||
# to reduce API calls and improve streaming latency
|
||||
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
|
||||
if isinstance(event, StreamDeltaEvent):
|
||||
msg, extra_pending = self._coalesce_stream_deltas(msg)
|
||||
pending.extend(extra_pending)
|
||||
event = outbound_event_from_message(msg)
|
||||
|
||||
channel = self.channels.get(msg.channel)
|
||||
if channel:
|
||||
# Duplicate suppression is scoped to a known source message
|
||||
# so repeated content from separate turns is still delivered.
|
||||
if (
|
||||
not msg.metadata.get("_stream_delta")
|
||||
and not msg.metadata.get("_stream_end")
|
||||
and not msg.metadata.get("_streamed")
|
||||
not isinstance(
|
||||
event,
|
||||
StreamDeltaEvent | StreamEndEvent | StreamedResponseEvent,
|
||||
)
|
||||
):
|
||||
if self._should_suppress_outbound(msg):
|
||||
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
|
||||
@@ -369,34 +382,116 @@ class ChannelManager:
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def _accepts_keyword(callable_obj: Callable[..., Any], name: str) -> bool:
|
||||
try:
|
||||
signature = inspect.signature(callable_obj)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
return any(
|
||||
parameter.kind is inspect.Parameter.VAR_KEYWORD or parameter.name == name
|
||||
for parameter in signature.parameters.values()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _send_reasoning_delta(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
|
||||
metadata = msg.metadata
|
||||
kwargs: dict[str, Any] = {}
|
||||
if cls._accepts_keyword(channel.send_reasoning_delta, "stream_id"):
|
||||
kwargs["stream_id"] = event.stream_id
|
||||
else:
|
||||
metadata = dict(metadata or {})
|
||||
metadata["_reasoning_delta"] = True
|
||||
if event.stream_id is not None:
|
||||
metadata["_stream_id"] = event.stream_id
|
||||
await channel.send_reasoning_delta(
|
||||
msg.chat_id,
|
||||
msg.content,
|
||||
metadata,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _send_reasoning_end(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
|
||||
metadata = msg.metadata
|
||||
kwargs: dict[str, Any] = {}
|
||||
if cls._accepts_keyword(channel.send_reasoning_end, "stream_id"):
|
||||
kwargs["stream_id"] = event.stream_id
|
||||
else:
|
||||
metadata = dict(metadata or {})
|
||||
metadata["_reasoning_end"] = True
|
||||
if event.stream_id is not None:
|
||||
metadata["_stream_id"] = event.stream_id
|
||||
await channel.send_reasoning_end(
|
||||
msg.chat_id,
|
||||
metadata,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _send_stream_event(
|
||||
cls,
|
||||
channel: BaseChannel,
|
||||
msg: OutboundMessage,
|
||||
event: StreamDeltaEvent | StreamEndEvent,
|
||||
) -> None:
|
||||
metadata = msg.metadata
|
||||
kwargs: dict[str, Any] = {}
|
||||
if cls._accepts_keyword(channel.send_delta, "stream_id"):
|
||||
kwargs["stream_id"] = event.stream_id
|
||||
else:
|
||||
metadata = dict(metadata or {})
|
||||
if event.stream_id is not None:
|
||||
metadata["_stream_id"] = event.stream_id
|
||||
|
||||
if isinstance(event, StreamEndEvent):
|
||||
if cls._accepts_keyword(channel.send_delta, "stream_end"):
|
||||
kwargs["stream_end"] = True
|
||||
else:
|
||||
metadata = dict(metadata or {})
|
||||
metadata["_stream_end"] = True
|
||||
if cls._accepts_keyword(channel.send_delta, "resuming"):
|
||||
kwargs["resuming"] = event.resuming
|
||||
elif not kwargs:
|
||||
metadata = dict(metadata or {})
|
||||
metadata["_stream_delta"] = True
|
||||
|
||||
await channel.send_delta(
|
||||
msg.chat_id,
|
||||
msg.content,
|
||||
metadata,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
|
||||
"""Send one outbound message without retry policy."""
|
||||
if msg.metadata.get("_reasoning_end"):
|
||||
await channel.send_reasoning_end(msg.chat_id, msg.metadata)
|
||||
elif msg.metadata.get("_reasoning_delta"):
|
||||
await channel.send_reasoning_delta(msg.chat_id, msg.content, msg.metadata)
|
||||
elif msg.metadata.get("_reasoning"):
|
||||
# Back-compat: one-shot reasoning. BaseChannel translates this
|
||||
# to a single delta + end pair so plugins only implement the
|
||||
# streaming primitives.
|
||||
event = outbound_event_from_message(msg)
|
||||
if isinstance(event, ProgressEvent) and event.reasoning_end:
|
||||
await ChannelManager._send_reasoning_end(channel, msg, event)
|
||||
elif isinstance(event, ProgressEvent) and event.reasoning_delta:
|
||||
await ChannelManager._send_reasoning_delta(channel, msg, event)
|
||||
elif isinstance(event, ProgressEvent) and event.reasoning:
|
||||
# BaseChannel translates one-shot reasoning to a single delta +
|
||||
# end pair so plugins only implement the streaming primitives.
|
||||
await channel.send_reasoning(msg)
|
||||
elif msg.metadata.get("_file_edit_events"):
|
||||
edits = msg.metadata.get("_file_edit_events")
|
||||
elif isinstance(event, ProgressEvent) and event.file_edit_events:
|
||||
await channel.send_file_edit_events(
|
||||
msg.chat_id,
|
||||
edits if isinstance(edits, list) else [],
|
||||
event.file_edit_events,
|
||||
msg.metadata,
|
||||
)
|
||||
elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"):
|
||||
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
|
||||
elif not msg.metadata.get("_streamed"):
|
||||
elif isinstance(event, StreamDeltaEvent):
|
||||
await ChannelManager._send_stream_event(channel, msg, event)
|
||||
elif isinstance(event, StreamEndEvent):
|
||||
await ChannelManager._send_stream_event(channel, msg, event)
|
||||
elif not isinstance(event, StreamedResponseEvent):
|
||||
await channel.send(msg)
|
||||
|
||||
def _coalesce_stream_deltas(
|
||||
self, first_msg: OutboundMessage
|
||||
) -> tuple[OutboundMessage, list[OutboundMessage]]:
|
||||
"""Merge consecutive _stream_delta messages for the same (channel, chat_id).
|
||||
"""Merge consecutive stream deltas for the same (channel, chat_id, stream_id).
|
||||
|
||||
This reduces the number of API calls when the queue has accumulated multiple
|
||||
deltas, which happens when LLM generates faster than the channel can process.
|
||||
@@ -404,9 +499,15 @@ class ChannelManager:
|
||||
Returns:
|
||||
tuple of (merged_message, list_of_non_matching_messages)
|
||||
"""
|
||||
target_key = (first_msg.channel, first_msg.chat_id)
|
||||
first_event = outbound_event_from_message(first_msg)
|
||||
first_stream_id = first_event.stream_id if isinstance(first_event, StreamDeltaEvent) else None
|
||||
target_key = (first_msg.channel, first_msg.chat_id, first_stream_id)
|
||||
combined_content = first_msg.content
|
||||
final_metadata = dict(first_msg.metadata or {})
|
||||
final_event: StreamDeltaEvent | StreamEndEvent = (
|
||||
first_event
|
||||
if isinstance(first_event, StreamDeltaEvent)
|
||||
else StreamDeltaEvent(stream_id=first_stream_id)
|
||||
)
|
||||
non_matching: list[OutboundMessage] = []
|
||||
|
||||
# Only merge consecutive deltas. As soon as we hit any other message,
|
||||
@@ -418,16 +519,29 @@ class ChannelManager:
|
||||
break
|
||||
|
||||
# Check if this message belongs to the same stream
|
||||
same_target = (next_msg.channel, next_msg.chat_id) == target_key
|
||||
is_delta = next_msg.metadata and next_msg.metadata.get("_stream_delta")
|
||||
is_end = next_msg.metadata and next_msg.metadata.get("_stream_end")
|
||||
next_event = outbound_event_from_message(next_msg)
|
||||
next_stream_id = (
|
||||
next_event.stream_id
|
||||
if isinstance(next_event, StreamDeltaEvent | StreamEndEvent)
|
||||
else None
|
||||
)
|
||||
same_target = (
|
||||
next_msg.channel,
|
||||
next_msg.chat_id,
|
||||
next_stream_id,
|
||||
) == target_key
|
||||
is_delta = isinstance(next_event, StreamDeltaEvent)
|
||||
is_end = isinstance(next_event, StreamEndEvent)
|
||||
|
||||
if same_target and is_delta and not final_metadata.get("_stream_end"):
|
||||
if same_target and (is_delta or (is_end and next_msg.content)):
|
||||
# Accumulate content
|
||||
combined_content += next_msg.content
|
||||
# If we see _stream_end, remember it and stop coalescing this stream
|
||||
if is_end:
|
||||
final_metadata["_stream_end"] = True
|
||||
# If we see stream_end, remember it and stop coalescing this stream
|
||||
if isinstance(next_event, StreamEndEvent):
|
||||
final_event = StreamEndEvent(
|
||||
stream_id=next_stream_id,
|
||||
resuming=next_event.resuming,
|
||||
)
|
||||
# Stream ended - stop coalescing this stream
|
||||
break
|
||||
else:
|
||||
@@ -435,12 +549,7 @@ class ChannelManager:
|
||||
non_matching.append(next_msg)
|
||||
break
|
||||
|
||||
merged = OutboundMessage(
|
||||
channel=first_msg.channel,
|
||||
chat_id=first_msg.chat_id,
|
||||
content=combined_content,
|
||||
metadata=final_metadata,
|
||||
)
|
||||
merged = replace_outbound_event(first_msg, final_event, content=combined_content)
|
||||
return merged, non_matching
|
||||
|
||||
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:
|
||||
|
||||
@@ -49,6 +49,7 @@ except ImportError as e:
|
||||
) from e
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_data_dir, get_media_dir
|
||||
@@ -504,7 +505,7 @@ class MatrixChannel(BaseChannel):
|
||||
text = msg.content or ""
|
||||
candidates = self._collect_outbound_media_candidates(msg.media)
|
||||
relates_to = self._build_thread_relates_to(msg.metadata)
|
||||
is_progress = bool((msg.metadata or {}).get("_progress"))
|
||||
is_progress = isinstance(msg.event, ProgressEvent)
|
||||
try:
|
||||
failures: list[str] = []
|
||||
if candidates:
|
||||
@@ -528,11 +529,19 @@ class MatrixChannel(BaseChannel):
|
||||
if not is_progress:
|
||||
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
|
||||
|
||||
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
meta = metadata or {}
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
relates_to = self._build_thread_relates_to(metadata)
|
||||
|
||||
if meta.get("_stream_end"):
|
||||
if stream_end:
|
||||
buf = self._stream_bufs.pop(chat_id, None)
|
||||
if not buf or not buf.event_id or not buf.text:
|
||||
return
|
||||
|
||||
@@ -18,6 +18,7 @@ import httpx
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
@@ -539,7 +540,7 @@ class SignalChannel(BaseChannel):
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Signal."""
|
||||
is_progress_message = bool(msg.metadata.get("_progress"))
|
||||
is_progress_message = isinstance(msg.event, ProgressEvent)
|
||||
try:
|
||||
plain_text, text_styles = _markdown_to_signal(msg.content)
|
||||
if not plain_text and not msg.media:
|
||||
|
||||
@@ -14,6 +14,7 @@ from slack_sdk.web.async_client import AsyncWebClient
|
||||
from slackify_markdown import slackify_markdown
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
@@ -164,7 +165,7 @@ class SlackChannel(BaseChannel):
|
||||
# only makes sense within the originating conversation.
|
||||
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None
|
||||
|
||||
is_progress = (msg.metadata or {}).get("_progress", False)
|
||||
is_progress = isinstance(msg.event, ProgressEvent)
|
||||
if is_progress and not msg.content:
|
||||
pass # skip empty progress messages (e.g. tool-event-only updates)
|
||||
elif msg.content or not (msg.media or []):
|
||||
@@ -190,7 +191,7 @@ class SlackChannel(BaseChannel):
|
||||
self.logger.exception("Failed to upload file {}", media_path)
|
||||
|
||||
# Update reaction emoji when the final (non-progress) response is sent
|
||||
if not (msg.metadata or {}).get("_progress"):
|
||||
if not is_progress:
|
||||
event = slack_meta.get("event", {})
|
||||
await self._update_react_emoji(origin_chat_id, event.get("ts"))
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from telegram.ext import Application, CallbackQueryHandler, ContextTypes, Messag
|
||||
from telegram.request import HTTPXRequest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.command.builtin import build_help_text
|
||||
@@ -36,7 +37,7 @@ from nanobot.utils.helpers import split_message
|
||||
|
||||
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
||||
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
|
||||
# safety margin for mid-stream edits (plain text). For _stream_end, we split
|
||||
# safety margin for mid-stream edits (plain text). On stream end, we split
|
||||
# raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char
|
||||
# boundary so the final rendered message never overflows.
|
||||
TELEGRAM_HTML_MAX_LEN = 4096
|
||||
@@ -351,6 +352,8 @@ class TelegramConfig(Base):
|
||||
streaming: bool = True
|
||||
# Enable inline keyboard buttons in Telegram messages.
|
||||
inline_keyboards: bool = False
|
||||
# Opt in to Bot API 10.1 sendRichMessage for richer markdown rendering.
|
||||
rich_messages: bool = False
|
||||
stream_edit_interval: float = Field(default=_STREAM_EDIT_INTERVAL_DEFAULT, ge=0.1)
|
||||
webhook_url: str = ""
|
||||
webhook_listen_host: str = "127.0.0.1"
|
||||
@@ -704,8 +707,10 @@ class TelegramChannel(BaseChannel):
|
||||
self.logger.warning("bot not running")
|
||||
return
|
||||
|
||||
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
|
||||
|
||||
# Only stop typing indicator and remove reaction for final responses
|
||||
if not msg.metadata.get("_progress", False):
|
||||
if progress_event is None:
|
||||
self._stop_typing(msg.chat_id)
|
||||
if reply_to_message_id := msg.metadata.get("message_id"):
|
||||
with suppress(ValueError):
|
||||
@@ -790,7 +795,7 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
# Send text content
|
||||
if msg.content and msg.content != "[empty message]":
|
||||
render_as_blockquote = bool(msg.metadata.get("_tool_hint"))
|
||||
render_as_blockquote = bool(progress_event and progress_event.tool_hint)
|
||||
buttons = getattr(msg, "buttons", None) or []
|
||||
reply_markup = self._build_keyboard(buttons) if buttons else None
|
||||
text = msg.content
|
||||
@@ -803,6 +808,7 @@ class TelegramChannel(BaseChannel):
|
||||
# latches off permanently if the server doesn't support it.
|
||||
if (
|
||||
not render_as_blockquote
|
||||
and self.config.rich_messages
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
):
|
||||
rich_ok = await self._try_send_rich(
|
||||
@@ -884,15 +890,23 @@ class TelegramChannel(BaseChannel):
|
||||
def _is_not_modified_error(exc: Exception) -> bool:
|
||||
return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower()
|
||||
|
||||
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
"""Progressive message editing: send on first delta, edit on subsequent ones."""
|
||||
if not self._app:
|
||||
return
|
||||
meta = metadata or {}
|
||||
int_chat_id = int(chat_id)
|
||||
stream_id = meta.get("_stream_id")
|
||||
|
||||
if meta.get("_stream_end"):
|
||||
if stream_end:
|
||||
buf = self._stream_bufs.get(chat_id)
|
||||
if not buf or not buf.message_id or not buf.text:
|
||||
return
|
||||
@@ -907,8 +921,11 @@ class TelegramChannel(BaseChannel):
|
||||
thread_kwargs["message_thread_id"] = message_thread_id
|
||||
raw_text = buf.text
|
||||
|
||||
# Try sendRichMessage for final output (Bot API 10.1)
|
||||
if not getattr(self, "_rich_send_disabled", False):
|
||||
# Try sendRichMessage for final output (Bot API 10.1).
|
||||
# Skip when a streaming preview already exists to avoid the
|
||||
# delete-and-resend pattern that causes flickering and drops
|
||||
# line breaks (issue #4470).
|
||||
if not buf.message_id and self.config.rich_messages and not getattr(self, "_rich_send_disabled", False):
|
||||
reply_params = None
|
||||
if reply_to_message_id := meta.get("message_id"):
|
||||
reply_params = {"message_id": int(reply_to_message_id), "allow_sending_without_reply": True}
|
||||
|
||||
@@ -19,6 +19,16 @@ from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest
|
||||
|
||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
GoalStateSyncEvent,
|
||||
GoalStatusEvent,
|
||||
ProgressEvent,
|
||||
RuntimeModelUpdatedEvent,
|
||||
SessionUpdatedEvent,
|
||||
TurnEndEvent,
|
||||
outbound_event_from_message,
|
||||
outbound_message_for_event,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
@@ -148,16 +158,13 @@ def publish_runtime_model_update(
|
||||
model_preset: str | None,
|
||||
) -> None:
|
||||
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
|
||||
bus.outbound.put_nowait(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="*",
|
||||
content="",
|
||||
metadata={
|
||||
"_runtime_model_updated": True,
|
||||
"model": model,
|
||||
"model_preset": model_preset,
|
||||
},
|
||||
))
|
||||
bus.outbound.put_nowait(
|
||||
outbound_message_for_event(
|
||||
channel="websocket",
|
||||
chat_id="*",
|
||||
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _parse_inbound_payload(raw: str) -> str | None:
|
||||
@@ -851,70 +858,63 @@ class WebSocketChannel(BaseChannel):
|
||||
raise
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
if msg.metadata.get("_runtime_model_updated"):
|
||||
event = outbound_event_from_message(msg)
|
||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
||||
if isinstance(event, RuntimeModelUpdatedEvent):
|
||||
await self.send_runtime_model_updated(
|
||||
model_name=msg.metadata.get("model"),
|
||||
model_preset=msg.metadata.get("model_preset"),
|
||||
model_name=event.model,
|
||||
model_preset=event.model_preset,
|
||||
)
|
||||
return
|
||||
|
||||
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
|
||||
conns = list(self._subs.get(msg.chat_id, ()))
|
||||
if not conns:
|
||||
if (
|
||||
msg.metadata.get("_progress")
|
||||
or msg.metadata.get("_file_edit_events")
|
||||
or msg.metadata.get("_turn_end")
|
||||
or msg.metadata.get("_session_updated")
|
||||
or msg.metadata.get("_goal_status")
|
||||
or msg.metadata.get("_goal_state_sync")
|
||||
if isinstance(
|
||||
event,
|
||||
ProgressEvent
|
||||
| TurnEndEvent
|
||||
| SessionUpdatedEvent
|
||||
| GoalStatusEvent
|
||||
| GoalStateSyncEvent,
|
||||
):
|
||||
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
||||
else:
|
||||
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
||||
if msg.metadata.get("_goal_state_sync"):
|
||||
if isinstance(event, GoalStateSyncEvent):
|
||||
if conns:
|
||||
blob = msg.metadata.get("goal_state")
|
||||
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
|
||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||
return
|
||||
if msg.metadata.get("_goal_status"):
|
||||
if isinstance(event, GoalStatusEvent):
|
||||
if conns:
|
||||
status = msg.metadata.get("goal_status")
|
||||
if status in ("running", "idle"):
|
||||
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
|
||||
if event.status in ("running", "idle"):
|
||||
await self.send_goal_status(
|
||||
msg.chat_id,
|
||||
status,
|
||||
started_at=float(started_raw) if isinstance(started_raw, int | float) else None,
|
||||
event.status,
|
||||
started_at=event.started_at,
|
||||
)
|
||||
return
|
||||
# Signal that the agent has fully finished processing the current turn.
|
||||
if msg.metadata.get("_turn_end"):
|
||||
lat = msg.metadata.get("latency_ms")
|
||||
lat_i = int(lat) if isinstance(lat, (int, float)) else None
|
||||
gs = msg.metadata.get("goal_state")
|
||||
gs_blob = gs if isinstance(gs, dict) else None
|
||||
if isinstance(event, TurnEndEvent):
|
||||
await self.send_turn_end(
|
||||
msg.chat_id,
|
||||
latency_ms=lat_i,
|
||||
goal_state=gs_blob,
|
||||
latency_ms=event.latency_ms,
|
||||
goal_state=event.goal_state,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
||||
return
|
||||
if msg.metadata.get("_session_updated"):
|
||||
if isinstance(event, SessionUpdatedEvent):
|
||||
if conns:
|
||||
scope = msg.metadata.get("_session_update_scope")
|
||||
await self.send_session_updated(
|
||||
msg.chat_id,
|
||||
scope=scope if isinstance(scope, str) else None,
|
||||
scope=event.scope,
|
||||
)
|
||||
return
|
||||
if msg.metadata.get("_file_edit_events"):
|
||||
edits = msg.metadata.get("_file_edit_events")
|
||||
if progress_event and progress_event.file_edit_events:
|
||||
await self.send_file_edit_events(
|
||||
msg.chat_id,
|
||||
edits if isinstance(edits, list) else [],
|
||||
progress_event.file_edit_events,
|
||||
msg.metadata,
|
||||
)
|
||||
return
|
||||
@@ -939,17 +939,17 @@ class WebSocketChannel(BaseChannel):
|
||||
lat = msg.metadata.get("latency_ms")
|
||||
if isinstance(lat, (int, float)):
|
||||
payload["latency_ms"] = int(lat)
|
||||
if msg.metadata.get("_tool_events"):
|
||||
payload["tool_events"] = msg.metadata["_tool_events"]
|
||||
if progress_event and progress_event.tool_events:
|
||||
payload["tool_events"] = progress_event.tool_events
|
||||
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
|
||||
if agent_ui is not None:
|
||||
payload["agent_ui"] = agent_ui
|
||||
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
||||
# progress strings) so WS clients can render them as subordinate
|
||||
# trace rows rather than conversational replies.
|
||||
if msg.metadata.get("_tool_hint"):
|
||||
if progress_event and progress_event.tool_hint:
|
||||
payload["kind"] = "tool_hint"
|
||||
elif msg.metadata.get("_progress"):
|
||||
elif progress_event:
|
||||
payload["kind"] = "progress"
|
||||
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
|
||||
self._transcripts.prepare_and_append(
|
||||
@@ -971,6 +971,8 @@ class WebSocketChannel(BaseChannel):
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
|
||||
clients receive a stream that opens, updates in place, and closes —
|
||||
@@ -986,7 +988,6 @@ class WebSocketChannel(BaseChannel):
|
||||
"chat_id": chat_id,
|
||||
"text": delta,
|
||||
}
|
||||
stream_id = meta.get("_stream_id")
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._transcripts.prepare_and_append(
|
||||
@@ -1005,6 +1006,8 @@ class WebSocketChannel(BaseChannel):
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
"""Close the current reasoning stream segment for in-place renderers."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
@@ -1013,7 +1016,6 @@ class WebSocketChannel(BaseChannel):
|
||||
"event": "reasoning_end",
|
||||
"chat_id": chat_id,
|
||||
}
|
||||
stream_id = meta.get("_stream_id")
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._transcripts.prepare_and_append(
|
||||
@@ -1057,11 +1059,15 @@ class WebSocketChannel(BaseChannel):
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
meta = metadata or {}
|
||||
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
|
||||
if meta.get("_stream_end"):
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
if stream_end:
|
||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||
buffered = self._stream_text_buffers.pop(stream_key, [])
|
||||
if delta:
|
||||
@@ -1077,8 +1083,8 @@ class WebSocketChannel(BaseChannel):
|
||||
"text": delta,
|
||||
}
|
||||
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
||||
if meta.get("_stream_id") is not None:
|
||||
body["stream_id"] = meta["_stream_id"]
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._transcripts.prepare_and_append(
|
||||
chat_id,
|
||||
body,
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
@@ -497,7 +498,7 @@ class WecomChannel(BaseChannel):
|
||||
|
||||
try:
|
||||
content = (msg.content or "").strip()
|
||||
is_progress = bool(msg.metadata.get("_progress"))
|
||||
is_progress = isinstance(msg.event, ProgressEvent)
|
||||
|
||||
# Get the stored frame for this chat
|
||||
frame = self._chat_frames.get(msg.chat_id)
|
||||
|
||||
+54
-10
@@ -29,6 +29,7 @@ from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir, get_runtime_subdir
|
||||
@@ -129,6 +130,13 @@ class WeixinConfig(Base):
|
||||
token: str = "" # Manually set token, or obtained via QR login
|
||||
state_dir: str = "" # Default: ~/.nanobot/weixin/
|
||||
poll_timeout: int = DEFAULT_LONG_POLL_TIMEOUT_S # seconds for long-poll
|
||||
# Default on: WeChat iLink has no native incremental delivery (send_delta is
|
||||
# buffered and the final answer is still sent in one shot), so streaming has
|
||||
# zero user-facing effect here — it only switches the LLM call to the
|
||||
# streaming API. That avoids upstream Anthropic relays that drop tool_use
|
||||
# id/name/input on the non-streaming Messages path (a common third-party
|
||||
# relay bug). Set to false only if a relay's streaming/SSE path is broken.
|
||||
streaming: bool = True
|
||||
|
||||
|
||||
class WeixinChannel(BaseChannel):
|
||||
@@ -167,6 +175,10 @@ class WeixinChannel(BaseChannel):
|
||||
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
||||
self._context_token_at: dict[str, float] = {}
|
||||
self._pending_tool_hints: dict[str, list[str]] = {}
|
||||
# Buffers streamed content deltas per chat. WeChat iLink has no native
|
||||
# incremental delivery, so when streaming is enabled we accumulate the
|
||||
# deltas and flush the full reply in one shot at _stream_end.
|
||||
self._stream_buffers: dict[str, list[str]] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State persistence
|
||||
@@ -1090,11 +1102,13 @@ class WeixinChannel(BaseChannel):
|
||||
raise RuntimeError("WeChat client not initialized or not authenticated")
|
||||
self._assert_session_active()
|
||||
|
||||
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
||||
event = getattr(msg, "event", None)
|
||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
||||
is_progress = progress_event is not None
|
||||
|
||||
# Buffer tool hints to coalesce consecutive ones and avoid burning
|
||||
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
|
||||
if is_progress and (msg.metadata or {}).get("_tool_hint"):
|
||||
if progress_event and progress_event.tool_hint:
|
||||
if not self.send_tool_hints:
|
||||
return
|
||||
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
|
||||
@@ -1107,7 +1121,7 @@ class WeixinChannel(BaseChannel):
|
||||
|
||||
# Reasoning deltas are invisible in WeChat (there is no reasoning
|
||||
# UI). Skip them entirely — do not send and do not flush buffer.
|
||||
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
|
||||
if progress_event and (progress_event.reasoning_delta or progress_event.reasoning):
|
||||
self.logger.debug(
|
||||
"Dropped invisible reasoning delta for {}", msg.chat_id
|
||||
)
|
||||
@@ -1221,16 +1235,46 @@ class WeixinChannel(BaseChannel):
|
||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
||||
|
||||
async def send_delta(
|
||||
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
"""Weixin iLink does not support native streaming deltas.
|
||||
"""Deliver a streamed reply to WeChat.
|
||||
|
||||
We only hook ``_stream_end`` so buffered tool hints are flushed even
|
||||
when the final answer carries the ``_streamed`` flag and bypasses
|
||||
:meth:`send`.
|
||||
WeChat iLink has no native incremental delivery, and the manager
|
||||
bypasses :meth:`send` for the ``_streamed`` final answer. So we
|
||||
accumulate content deltas and flush the full reply as a single message
|
||||
at stream end. Reasoning deltas are invisible in WeChat and are dropped.
|
||||
"""
|
||||
if metadata and metadata.get("_stream_end"):
|
||||
await self._flush_tool_hints(chat_id)
|
||||
meta = metadata or {}
|
||||
if meta.get("_reasoning_delta") or meta.get("_reasoning"):
|
||||
return
|
||||
is_end = stream_end or bool(meta.get("_stream_end"))
|
||||
buffer_key = stream_id or chat_id
|
||||
# Accumulate intermediate deltas. The stream_end message's own content
|
||||
# (present when the manager coalesces deltas into the end message) is
|
||||
# folded into `full` below instead of appended here, so a send retry
|
||||
# recomputes the same `full` from an unchanged buffer rather than
|
||||
# double-counting that delta.
|
||||
if delta and not is_end:
|
||||
self._stream_buffers.setdefault(buffer_key, []).append(delta)
|
||||
if not is_end:
|
||||
return
|
||||
full = ("".join(self._stream_buffers.get(buffer_key, [])) + (delta or "")).strip()
|
||||
await self._flush_tool_hints(chat_id)
|
||||
if full:
|
||||
# Send before clearing the buffer: if the send raises, the buffer is
|
||||
# left intact so ChannelManager._send_with_retry can re-deliver the
|
||||
# same stream_end message instead of silently losing the reply.
|
||||
await self.send(
|
||||
OutboundMessage(channel=self.name, chat_id=chat_id, content=full)
|
||||
)
|
||||
self._stream_buffers.pop(buffer_key, None)
|
||||
|
||||
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
||||
"""Start typing indicator immediately when a message is received."""
|
||||
|
||||
+616
-329
File diff suppressed because it is too large
Load Diff
+135
-30
@@ -5,7 +5,7 @@ import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import nullcontext, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -50,6 +50,14 @@ from rich.text import Text # noqa: E402
|
||||
|
||||
from nanobot import __logo__, __version__ # noqa: E402
|
||||
from nanobot.agent.loop import AgentLoop # noqa: E402
|
||||
from nanobot.bus.outbound_events import ( # noqa: E402
|
||||
ProgressEvent,
|
||||
RetryWaitEvent,
|
||||
StreamDeltaEvent,
|
||||
StreamedResponseEvent,
|
||||
StreamEndEvent,
|
||||
outbound_event_from_message,
|
||||
)
|
||||
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
||||
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
||||
@@ -61,6 +69,7 @@ from nanobot.utils.restart import ( # noqa: E402
|
||||
format_restart_completed_message,
|
||||
should_show_cli_restart_notice,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import read_webui_sidebar_state # noqa: E402
|
||||
|
||||
|
||||
def _sanitize_surrogates(text: str) -> str:
|
||||
@@ -153,6 +162,12 @@ def _install_gateway_shutdown_handlers(
|
||||
return restore
|
||||
|
||||
|
||||
def _advance_dream_cursor_if_behind(memory: Any) -> None:
|
||||
latest = memory.get_latest_cursor()
|
||||
if memory.get_last_dream_cursor() < latest:
|
||||
memory.set_last_dream_cursor(latest)
|
||||
|
||||
|
||||
class SafeFileHistory(FileHistory):
|
||||
"""FileHistory subclass that sanitizes surrogate characters on write.
|
||||
|
||||
@@ -210,6 +225,29 @@ def _heartbeat_has_active_tasks(content: str) -> bool:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pick_heartbeat_target_from_sessions(
|
||||
*,
|
||||
enabled_channels: Iterable[str],
|
||||
sessions: Iterable[dict[str, Any]],
|
||||
archived_keys: Iterable[str],
|
||||
) -> tuple[str, str]:
|
||||
enabled = set(enabled_channels)
|
||||
archived = set(archived_keys)
|
||||
for item in sessions:
|
||||
key = item.get("key") or ""
|
||||
if key in archived:
|
||||
continue
|
||||
if ":" not in key:
|
||||
continue
|
||||
channel, chat_id = key.split(":", 1)
|
||||
if channel in {"cli", "system"}:
|
||||
continue
|
||||
if channel in enabled and chat_id:
|
||||
return channel, chat_id
|
||||
return "cli", "direct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI input: prompt_toolkit for editing, paste, history, and display
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -431,25 +469,25 @@ async def _maybe_print_interactive_progress(
|
||||
renderer: StreamRenderer | None = None,
|
||||
reasoning_buffer: _ReasoningBuffer | None = None,
|
||||
) -> bool:
|
||||
metadata = msg.metadata or {}
|
||||
if metadata.get("_retry_wait"):
|
||||
event = outbound_event_from_message(msg)
|
||||
if isinstance(event, RetryWaitEvent):
|
||||
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
||||
return True
|
||||
|
||||
if not metadata.get("_progress"):
|
||||
if not isinstance(event, ProgressEvent):
|
||||
return False
|
||||
|
||||
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
|
||||
|
||||
if metadata.get("_reasoning_end"):
|
||||
if event.reasoning_end:
|
||||
if channels_config and not channels_config.show_reasoning:
|
||||
reasoning_buffer.clear()
|
||||
else:
|
||||
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
|
||||
return True
|
||||
|
||||
is_tool_hint = metadata.get("_tool_hint", False)
|
||||
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
|
||||
is_tool_hint = event.tool_hint
|
||||
is_reasoning = event.reasoning or event.reasoning_delta
|
||||
if is_reasoning:
|
||||
if channels_config and not channels_config.show_reasoning:
|
||||
reasoning_buffer.clear()
|
||||
@@ -768,14 +806,24 @@ def serve(
|
||||
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
|
||||
console.print(" [cyan]Session[/cyan] : api:default")
|
||||
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
||||
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
|
||||
if host in {"0.0.0.0", "::"}:
|
||||
if not api_key:
|
||||
console.print(
|
||||
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
|
||||
"Set api.api_key in config to prevent unauthenticated access.[/red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] API is bound to all interfaces. "
|
||||
"Only do this behind a trusted network boundary, firewall, or reverse proxy."
|
||||
"[yellow]API is bound to all interfaces "
|
||||
"(authentication required).[/yellow]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout)
|
||||
api_app = create_app(
|
||||
agent_loop, model_name=model_name, request_timeout=timeout,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
async def on_startup(_app):
|
||||
await agent_loop._connect_mcp()
|
||||
@@ -1064,17 +1112,12 @@ def _run_gateway(
|
||||
|
||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
||||
enabled = set(channels.enabled_channels)
|
||||
for item in session_manager.list_sessions():
|
||||
key = item.get("key") or ""
|
||||
if ":" not in key:
|
||||
continue
|
||||
channel, chat_id = key.split(":", 1)
|
||||
if channel in {"cli", "system"}:
|
||||
continue
|
||||
if channel in enabled and chat_id:
|
||||
return channel, chat_id
|
||||
return "cli", "direct"
|
||||
sidebar_state = read_webui_sidebar_state()
|
||||
return _pick_heartbeat_target_from_sessions(
|
||||
enabled_channels=channels.enabled_channels,
|
||||
sessions=session_manager.list_sessions(),
|
||||
archived_keys=sidebar_state.get("archived_keys", []),
|
||||
)
|
||||
|
||||
if channels.enabled_channels:
|
||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||
@@ -1146,6 +1189,7 @@ def _run_gateway(
|
||||
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
|
||||
else:
|
||||
console.print("[yellow]○[/yellow] Dream: disabled")
|
||||
_advance_dream_cursor_if_behind(agent.context.memory)
|
||||
|
||||
# Register Heartbeat system job (idempotent on restart)
|
||||
if hb_cfg.enabled:
|
||||
@@ -1420,7 +1464,7 @@ def agent(
|
||||
bus_task = asyncio.create_task(agent_loop.run())
|
||||
turn_done = asyncio.Event()
|
||||
turn_done.set()
|
||||
turn_response: list[tuple[str, dict]] = []
|
||||
turn_response: list[Any] = []
|
||||
renderer: StreamRenderer | None = None
|
||||
reasoning_buffer = _ReasoningBuffer()
|
||||
|
||||
@@ -1428,18 +1472,19 @@ def agent(
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
event = outbound_event_from_message(msg)
|
||||
|
||||
if msg.metadata.get("_stream_delta"):
|
||||
if isinstance(event, StreamDeltaEvent):
|
||||
if renderer:
|
||||
await renderer.on_delta(msg.content)
|
||||
continue
|
||||
if msg.metadata.get("_stream_end"):
|
||||
if isinstance(event, StreamEndEvent):
|
||||
if renderer:
|
||||
await renderer.on_end(
|
||||
resuming=msg.metadata.get("_resuming", False),
|
||||
resuming=event.resuming,
|
||||
)
|
||||
continue
|
||||
if msg.metadata.get("_streamed"):
|
||||
if isinstance(event, StreamedResponseEvent):
|
||||
turn_done.set()
|
||||
continue
|
||||
|
||||
@@ -1454,7 +1499,7 @@ def agent(
|
||||
|
||||
if not turn_done.is_set():
|
||||
if msg.content:
|
||||
turn_response.append((msg.content, dict(msg.metadata or {})))
|
||||
turn_response.append(msg)
|
||||
turn_done.set()
|
||||
elif msg.content:
|
||||
await _print_interactive_response(
|
||||
@@ -1507,8 +1552,10 @@ def agent(
|
||||
await turn_done.wait()
|
||||
|
||||
if turn_response:
|
||||
content, meta = turn_response[0]
|
||||
if content and not meta.get("_streamed"):
|
||||
response_msg = turn_response[0]
|
||||
content = response_msg.content
|
||||
meta = response_msg.metadata
|
||||
if content and not isinstance(response_msg.event, StreamedResponseEvent):
|
||||
if renderer:
|
||||
await renderer.close()
|
||||
print_kwargs: dict[str, Any] = {}
|
||||
@@ -1718,6 +1765,11 @@ _PROVIDER_DISPLAY: dict[str, str] = {
|
||||
"github_copilot": "GitHub Copilot",
|
||||
}
|
||||
|
||||
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||
"openai_codex": "openai-codex/gpt-5.4-mini",
|
||||
"github_copilot": "github-copilot/gpt-5.4-mini",
|
||||
}
|
||||
|
||||
|
||||
def _register_login(name: str):
|
||||
"""Register an OAuth login handler."""
|
||||
@@ -1749,9 +1801,51 @@ def _resolve_oauth_provider(provider: str):
|
||||
return spec
|
||||
|
||||
|
||||
def _set_oauth_provider_as_main(
|
||||
provider_name: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
config_path: str | None = None,
|
||||
) -> None:
|
||||
"""Persist an OAuth provider as the active agent provider."""
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
|
||||
|
||||
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
||||
if resolved_config_path is not None:
|
||||
set_config_path(resolved_config_path)
|
||||
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
||||
|
||||
config = load_config(resolved_config_path)
|
||||
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
||||
config.agents.defaults.model_preset = None
|
||||
config.agents.defaults.provider = provider_name
|
||||
config.agents.defaults.model = selected_model
|
||||
save_config(config, resolved_config_path)
|
||||
|
||||
saved_path = resolved_config_path or get_config_path()
|
||||
console.print(
|
||||
f"[green]✓ Set {provider_name.replace('_', '-')} as the main provider[/green] "
|
||||
f"[dim]{selected_model}[/dim]"
|
||||
)
|
||||
console.print(f"[dim]Saved: {saved_path}[/dim]")
|
||||
|
||||
|
||||
@provider_app.command("login")
|
||||
def provider_login(
|
||||
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
||||
set_main: bool = typer.Option(
|
||||
False,
|
||||
"--set-main",
|
||||
"--main",
|
||||
help="Set this OAuth provider as the active agent provider after login",
|
||||
),
|
||||
model: str | None = typer.Option(
|
||||
None,
|
||||
"--model",
|
||||
"-m",
|
||||
help="Model to use when setting this provider as the active provider",
|
||||
),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
):
|
||||
"""Authenticate with an OAuth provider."""
|
||||
spec = _resolve_oauth_provider(provider)
|
||||
@@ -1763,6 +1857,8 @@ def provider_login(
|
||||
|
||||
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
|
||||
handler()
|
||||
if set_main or model:
|
||||
_set_oauth_provider_as_main(spec.name, model=model, config_path=config)
|
||||
|
||||
|
||||
@provider_app.command("logout")
|
||||
@@ -1786,14 +1882,23 @@ def _login_openai_codex() -> None:
|
||||
try:
|
||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
proxy = None
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
except ValueError as e:
|
||||
console.print(f"[red]{e}[/red]")
|
||||
raise typer.Exit(1) from e
|
||||
token = None
|
||||
with suppress(Exception):
|
||||
token = get_token()
|
||||
token = get_token(proxy=proxy)
|
||||
if not (token and token.access):
|
||||
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||
token = login_oauth_interactive(
|
||||
print_fn=lambda s: console.print(s),
|
||||
prompt_fn=lambda s: typer.prompt(s),
|
||||
proxy=proxy,
|
||||
)
|
||||
if not (token and token.access):
|
||||
console.print("[red]✗ Authentication failed[/red]")
|
||||
|
||||
+35
-7
@@ -762,13 +762,11 @@ def _handle_model_preset_field(
|
||||
setattr(working_model, field_name, new_value)
|
||||
|
||||
|
||||
def _handle_provider_field(
|
||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||
def _set_field_from_choices(
|
||||
working_model: BaseModel, field_name: str, field_display: str,
|
||||
choices: list[str], default_choice: str
|
||||
) -> None:
|
||||
"""Handle the 'provider' field with a list of registered providers."""
|
||||
provider_names = sorted(_get_provider_names().keys())
|
||||
choices = ["auto"] + provider_names
|
||||
default_choice = str(current_value) if current_value else "auto"
|
||||
"""Prompt to pick one of ``choices`` and set the field (no-op on back/cancel)."""
|
||||
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||
if new_value is _BACK_PRESSED:
|
||||
return
|
||||
@@ -776,6 +774,15 @@ def _handle_provider_field(
|
||||
setattr(working_model, field_name, new_value)
|
||||
|
||||
|
||||
def _handle_provider_field(
|
||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||
) -> None:
|
||||
"""Handle the 'provider' field with a list of registered LLM providers."""
|
||||
choices = ["auto"] + sorted(_get_provider_names().keys())
|
||||
default_choice = str(current_value) if current_value else "auto"
|
||||
_set_field_from_choices(working_model, field_name, field_display, choices, default_choice)
|
||||
|
||||
|
||||
def _handle_fallback_models_field(
|
||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||
) -> None:
|
||||
@@ -836,6 +843,17 @@ def _handle_fallback_models_field(
|
||||
items.clear()
|
||||
|
||||
|
||||
def _handle_search_provider_field(
|
||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||
) -> None:
|
||||
"""Handle the web-search 'provider' field with the search-engine list."""
|
||||
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
||||
|
||||
choices = [opt["name"] for opt in SEARCH_PROVIDER_OPTIONS]
|
||||
default_choice = current_value if current_value in choices else choices[0]
|
||||
_set_field_from_choices(working_model, field_name, field_display, choices, default_choice)
|
||||
|
||||
|
||||
_FIELD_HANDLERS: dict[str, Any] = {
|
||||
"model": _handle_model_field,
|
||||
"context_window_tokens": _handle_context_window_field,
|
||||
@@ -845,6 +863,16 @@ _FIELD_HANDLERS: dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
def _resolve_field_handler(model: BaseModel, field_name: str) -> Any:
|
||||
"""Resolve the handler for a field. WebSearchConfig shares the bare "provider"
|
||||
name with LLM configs but needs the search-engine picker, not the LLM list."""
|
||||
if field_name == "provider":
|
||||
from nanobot.agent.tools.web import WebSearchConfig
|
||||
if isinstance(model, WebSearchConfig):
|
||||
return _handle_search_provider_field
|
||||
return _FIELD_HANDLERS.get(field_name)
|
||||
|
||||
|
||||
def _is_str_or_none(annotation: Any) -> bool:
|
||||
"""Check whether a field annotation is ``str | None`` (or ``Optional[str]``)."""
|
||||
origin = get_origin(annotation)
|
||||
@@ -934,7 +962,7 @@ def _configure_pydantic_model(
|
||||
continue
|
||||
|
||||
# Registered special-field handlers
|
||||
handler = _FIELD_HANDLERS.get(field_name)
|
||||
handler = _resolve_field_handler(working_model, field_name)
|
||||
if handler:
|
||||
handler(working_model, field_name, field_display, current_value)
|
||||
continue
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
@@ -50,7 +51,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
|
||||
BuiltinCommandSpec(
|
||||
"/restart",
|
||||
"Restart nanobot",
|
||||
"Restart the bot process in place.",
|
||||
"Restart the bot process.",
|
||||
"rotate-cw",
|
||||
),
|
||||
BuiltinCommandSpec(
|
||||
@@ -130,6 +131,15 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||
loop = ctx.loop
|
||||
msg = ctx.msg
|
||||
total = await loop._cancel_active_tasks(ctx.key)
|
||||
# Also drain pending queue to prevent mid-turn injection deadlock
|
||||
pending = loop._pending_queues.pop(ctx.key, None)
|
||||
if pending is not None:
|
||||
while not pending.empty():
|
||||
try:
|
||||
pending.get_nowait()
|
||||
total += 1
|
||||
except Exception:
|
||||
break
|
||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||
return OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||
@@ -138,7 +148,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
|
||||
async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Restart the process in-place via os.execv."""
|
||||
"""Restart the process."""
|
||||
msg = ctx.msg
|
||||
set_restart_notice_to_env(
|
||||
channel=msg.channel,
|
||||
@@ -148,7 +158,19 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
async def _do_restart():
|
||||
await asyncio.sleep(1)
|
||||
os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
|
||||
argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:]
|
||||
mode = getattr(ctx.loop, "restart_mode", "auto") or "auto"
|
||||
if mode == "auto":
|
||||
mode = "spawn" if sys.platform == "win32" else "exec"
|
||||
if mode == "exec":
|
||||
os.execv(sys.executable, argv)
|
||||
return
|
||||
if mode == "spawn":
|
||||
kwargs = {}
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
subprocess.Popen(argv, **kwargs)
|
||||
os._exit(0)
|
||||
|
||||
asyncio.create_task(_do_restart())
|
||||
return OutboundMessage(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
from nanobot.config.paths import (
|
||||
get_bridge_install_dir,
|
||||
get_cli_history_path,
|
||||
get_cron_dir,
|
||||
get_data_dir,
|
||||
@@ -29,6 +28,5 @@ __all__ = [
|
||||
"get_workspace_path",
|
||||
"is_default_workspace",
|
||||
"get_cli_history_path",
|
||||
"get_bridge_install_dir",
|
||||
"get_legacy_sessions_dir",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pydantic
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||
@@ -79,6 +80,10 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = config.model_dump(mode="json", by_alias=True)
|
||||
if config.providers.openai_codex.proxy is not None:
|
||||
data.setdefault("providers", {})["openaiCodex"] = {
|
||||
"proxy": config.providers.openai_codex.proxy,
|
||||
}
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
@@ -152,6 +157,23 @@ def _env_replace(match: re.Match[str]) -> str:
|
||||
|
||||
def _migrate_config(data: dict) -> dict:
|
||||
"""Migrate old config formats to current."""
|
||||
agents = data.get("agents", {})
|
||||
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
|
||||
if isinstance(defaults, dict):
|
||||
had_legacy_max_messages = (
|
||||
"maxMessages" in defaults or "max_messages" in defaults
|
||||
)
|
||||
defaults.pop("maxMessages", None)
|
||||
defaults.pop("max_messages", None)
|
||||
if had_legacy_max_messages:
|
||||
# TODO(next version): Remove this legacy cleanup branch; the schema
|
||||
# will silently ignore this field once the warning grace period ends.
|
||||
logger.warning(
|
||||
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
||||
"replay max messages is now an internal safety cap. Remove it from "
|
||||
"config. This compatibility warning will be removed in the next version."
|
||||
)
|
||||
|
||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||
tools = data.get("tools", {})
|
||||
exec_cfg = tools.get("exec", {})
|
||||
|
||||
@@ -66,11 +66,6 @@ def get_cli_history_path() -> Path:
|
||||
return Path.home() / ".nanobot" / "history" / "cli_history"
|
||||
|
||||
|
||||
def get_bridge_install_dir() -> Path:
|
||||
"""Return the shared WhatsApp bridge installation directory."""
|
||||
return Path.home() / ".nanobot" / "bridge"
|
||||
|
||||
|
||||
def get_legacy_sessions_dir() -> Path:
|
||||
"""Return the legacy global session directory used for migration fallback."""
|
||||
return Path.home() / ".nanobot" / "sessions"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
||||
|
||||
from pydantic import AliasChoices, ConfigDict, Field, model_validator
|
||||
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from nanobot.config_base import Base
|
||||
@@ -56,7 +56,10 @@ class DreamConfig(Base):
|
||||
|
||||
enabled: bool = True # Register the periodic Dream consolidation job on startup
|
||||
interval_h: int = Field(default=2, ge=1) # Every 2 hours by default
|
||||
cron: str | None = Field(default=None, exclude=True) # Legacy cron expression override
|
||||
cron: str | None = Field(
|
||||
default=None,
|
||||
exclude_if=lambda value: value is None,
|
||||
) # Legacy cron expression override
|
||||
model_override: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
||||
@@ -129,6 +132,7 @@ class AgentDefaults(Base):
|
||||
fallback_models: list[FallbackCandidate] = Field(default_factory=list)
|
||||
max_tool_iterations: int = 200
|
||||
max_concurrent_subagents: int = Field(default=1, ge=1)
|
||||
fail_on_tool_error: bool = True
|
||||
max_tool_result_chars: int = 16_000
|
||||
provider_retry_mode: Literal["standard", "persistent"] = "standard"
|
||||
tool_hint_max_length: int = Field(
|
||||
@@ -150,10 +154,6 @@ class AgentDefaults(Base):
|
||||
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
|
||||
serialization_alias="idleCompactAfterMinutes",
|
||||
) # Auto-compact idle threshold in minutes (0 = disabled)
|
||||
max_messages: int = Field(
|
||||
default=120,
|
||||
ge=0,
|
||||
) # Max messages to replay from session history (0 = use default 120, respects token budget)
|
||||
consolidation_ratio: float = Field(
|
||||
default=0.5,
|
||||
ge=0.1,
|
||||
@@ -179,6 +179,30 @@ class ProviderConfig(Base):
|
||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
||||
extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways)
|
||||
proxy: str | None = None # OpenAI-compatible/Codex HTTP proxy URL
|
||||
thinking_style: str | None = None # Thinking/reasoning style for custom providers
|
||||
|
||||
# Valid values mirror the keys of _THINKING_STYLE_MAP in
|
||||
# nanobot/providers/openai_compat_provider.py. Kept duplicated here to
|
||||
# avoid an import cycle (schema.py must not import from providers/).
|
||||
_VALID_THINKING_STYLES: ClassVar[tuple[str, ...]] = (
|
||||
"thinking_type",
|
||||
"enable_thinking",
|
||||
"reasoning_split",
|
||||
)
|
||||
|
||||
@field_validator("thinking_style")
|
||||
@classmethod
|
||||
def _validate_thinking_style(cls, v: str | None) -> str | None:
|
||||
if not v: # None or "" -> no injection, valid (backwards compatible)
|
||||
return v
|
||||
if v not in cls._VALID_THINKING_STYLES:
|
||||
raise ValueError(
|
||||
f"Invalid thinking_style {v!r}. "
|
||||
f"Must be one of: {', '.join(repr(s) for s in cls._VALID_THINKING_STYLES)} "
|
||||
f"(or empty/omitted)."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class BedrockProviderConfig(ProviderConfig):
|
||||
@@ -217,6 +241,7 @@ class ProvidersConfig(Base):
|
||||
ovms: ProviderConfig = Field(default_factory=ProviderConfig) # OpenVINO Model Server (OVMS)
|
||||
gemini: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
kimi_coding: ProviderConfig = Field(default_factory=ProviderConfig) # Kimi Coding Plan (Anthropic Messages API)
|
||||
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
minimax_anthropic: ProviderConfig = Field(default_factory=ProviderConfig) # MiniMax Anthropic endpoint (thinking)
|
||||
mistral: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
@@ -235,6 +260,8 @@ class ProvidersConfig(Base):
|
||||
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth)
|
||||
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
|
||||
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
|
||||
opencode_zen: ProviderConfig = Field(default_factory=ProviderConfig) # OpenCode Zen (curated coding models)
|
||||
opencode_go: ProviderConfig = Field(default_factory=ProviderConfig) # OpenCode Go (low-cost coding models)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def convert_extra_providers(self):
|
||||
@@ -280,6 +307,18 @@ class ApiConfig(Base):
|
||||
host: str = "127.0.0.1" # Safer default: local-only bind.
|
||||
port: int = 8900
|
||||
timeout: float = 120.0 # Per-request timeout in seconds.
|
||||
api_key: str = Field(default="", repr=False)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def wildcard_host_requires_auth(self) -> "ApiConfig":
|
||||
if self.host not in ("0.0.0.0", "::"):
|
||||
return self
|
||||
if self.api_key.strip():
|
||||
return self
|
||||
raise ValueError(
|
||||
"host is 0.0.0.0 (all interfaces) but api_key is not set "
|
||||
"- set api.api_key to prevent unauthenticated access"
|
||||
)
|
||||
|
||||
|
||||
class GatewayConfig(Base):
|
||||
@@ -287,6 +326,7 @@ class GatewayConfig(Base):
|
||||
|
||||
host: str = "127.0.0.1" # Safer default: local-only bind.
|
||||
port: int = 18790
|
||||
restart_mode: Literal["auto", "exec", "spawn", "exit"] = "auto"
|
||||
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
||||
|
||||
|
||||
@@ -301,7 +341,7 @@ class MCPServerConfig(Base):
|
||||
url: str = "" # HTTP/SSE: endpoint URL
|
||||
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
|
||||
tool_timeout: int = 30 # seconds before a tool call is cancelled
|
||||
enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools
|
||||
enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all capabilities (tools, resources, prompts); any restriction = only listed tools, no resources/prompts
|
||||
|
||||
|
||||
def _lazy_default(module_path: str, class_name: str) -> Any:
|
||||
|
||||
+35
-11
@@ -1,6 +1,7 @@
|
||||
"""Cron service for scheduling agent tasks."""
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -357,6 +358,25 @@ class CronService:
|
||||
|
||||
return self._store
|
||||
|
||||
def _require_store(self) -> CronStore:
|
||||
"""Return a usable store or raise a clear error.
|
||||
|
||||
``_load_store`` deliberately returns ``None`` when the first load sees
|
||||
a corrupt on-disk store and no previous in-memory snapshot exists. The
|
||||
public API requires a concrete store object before touching
|
||||
``store.jobs``; raising here keeps callers from seeing an accidental
|
||||
``AttributeError`` and, more importantly, prevents follow-up saves from
|
||||
treating a corrupt store as an empty one.
|
||||
"""
|
||||
store = self._load_store()
|
||||
if store is None:
|
||||
raise RuntimeError(
|
||||
f"cron store at {self.store_path} could not be loaded and was preserved "
|
||||
"as a .corrupt-<ts> backup; refusing to operate to avoid overwriting "
|
||||
"scheduled jobs. Inspect the corrupt backup and restore jobs.json manually."
|
||||
)
|
||||
return store
|
||||
|
||||
def _save_store(self) -> None:
|
||||
"""Save jobs to disk."""
|
||||
if not self._store:
|
||||
@@ -437,11 +457,15 @@ class CronService:
|
||||
os.replace(tmp_path, path)
|
||||
# fsync the parent directory so the rename itself is durable.
|
||||
# Skip on Windows where opening a directory raises PermissionError;
|
||||
# NTFS journals metadata synchronously so this is a no-op there.
|
||||
# some shared filesystems reject directory fsync with EINVAL.
|
||||
with suppress(PermissionError):
|
||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EINVAL:
|
||||
raise
|
||||
finally:
|
||||
os.close(fd)
|
||||
except BaseException:
|
||||
@@ -622,7 +646,7 @@ class CronService:
|
||||
|
||||
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
|
||||
"""List all jobs."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
|
||||
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
|
||||
|
||||
@@ -684,7 +708,7 @@ class CronService:
|
||||
_normalize_agent_turn_job(job)
|
||||
self._enforce_agent_binding(job)
|
||||
if self._running:
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
store.jobs.append(job)
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
@@ -696,7 +720,7 @@ class CronService:
|
||||
|
||||
def register_system_job(self, job: CronJob) -> CronJob:
|
||||
"""Register an internal system job (idempotent on restart)."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
now = _now_ms()
|
||||
job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now))
|
||||
job.created_at_ms = now
|
||||
@@ -710,7 +734,7 @@ class CronService:
|
||||
|
||||
def remove_job(self, job_id: str) -> Literal["removed", "protected", "not_found"]:
|
||||
"""Remove a job by ID, unless it is a protected system job."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
job = next((j for j in store.jobs if j.id == job_id), None)
|
||||
if job is None:
|
||||
return "not_found"
|
||||
@@ -735,7 +759,7 @@ class CronService:
|
||||
|
||||
def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
|
||||
"""Enable or disable a job."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
for job in store.jobs:
|
||||
if job.id == job_id:
|
||||
job.enabled = enabled
|
||||
@@ -770,7 +794,7 @@ class CronService:
|
||||
For ``channel`` and ``to``, pass an explicit value (including ``None``)
|
||||
to update; omit (sentinel ``...``) to leave unchanged.
|
||||
"""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
job = next((j for j in store.jobs if j.id == job_id), None)
|
||||
if job is None:
|
||||
return "not_found"
|
||||
@@ -815,7 +839,7 @@ class CronService:
|
||||
was_running = self._running
|
||||
self._running = True
|
||||
try:
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
for job in store.jobs:
|
||||
if job.id == job_id:
|
||||
if self._is_unbound_agent_job(job):
|
||||
@@ -835,12 +859,12 @@ class CronService:
|
||||
|
||||
def get_job(self, job_id: str) -> CronJob | None:
|
||||
"""Get a job by ID."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
return next((j for j in store.jobs if j.id == job_id), None)
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Get service status."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
return {
|
||||
"enabled": self._running,
|
||||
"jobs": len(store.jobs),
|
||||
|
||||
+13
-13
@@ -178,7 +178,8 @@ class GatewayRuntime:
|
||||
self._clear_state()
|
||||
return RuntimeResult(False, "gateway_state_stale", self.status(reason="stale_state"))
|
||||
|
||||
self._terminate(status.pid, timeout_s=timeout_s)
|
||||
if not self._terminate(status.pid, timeout_s=timeout_s):
|
||||
return RuntimeResult(False, "gateway_stop_timeout", self.status(reason="stop_timeout"))
|
||||
self._clear_state()
|
||||
return RuntimeResult(True, "gateway_stopped", self.status(reason="stopped"))
|
||||
|
||||
@@ -263,13 +264,12 @@ class GatewayRuntime:
|
||||
return {"creationflags": flags}
|
||||
return {"start_new_session": True}
|
||||
|
||||
def _terminate(self, pid: int, *, timeout_s: int) -> None:
|
||||
def _terminate(self, pid: int, *, timeout_s: int) -> bool:
|
||||
if self.platform_name == "Windows":
|
||||
self._terminate_windows(pid, timeout_s=timeout_s)
|
||||
else:
|
||||
self._terminate_posix(pid, timeout_s=timeout_s)
|
||||
return self._terminate_windows(pid, timeout_s=timeout_s)
|
||||
return self._terminate_posix(pid, timeout_s=timeout_s)
|
||||
|
||||
def _terminate_posix(self, pid: int, *, timeout_s: int) -> None:
|
||||
def _terminate_posix(self, pid: int, *, timeout_s: int) -> bool:
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
except OSError:
|
||||
@@ -280,28 +280,28 @@ class GatewayRuntime:
|
||||
else:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
return True
|
||||
if self._wait_for_exit(pid, timeout_s):
|
||||
return
|
||||
return True
|
||||
with suppress(ProcessLookupError):
|
||||
if pgid is not None:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
else:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
self._wait_for_exit(pid, 2)
|
||||
return self._wait_for_exit(pid, 2)
|
||||
|
||||
def _terminate_windows(self, pid: int, *, timeout_s: int) -> None:
|
||||
def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool:
|
||||
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
|
||||
if ctrl_break is not None:
|
||||
with suppress(ProcessLookupError):
|
||||
os.kill(pid, ctrl_break)
|
||||
if self._wait_for_exit(pid, timeout_s):
|
||||
return
|
||||
return True
|
||||
self._subprocess_run(["taskkill", "/PID", str(pid), "/T"], check=False)
|
||||
if self._wait_for_exit(pid, 2):
|
||||
return
|
||||
return True
|
||||
self._subprocess_run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False)
|
||||
self._wait_for_exit(pid, 2)
|
||||
return self._wait_for_exit(pid, 2)
|
||||
|
||||
def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool:
|
||||
deadline = time.monotonic() + max(float(timeout_s), 0.0)
|
||||
|
||||
@@ -141,7 +141,7 @@ class GatewayServiceInstaller:
|
||||
"Label": label,
|
||||
"ProgramArguments": build_gateway_command(options.python_executable, options.start),
|
||||
"WorkingDirectory": _working_directory_text(options.start),
|
||||
"RunAtLoad": bool(options.start_now),
|
||||
"RunAtLoad": bool(options.enable),
|
||||
"KeepAlive": {"SuccessfulExit": False},
|
||||
"StandardOutPath": str(stdout_path),
|
||||
"StandardErrorPath": str(stderr_path),
|
||||
@@ -149,7 +149,7 @@ class GatewayServiceInstaller:
|
||||
content = plistlib.dumps(payload, sort_keys=False).decode("utf-8")
|
||||
domain = _launchd_domain()
|
||||
commands: list[tuple[str, ...]] = []
|
||||
if options.enable or options.start_now:
|
||||
if options.start_now:
|
||||
commands.append(("launchctl", "bootstrap", domain, str(path)))
|
||||
if options.enable:
|
||||
commands.append(("launchctl", "enable", f"{domain}/{label}"))
|
||||
@@ -162,7 +162,7 @@ class GatewayServiceInstaller:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stdout_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
if options.enable or options.start_now:
|
||||
if options.start_now:
|
||||
self._run_best_effort(("launchctl", "bootout", domain, str(path)))
|
||||
for command_args in commands:
|
||||
self._subprocess_run(list(command_args), check=True)
|
||||
|
||||
@@ -44,9 +44,9 @@ def _load() -> dict[str, Any]:
|
||||
logger.warning("Corrupted pairing store, resetting")
|
||||
return {"approved": {}, "pending": {}}
|
||||
|
||||
# Convert approved lists to sets for O(1) lookup
|
||||
# Convert approved lists to str sets for O(1) lookup.
|
||||
for channel, users in data.get("approved", {}).items():
|
||||
data["approved"][channel] = set(users)
|
||||
data["approved"][channel] = {str(u) for u in users}
|
||||
return data
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def generate_code(
|
||||
|
||||
data.setdefault("pending", {})[code] = {
|
||||
"channel": channel,
|
||||
"sender_id": sender_id,
|
||||
"sender_id": str(sender_id),
|
||||
"created_at": time.time(),
|
||||
"expires_at": time.time() + ttl,
|
||||
}
|
||||
@@ -110,7 +110,7 @@ def approve_code(code: str) -> tuple[str, str] | None:
|
||||
if info is None:
|
||||
return None
|
||||
channel = info["channel"]
|
||||
sender_id = info["sender_id"]
|
||||
sender_id = str(info["sender_id"])
|
||||
data.setdefault("approved", {}).setdefault(channel, set()).add(sender_id)
|
||||
_save(data)
|
||||
logger.info("Approved pairing code {} for {}@{}", code, sender_id, channel)
|
||||
@@ -162,12 +162,13 @@ def revoke(channel: str, sender_id: str) -> bool:
|
||||
data = _load()
|
||||
approved: dict[str, set[str]] = data.get("approved", {})
|
||||
users = approved.get(channel, set())
|
||||
if sender_id in users:
|
||||
users.discard(sender_id)
|
||||
sid = str(sender_id)
|
||||
if sid in users:
|
||||
users.discard(sid)
|
||||
if not users:
|
||||
del approved[channel]
|
||||
_save(data)
|
||||
logger.info("Revoked {} from {}", sender_id, channel)
|
||||
logger.info("Revoked {} from {}", sid, channel)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
@@ -154,6 +158,40 @@ class AnthropicProvider(LLMProvider):
|
||||
"""Return ``(system, anthropic_messages)``."""
|
||||
system: str | list[dict[str, Any]] = ""
|
||||
raw: list[dict[str, Any]] = []
|
||||
seen_tool_ids: set[str] = set()
|
||||
pending_tool_ids: dict[str, deque[str]] = {}
|
||||
|
||||
def unique_tool_id(value: Any) -> str:
|
||||
raw_key = str(value) if value else ""
|
||||
mapped_id = _sanitize_tool_id(raw_key) if raw_key else _gen_tool_id()
|
||||
if mapped_id and mapped_id not in seen_tool_ids:
|
||||
seen_tool_ids.add(mapped_id)
|
||||
if raw_key:
|
||||
pending_tool_ids.setdefault(raw_key, deque()).append(mapped_id)
|
||||
return mapped_id
|
||||
|
||||
seed = mapped_id or _gen_tool_id()
|
||||
suffix = 2
|
||||
while True:
|
||||
candidate = f"{seed}__dedupe_{suffix}"
|
||||
if candidate not in seen_tool_ids:
|
||||
seen_tool_ids.add(candidate)
|
||||
if raw_key:
|
||||
pending_tool_ids.setdefault(raw_key, deque()).append(candidate)
|
||||
return candidate
|
||||
suffix += 1
|
||||
|
||||
def map_tool_result_id(value: Any) -> str:
|
||||
if not value:
|
||||
return _sanitize_tool_id(value or "")
|
||||
raw_id = str(value)
|
||||
queue = pending_tool_ids.get(raw_id)
|
||||
if queue:
|
||||
mapped_id = queue.popleft()
|
||||
if not queue:
|
||||
pending_tool_ids.pop(raw_id, None)
|
||||
return mapped_id
|
||||
return _sanitize_tool_id(raw_id)
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
@@ -164,7 +202,7 @@ class AnthropicProvider(LLMProvider):
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
block = self._tool_result_block(msg)
|
||||
block = self._tool_result_block(msg, map_tool_result_id=map_tool_result_id)
|
||||
if raw and raw[-1]["role"] == "user":
|
||||
prev_c = raw[-1]["content"]
|
||||
if isinstance(prev_c, list):
|
||||
@@ -178,7 +216,10 @@ class AnthropicProvider(LLMProvider):
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
raw.append({"role": "assistant", "content": self._assistant_blocks(msg)})
|
||||
raw.append({
|
||||
"role": "assistant",
|
||||
"content": self._assistant_blocks(msg, map_tool_id=unique_tool_id),
|
||||
})
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
@@ -191,11 +232,20 @@ class AnthropicProvider(LLMProvider):
|
||||
return system, self._merge_consecutive(raw)
|
||||
|
||||
@staticmethod
|
||||
def _tool_result_block(msg: dict[str, Any]) -> dict[str, Any]:
|
||||
def _tool_result_block(
|
||||
msg: dict[str, Any],
|
||||
*,
|
||||
map_tool_result_id: Callable[[Any], str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
content = msg.get("content")
|
||||
tool_call_id = msg.get("tool_call_id", "")
|
||||
block: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": _sanitize_tool_id(msg.get("tool_call_id", "")),
|
||||
"tool_use_id": (
|
||||
map_tool_result_id(tool_call_id)
|
||||
if map_tool_result_id is not None
|
||||
else _sanitize_tool_id(tool_call_id)
|
||||
),
|
||||
}
|
||||
if isinstance(content, list):
|
||||
block["content"] = AnthropicProvider._convert_user_content(content)
|
||||
@@ -206,7 +256,11 @@ class AnthropicProvider(LLMProvider):
|
||||
return block
|
||||
|
||||
@staticmethod
|
||||
def _assistant_blocks(msg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def _assistant_blocks(
|
||||
msg: dict[str, Any],
|
||||
*,
|
||||
map_tool_id: Callable[[Any], str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
blocks: list[dict[str, Any]] = []
|
||||
content = msg.get("content")
|
||||
|
||||
@@ -222,16 +276,29 @@ class AnthropicProvider(LLMProvider):
|
||||
blocks.append({"type": "text", "text": content})
|
||||
elif isinstance(content, list):
|
||||
for item in content:
|
||||
blocks.append(item if isinstance(item, dict) else {"type": "text", "text": str(item)})
|
||||
if isinstance(item, dict):
|
||||
if not item.get("type"):
|
||||
# Anthropic requires every content block to declare a "type".
|
||||
# A tool that returned a bare dict lands here; coerce it to
|
||||
# a text block instead of emitting one that the API rejects.
|
||||
blocks.append({
|
||||
"type": "text",
|
||||
"text": AnthropicProvider._stringify_typeless_block(item),
|
||||
})
|
||||
else:
|
||||
blocks.append(item)
|
||||
else:
|
||||
blocks.append({"type": "text", "text": str(item)})
|
||||
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
func = tc.get("function", {})
|
||||
args = func.get("arguments", "{}")
|
||||
raw_id = tc.get("id") or _gen_tool_id()
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": _sanitize_tool_id(tc.get("id") or _gen_tool_id()),
|
||||
"id": map_tool_id(raw_id) if map_tool_id is not None else _sanitize_tool_id(raw_id),
|
||||
"name": func.get("name", ""),
|
||||
"input": tool_arguments_object_for_replay(args),
|
||||
})
|
||||
@@ -261,11 +328,18 @@ class AnthropicProvider(LLMProvider):
|
||||
# A tool that returned a bare dict (or a list of dicts) lands
|
||||
# here; coerce it to a text block instead of emitting a block
|
||||
# the API rejects with "content.0.type: Field required".
|
||||
result.append({"type": "text", "text": str(item)})
|
||||
result.append({
|
||||
"type": "text",
|
||||
"text": AnthropicProvider._stringify_typeless_block(item),
|
||||
})
|
||||
continue
|
||||
result.append(item)
|
||||
return result or "(empty)"
|
||||
|
||||
@staticmethod
|
||||
def _stringify_typeless_block(block: dict[str, Any]) -> str:
|
||||
return json.dumps(block, ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
@staticmethod
|
||||
def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Convert OpenAI image_url block to Anthropic image block."""
|
||||
@@ -522,13 +596,25 @@ class AnthropicProvider(LLMProvider):
|
||||
content_parts: list[str] = []
|
||||
tool_calls: list[ToolCallRequest] = []
|
||||
thinking_blocks: list[dict[str, Any]] = []
|
||||
seen_tool_ids: set[str] = set()
|
||||
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
content_parts.append(block.text)
|
||||
elif block.type == "tool_use":
|
||||
tool_id = str(block.id or _gen_tool_id())
|
||||
if tool_id in seen_tool_ids:
|
||||
original_id = tool_id
|
||||
while tool_id in seen_tool_ids:
|
||||
tool_id = _gen_tool_id()
|
||||
logger.warning(
|
||||
"remapping duplicate tool_use id from response: {} -> {}",
|
||||
original_id,
|
||||
tool_id,
|
||||
)
|
||||
seen_tool_ids.add(tool_id)
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=block.id,
|
||||
id=tool_id,
|
||||
name=block.name,
|
||||
arguments=block.input,
|
||||
))
|
||||
|
||||
@@ -54,6 +54,18 @@ class ToolCallRequest:
|
||||
provider_specific_fields: dict[str, Any] | None = None
|
||||
function_provider_specific_fields: dict[str, Any] | None = None
|
||||
|
||||
def has_valid_name(self) -> bool:
|
||||
"""Whether this call carries a usable (non-empty string) tool name.
|
||||
|
||||
ToolCallRequest.name is typed ``str`` but not enforced at runtime: a
|
||||
model/gateway can emit a degenerate call with ``name=None`` or ``""``.
|
||||
Such a call cannot be executed and, if persisted and replayed, makes
|
||||
upstream APIs reject the whole request (e.g. Anthropic-style
|
||||
``messages.content.N.tool_use.name: Input should be a valid string``),
|
||||
which permanently wedges the session.
|
||||
"""
|
||||
return isinstance(self.name, str) and bool(self.name)
|
||||
|
||||
def to_openai_tool_call(self) -> dict[str, Any]:
|
||||
"""Serialize to an OpenAI-style tool_call payload."""
|
||||
arguments = (
|
||||
|
||||
@@ -5,10 +5,10 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig
|
||||
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig, ProviderConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.fallback_provider import FallbackProvider
|
||||
from nanobot.providers.registry import create_dynamic_spec, find_by_name
|
||||
from nanobot.providers.registry import ProviderSpec, create_dynamic_spec, find_by_name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -28,6 +28,16 @@ def _resolve_model_preset(
|
||||
return preset if preset is not None else config.resolve_preset(preset_name)
|
||||
|
||||
|
||||
def _provider_extra_headers(
|
||||
spec: ProviderSpec | None,
|
||||
provider_config: ProviderConfig | None,
|
||||
) -> dict[str, str] | None:
|
||||
headers = dict(spec.default_extra_headers) if spec else {}
|
||||
if provider_config and provider_config.extra_headers:
|
||||
headers.update(provider_config.extra_headers)
|
||||
return headers or None
|
||||
|
||||
|
||||
def _make_provider_core(
|
||||
config: Config,
|
||||
*,
|
||||
@@ -44,10 +54,15 @@ def _make_provider_core(
|
||||
if provider_name and not spec and p:
|
||||
if not p.api_base:
|
||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||
spec = create_dynamic_spec(provider_name)
|
||||
spec = create_dynamic_spec(provider_name, thinking_style=(p.thinking_style or "") if p else "")
|
||||
if spec and spec.is_transcription_only:
|
||||
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
if p and p.proxy and backend not in {"openai_compat", "openai_codex"}:
|
||||
raise ValueError(
|
||||
f"providers.{provider_name}.proxy is only supported for "
|
||||
"OpenAI-compatible providers and OpenAI Codex."
|
||||
)
|
||||
|
||||
if backend == "azure_openai":
|
||||
if not p or not p.api_base:
|
||||
@@ -69,7 +84,10 @@ def _make_provider_core(
|
||||
if backend == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
provider = OpenAICodexProvider(default_model=model)
|
||||
provider = OpenAICodexProvider(
|
||||
default_model=model,
|
||||
proxy=getattr(p, "proxy", None) if p else None,
|
||||
)
|
||||
elif backend == "azure_openai":
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
|
||||
@@ -89,7 +107,7 @@ def _make_provider_core(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model, preset=resolved),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
extra_headers=_provider_extra_headers(spec, p),
|
||||
)
|
||||
elif backend == "bedrock":
|
||||
from nanobot.providers.bedrock_provider import BedrockProvider
|
||||
@@ -109,11 +127,12 @@ def _make_provider_core(
|
||||
api_key=p.api_key if p else None,
|
||||
api_base=config.get_api_base(model, preset=resolved),
|
||||
default_model=model,
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
extra_headers=_provider_extra_headers(spec, p),
|
||||
spec=spec,
|
||||
extra_body=p.extra_body if p else None,
|
||||
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
||||
extra_query=p.extra_query if p else None,
|
||||
proxy=p.proxy if p else None,
|
||||
)
|
||||
|
||||
provider.generation = resolved.to_generation_settings()
|
||||
@@ -191,13 +210,14 @@ def provider_signature(
|
||||
|
||||
def _fallback_signature(fallback: ModelPresetConfig) -> tuple[object, ...]:
|
||||
fp = config.get_provider(fallback.model, preset=fallback)
|
||||
provider_name = config.get_provider_name(fallback.model, preset=fallback)
|
||||
return (
|
||||
fallback.model,
|
||||
fallback.provider,
|
||||
config.get_provider_name(fallback.model, preset=fallback),
|
||||
provider_name,
|
||||
config.get_api_key(fallback.model, preset=fallback),
|
||||
config.get_api_base(fallback.model, preset=fallback),
|
||||
fp.extra_headers if fp else None,
|
||||
_provider_extra_headers(find_by_name(provider_name) if provider_name else None, fp),
|
||||
fp.extra_body if fp else None,
|
||||
fp.api_type if fp else "auto",
|
||||
fp.extra_query if fp else None,
|
||||
@@ -207,15 +227,17 @@ def provider_signature(
|
||||
fallback.temperature,
|
||||
fallback.reasoning_effort,
|
||||
fallback.context_window_tokens,
|
||||
getattr(fp, "proxy", None) if fp else None,
|
||||
)
|
||||
|
||||
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
||||
return (
|
||||
resolved.model,
|
||||
resolved.provider,
|
||||
config.get_provider_name(resolved.model, preset=resolved),
|
||||
provider_name,
|
||||
config.get_api_key(resolved.model, preset=resolved),
|
||||
config.get_api_base(resolved.model, preset=resolved),
|
||||
p.extra_headers if p else None,
|
||||
_provider_extra_headers(find_by_name(provider_name) if provider_name else None, p),
|
||||
p.extra_body if p else None,
|
||||
p.api_type if p else "auto",
|
||||
p.extra_query if p else None,
|
||||
@@ -225,6 +247,7 @@ def provider_signature(
|
||||
resolved.temperature,
|
||||
resolved.reasoning_effort,
|
||||
resolved.context_window_tokens,
|
||||
getattr(p, "proxy", None) if p else None,
|
||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -29,6 +30,12 @@ _EXPIRY_SKEW_SECONDS = 60
|
||||
_LONG_LIVED_TOKEN_SECONDS = 315360000
|
||||
|
||||
|
||||
def _resolve(env_var: str, default: str) -> str:
|
||||
"""Allow GitHub Enterprise / Copilot for Business deployments to override defaults via env."""
|
||||
value = os.environ.get(env_var)
|
||||
return value.strip() if value and value.strip() else default
|
||||
|
||||
|
||||
def get_storage() -> FileTokenStorage:
|
||||
return FileTokenStorage(
|
||||
token_filename=TOKEN_FILENAME,
|
||||
@@ -68,11 +75,16 @@ def login_github_copilot(
|
||||
printer = print_fn or print
|
||||
timeout = httpx.Timeout(20.0, connect=20.0)
|
||||
|
||||
client_id = _resolve("NANOBOT_GITHUB_COPILOT_CLIENT_ID", GITHUB_COPILOT_CLIENT_ID)
|
||||
device_code_url = _resolve("NANOBOT_GITHUB_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL)
|
||||
access_token_url = _resolve("NANOBOT_GITHUB_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL)
|
||||
user_url = _resolve("NANOBOT_GITHUB_USER_URL", DEFAULT_GITHUB_USER_URL)
|
||||
|
||||
with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
||||
response = client.post(
|
||||
DEFAULT_GITHUB_DEVICE_CODE_URL,
|
||||
device_code_url,
|
||||
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
||||
data={"client_id": GITHUB_COPILOT_CLIENT_ID, "scope": GITHUB_COPILOT_SCOPE},
|
||||
data={"client_id": client_id, "scope": GITHUB_COPILOT_SCOPE},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
@@ -96,10 +108,10 @@ def login_github_copilot(
|
||||
token_expires_in = _LONG_LIVED_TOKEN_SECONDS
|
||||
while time.time() < deadline:
|
||||
poll = client.post(
|
||||
DEFAULT_GITHUB_ACCESS_TOKEN_URL,
|
||||
access_token_url,
|
||||
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
||||
data={
|
||||
"client_id": GITHUB_COPILOT_CLIENT_ID,
|
||||
"client_id": client_id,
|
||||
"device_code": device_code,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
@@ -132,7 +144,7 @@ def login_github_copilot(
|
||||
raise RuntimeError("GitHub device flow timed out.")
|
||||
|
||||
user = client.get(
|
||||
DEFAULT_GITHUB_USER_URL,
|
||||
user_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
@@ -164,7 +176,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
self._copilot_expires_at: float = 0.0
|
||||
super().__init__(
|
||||
api_key="no-key",
|
||||
api_base=DEFAULT_COPILOT_BASE_URL,
|
||||
api_base=_resolve("NANOBOT_COPILOT_BASE_URL", DEFAULT_COPILOT_BASE_URL),
|
||||
default_model=default_model,
|
||||
extra_headers={
|
||||
"Editor-Version": EDITOR_VERSION,
|
||||
@@ -186,7 +198,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
timeout = httpx.Timeout(20.0, connect=20.0)
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
||||
response = await client.get(
|
||||
DEFAULT_COPILOT_TOKEN_URL,
|
||||
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
|
||||
headers=_copilot_headers(github_token.access),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -33,9 +33,14 @@ class OpenAICodexProvider(LLMProvider):
|
||||
|
||||
supports_progress_deltas = True
|
||||
|
||||
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||
def __init__(
|
||||
self,
|
||||
default_model: str = "openai-codex/gpt-5.1-codex",
|
||||
proxy: str | None = None,
|
||||
):
|
||||
super().__init__(api_key=None, api_base=None)
|
||||
self.default_model = default_model
|
||||
self.proxy = proxy or None
|
||||
|
||||
async def _call_codex(
|
||||
self,
|
||||
@@ -52,9 +57,6 @@ class OpenAICodexProvider(LLMProvider):
|
||||
model = model or self.default_model
|
||||
system_prompt, input_items = convert_messages(messages)
|
||||
|
||||
token = await asyncio.to_thread(get_codex_token)
|
||||
headers = _build_headers(token.account_id, token.access)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": _strip_model_prefix(model),
|
||||
"store": False,
|
||||
@@ -74,9 +76,13 @@ class OpenAICodexProvider(LLMProvider):
|
||||
body["tools"] = convert_tools(tools)
|
||||
|
||||
try:
|
||||
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
||||
headers = _build_headers(token.account_id, token.access)
|
||||
|
||||
try:
|
||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||
DEFAULT_CODEX_URL, headers, body, verify=True,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
@@ -87,6 +93,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||
DEFAULT_CODEX_URL, headers, body, verify=False,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
@@ -199,12 +206,17 @@ async def _request_codex(
|
||||
headers: dict[str, str],
|
||||
body: dict[str, Any],
|
||||
verify: bool,
|
||||
proxy: str | None = None,
|
||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
|
||||
client_kwargs: dict[str, Any] = {"timeout": idle_timeout_s, "verify": verify}
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
client_kwargs["trust_env"] = False
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||
if response.status_code != 200:
|
||||
text = await response.aread()
|
||||
|
||||
@@ -358,6 +358,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
api_type: str = "auto",
|
||||
extra_query: dict[str, str] | None = None,
|
||||
proxy: str | None = None,
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
@@ -366,6 +367,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
self._extra_body = extra_body or {}
|
||||
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
||||
self._extra_query = extra_query or {}
|
||||
self._proxy = proxy or None
|
||||
|
||||
if api_key and spec and spec.env_key:
|
||||
self._setup_env(api_key, api_base)
|
||||
@@ -396,7 +398,14 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
timeout_s = _openai_compat_timeout_s()
|
||||
http_client: httpx.AsyncClient | None = None
|
||||
if self._is_local:
|
||||
if self._proxy:
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=timeout_s,
|
||||
proxy=self._proxy,
|
||||
trust_env=False,
|
||||
follow_redirects=True,
|
||||
)
|
||||
elif self._is_local:
|
||||
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
||||
# HTTP connections before the client-side keepalive expires. When
|
||||
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
||||
@@ -1131,14 +1140,21 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if reasoning_content is None:
|
||||
reasoning_content = m.get("reasoning_content")
|
||||
|
||||
# Deduplicate tool call IDs (same pattern as streaming path)
|
||||
# Some providers reuse the same ID for parallel tool calls.
|
||||
_seen_tc_ids: set[str] = set()
|
||||
parsed_tool_calls = []
|
||||
for tc in raw_tool_calls:
|
||||
tc_map = self._maybe_mapping(tc) or {}
|
||||
fn = self._maybe_mapping(tc_map.get("function")) or {}
|
||||
args = parse_tool_arguments(fn.get("arguments", {}))
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
raw_id = str(tc_map.get("id") or _short_tool_id())
|
||||
if not raw_id or raw_id in _seen_tc_ids:
|
||||
raw_id = _short_tool_id()
|
||||
_seen_tc_ids.add(raw_id)
|
||||
parsed_tool_calls.append(ToolCallRequest(
|
||||
id=str(tc_map.get("id") or _short_tool_id()),
|
||||
id=raw_id,
|
||||
name=str(fn.get("name") or ""),
|
||||
arguments=args,
|
||||
extra_content=ec,
|
||||
|
||||
@@ -32,13 +32,15 @@ class ProviderSpec:
|
||||
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
|
||||
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
|
||||
display_name: str = "" # shown in `nanobot status`
|
||||
model_catalog: str = "auto" # WebUI model-list source
|
||||
|
||||
# which provider implementation to use
|
||||
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
|
||||
backend: str = "openai_compat"
|
||||
|
||||
# extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),)
|
||||
# extra env vars / request headers supplied by the provider integration.
|
||||
env_extras: tuple[tuple[str, str], ...] = ()
|
||||
default_extra_headers: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
# gateway / local detection
|
||||
is_gateway: bool = False # routes any model (OpenRouter, AiHubMix)
|
||||
@@ -176,6 +178,32 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
supports_prompt_caching=True,
|
||||
gateway_reasoning_style="reasoning_effort",
|
||||
),
|
||||
# OpenCode Zen: OpenAI-compatible chat-completions gateway for coding models.
|
||||
# OpenCode's own config uses "opencode/<model>"; send the bare model upstream.
|
||||
ProviderSpec(
|
||||
name="opencode_zen",
|
||||
keywords=("opencode/", "opencode_zen", "opencode-zen"),
|
||||
env_key="OPENCODE_API_KEY",
|
||||
display_name="OpenCode Zen",
|
||||
backend="openai_compat",
|
||||
is_gateway=True,
|
||||
detect_by_base_keyword="opencode.ai/zen",
|
||||
default_api_base="https://opencode.ai/zen/v1",
|
||||
strip_model_prefixes=("opencode", "opencode_zen", "opencode-zen"),
|
||||
),
|
||||
# OpenCode Go: OpenAI-compatible chat-completions gateway for low-cost models.
|
||||
# OpenCode's own config uses "opencode-go/<model>"; send the bare model upstream.
|
||||
ProviderSpec(
|
||||
name="opencode_go",
|
||||
keywords=("opencode-go", "opencode_go"),
|
||||
env_key="OPENCODE_API_KEY",
|
||||
display_name="OpenCode Go",
|
||||
backend="openai_compat",
|
||||
is_gateway=True,
|
||||
detect_by_base_keyword="opencode.ai/zen/go",
|
||||
default_api_base="https://opencode.ai/zen/go/v1",
|
||||
strip_model_prefixes=("opencode-go", "opencode_go"),
|
||||
),
|
||||
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
|
||||
ProviderSpec(
|
||||
name="huggingface",
|
||||
@@ -194,6 +222,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
keywords=("skywork", "skyclaw", "apifree"),
|
||||
env_key="SKYWORK_API_KEY",
|
||||
display_name="Skywork",
|
||||
model_catalog="official",
|
||||
backend="openai_compat",
|
||||
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
|
||||
is_gateway=True,
|
||||
@@ -391,6 +420,17 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
("kimi-k2.7-code-highspeed", {"temperature": 1.0}),
|
||||
),
|
||||
),
|
||||
# Kimi Coding Plan — Anthropic Messages API at api.kimi.com/coding
|
||||
# sk-kimi-* keys; requires User-Agent: claude-code/0.1.0 header.
|
||||
ProviderSpec(
|
||||
name="kimi_coding",
|
||||
keywords=("kimi-coding", "kimi_coding", "kimi-for-coding"),
|
||||
env_key="KIMI_CODING_API_KEY",
|
||||
display_name="Kimi Coding",
|
||||
backend="anthropic",
|
||||
default_api_base="https://api.kimi.com/coding/v1",
|
||||
default_extra_headers=(("User-Agent", "claude-code/0.1.0"),),
|
||||
),
|
||||
# MiniMax: OpenAI-compatible API
|
||||
ProviderSpec(
|
||||
name="minimax",
|
||||
@@ -590,7 +630,7 @@ def find_by_name(name: str) -> ProviderSpec | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_dynamic_spec(name: str) -> ProviderSpec:
|
||||
def create_dynamic_spec(name: str, *, thinking_style: str = "") -> ProviderSpec:
|
||||
"""Create a dynamic ProviderSpec for custom user-defined providers."""
|
||||
normalized = to_snake(name.replace("-", "_"))
|
||||
strip_prefixes = tuple(dict.fromkeys((name, normalized)))
|
||||
@@ -602,4 +642,5 @@ def create_dynamic_spec(name: str) -> ProviderSpec:
|
||||
backend="openai_compat",
|
||||
is_direct=True,
|
||||
strip_model_prefixes=strip_prefixes,
|
||||
thinking_style=thinking_style,
|
||||
)
|
||||
|
||||
+115
-51
@@ -1,5 +1,6 @@
|
||||
"""Session management for conversation history."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -26,6 +27,8 @@ from nanobot.utils.helpers import (
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||
|
||||
FILE_MAX_MESSAGES = 2000
|
||||
MIN_REPLAY_MAX_MESSAGES = 120
|
||||
REPLAY_TOKENS_PER_MESSAGE = 100
|
||||
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
||||
@@ -42,6 +45,15 @@ _FORK_VOLATILE_METADATA_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def replay_max_messages_for_context(context_window_tokens: int | None) -> int:
|
||||
if not context_window_tokens or context_window_tokens <= 0:
|
||||
return FILE_MAX_MESSAGES
|
||||
return min(
|
||||
FILE_MAX_MESSAGES,
|
||||
max(MIN_REPLAY_MAX_MESSAGES, context_window_tokens // REPLAY_TOKENS_PER_MESSAGE),
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_assistant_replay_text(content: str) -> str:
|
||||
"""Remove internal replay artifacts that the model may have copied before.
|
||||
|
||||
@@ -98,6 +110,12 @@ def _metadata_title(metadata: Any) -> str:
|
||||
return strip_think(title)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetentionResult:
|
||||
dropped: list[dict]
|
||||
already_consolidated_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""A conversation session."""
|
||||
@@ -118,25 +136,6 @@ class Session:
|
||||
):
|
||||
self.last_consolidated = 0
|
||||
|
||||
@staticmethod
|
||||
def _annotate_message_time(message: dict[str, Any], content: Any) -> Any:
|
||||
"""Expose persisted turn timestamps to the model for relative-date reasoning.
|
||||
|
||||
Annotating *every* assistant turn trains the model (via in-context
|
||||
demonstrations) to start its own replies with the same
|
||||
``[Message Time: ...]`` prefix, which leaks metadata back to the user.
|
||||
We therefore only annotate user turns. User-side stamps are enough to
|
||||
pin adjacent assistant replies for relative-time reasoning, including
|
||||
proactive messages the user replies to later.
|
||||
"""
|
||||
timestamp = message.get("timestamp")
|
||||
if not timestamp or not isinstance(content, str):
|
||||
return content
|
||||
role = message.get("role")
|
||||
if role != "user":
|
||||
return content
|
||||
return f"[Message Time: {timestamp}]\n{content}"
|
||||
|
||||
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
|
||||
"""Add a message to the session."""
|
||||
msg = {
|
||||
@@ -150,10 +149,9 @@ class Session:
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
max_messages: int = 120,
|
||||
max_messages: int = FILE_MAX_MESSAGES,
|
||||
*,
|
||||
max_tokens: int = 0,
|
||||
include_timestamps: bool = False,
|
||||
extend_to_user: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return unconsolidated messages for LLM input.
|
||||
@@ -162,7 +160,7 @@ class Session:
|
||||
token budget from the tail (``max_tokens``) when provided.
|
||||
"""
|
||||
unconsolidated = self.messages[self.last_consolidated:]
|
||||
max_messages = max_messages if max_messages > 0 else 120
|
||||
max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
|
||||
start_idx = recent_message_start_index(
|
||||
unconsolidated,
|
||||
max_messages,
|
||||
@@ -243,8 +241,6 @@ class Session:
|
||||
if mcp_lines:
|
||||
breadcrumbs = "\n".join(mcp_lines)
|
||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||
if include_timestamps:
|
||||
content = self._annotate_message_time(message, content)
|
||||
if role == "assistant" and isinstance(content, str) and not content.strip():
|
||||
if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")):
|
||||
continue
|
||||
@@ -299,22 +295,26 @@ class Session:
|
||||
max_messages: int,
|
||||
*,
|
||||
extend_to_user: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
) -> RetentionResult:
|
||||
"""Keep a legal recent suffix, optionally extending it back to a user turn.
|
||||
|
||||
Returns ``(dropped, already_consolidated_count)`` where *dropped* is
|
||||
the list of removed messages (in original order) and
|
||||
*already_consolidated_count* is how many of those were inside the
|
||||
pre-existing ``last_consolidated`` prefix and therefore do not need
|
||||
raw archiving.
|
||||
Returns a RetentionResult with dropped messages and how many of those
|
||||
were in the already-consolidated prefix. This method mutates
|
||||
self.messages and self.last_consolidated in place.
|
||||
"""
|
||||
if max_messages <= 0:
|
||||
dropped = list(self.messages)
|
||||
lc = self.last_consolidated
|
||||
self.clear()
|
||||
return dropped, min(lc, len(dropped))
|
||||
return RetentionResult(
|
||||
dropped=dropped,
|
||||
already_consolidated_count=min(lc, len(dropped)),
|
||||
)
|
||||
if len(self.messages) <= max_messages:
|
||||
return [], 0
|
||||
return RetentionResult(
|
||||
dropped=[],
|
||||
already_consolidated_count=0,
|
||||
)
|
||||
|
||||
original = list(self.messages)
|
||||
before_lc = self.last_consolidated
|
||||
@@ -380,7 +380,10 @@ class Session:
|
||||
self.messages = retained
|
||||
self.last_consolidated = new_lc
|
||||
self.updated_at = datetime.now()
|
||||
return dropped, already_consolidated
|
||||
return RetentionResult(
|
||||
dropped=dropped,
|
||||
already_consolidated_count=already_consolidated,
|
||||
)
|
||||
|
||||
def enforce_file_cap(
|
||||
self,
|
||||
@@ -391,17 +394,17 @@ class Session:
|
||||
if limit <= 0 or len(self.messages) <= limit:
|
||||
return
|
||||
|
||||
dropped, already_consolidated = self.retain_recent_legal_suffix(limit)
|
||||
if not dropped:
|
||||
result = self.retain_recent_legal_suffix(limit)
|
||||
if not result.dropped:
|
||||
return
|
||||
|
||||
archive_chunk = dropped[already_consolidated:]
|
||||
archive_chunk = result.dropped[result.already_consolidated_count:]
|
||||
if archive_chunk and on_archive:
|
||||
on_archive(archive_chunk)
|
||||
logger.info(
|
||||
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
||||
self.key,
|
||||
len(dropped),
|
||||
len(result.dropped),
|
||||
len(archive_chunk),
|
||||
len(self.messages),
|
||||
)
|
||||
@@ -425,14 +428,53 @@ class SessionManager:
|
||||
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
|
||||
return safe_filename(key.replace(":", "_"))
|
||||
|
||||
@staticmethod
|
||||
def _storage_key(key: str) -> str:
|
||||
"""Collision-resistant encoding for internal session storage filenames."""
|
||||
return base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
||||
|
||||
@staticmethod
|
||||
def _decode_storage_key(stem: str) -> str | None:
|
||||
"""Reverse _storage_key(): decode a base64url (no-padding) stem back to the original key."""
|
||||
try:
|
||||
# Restore padding stripped by rstrip("=")
|
||||
padding = 4 - len(stem) % 4
|
||||
if padding != 4:
|
||||
stem += "=" * padding
|
||||
return base64.urlsafe_b64decode(stem).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_session_path(self, key: str) -> Path:
|
||||
"""Get the file path for a session."""
|
||||
return self.sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||
"""Get the collision-resistant workspace path for a session."""
|
||||
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
|
||||
|
||||
def _get_legacy_lossy_path(self, key: str) -> Path:
|
||||
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
||||
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
||||
|
||||
def _get_legacy_session_path(self, key: str) -> Path:
|
||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||
|
||||
@staticmethod
|
||||
def _stored_key_for_path(path: Path) -> str | None:
|
||||
"""Read the stored session key from a JSONL metadata row, if present."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
data = json.loads(line)
|
||||
if data.get("_type") == "metadata":
|
||||
stored_key = data.get("key")
|
||||
return stored_key if isinstance(stored_key, str) else None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_or_create(self, key: str) -> Session:
|
||||
"""
|
||||
Get an existing session or create a new one.
|
||||
@@ -457,13 +499,28 @@ class SessionManager:
|
||||
"""Load a session from disk."""
|
||||
path = self._get_session_path(key)
|
||||
if not path.exists():
|
||||
legacy_path = self._get_legacy_session_path(key)
|
||||
if legacy_path.exists():
|
||||
fallback_paths = [
|
||||
(self._get_legacy_lossy_path(key), "legacy lossy path"),
|
||||
(self._get_legacy_session_path(key), "legacy path"),
|
||||
]
|
||||
for fallback_path, description in fallback_paths:
|
||||
if not fallback_path.exists():
|
||||
continue
|
||||
stored_key = self._stored_key_for_path(fallback_path)
|
||||
if stored_key and stored_key != key:
|
||||
logger.info(
|
||||
"Skipping migration for {} from {} because it belongs to {}",
|
||||
key,
|
||||
description,
|
||||
stored_key,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
shutil.move(str(legacy_path), str(path))
|
||||
logger.info("Migrated session {} from legacy path", key)
|
||||
shutil.move(str(fallback_path), str(path))
|
||||
logger.info("Migrated session {} from {}", key, description)
|
||||
except Exception:
|
||||
logger.exception("Failed to migrate session {}", key)
|
||||
break
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -506,9 +563,10 @@ class SessionManager:
|
||||
logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages))
|
||||
return repaired
|
||||
|
||||
def _repair(self, key: str) -> Session | None:
|
||||
def _repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
||||
"""Attempt to recover a session from a corrupt JSONL file."""
|
||||
path = self._get_session_path(key)
|
||||
if path is None:
|
||||
path = self._get_session_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
@@ -645,7 +703,11 @@ class SessionManager:
|
||||
|
||||
Returns True if at least one JSONL file was found and unlinked.
|
||||
"""
|
||||
paths = [self._get_session_path(key), self._get_legacy_session_path(key)]
|
||||
paths = [
|
||||
self._get_session_path(key),
|
||||
self._get_legacy_lossy_path(key),
|
||||
self._get_legacy_session_path(key),
|
||||
]
|
||||
self.invalidate(key)
|
||||
deleted = False
|
||||
for path in paths:
|
||||
@@ -806,7 +868,8 @@ class SessionManager:
|
||||
sessions = []
|
||||
|
||||
for path in self.sessions_dir.glob("*.jsonl"):
|
||||
fallback_key = path.stem.replace("_", ":", 1)
|
||||
decoded = self._decode_storage_key(path.stem)
|
||||
fallback_key = decoded or path.stem.replace("_", ":", 1)
|
||||
try:
|
||||
# Read the metadata line and a small preview for session lists.
|
||||
with open(path, encoding="utf-8") as f:
|
||||
@@ -814,7 +877,7 @@ class SessionManager:
|
||||
if first_line:
|
||||
data = json.loads(first_line)
|
||||
if data.get("_type") == "metadata":
|
||||
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||
key = data.get("key") or fallback_key
|
||||
metadata = data.get("metadata", {})
|
||||
title = _metadata_title(metadata)
|
||||
preview = ""
|
||||
@@ -843,18 +906,19 @@ class SessionManager:
|
||||
if not fallback_preview and item.get("role") == "assistant":
|
||||
fallback_preview = text
|
||||
preview = preview or fallback_preview
|
||||
fallback_time = datetime.fromtimestamp(path.stat().st_mtime).isoformat()
|
||||
sessions.append(
|
||||
{
|
||||
"key": key,
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"created_at": data.get("created_at") or fallback_time,
|
||||
"updated_at": data.get("updated_at") or fallback_time,
|
||||
"title": title,
|
||||
"preview": preview,
|
||||
"path": str(path),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
repaired = self._repair(fallback_key)
|
||||
repaired = self._repair(fallback_key, path=path)
|
||||
if repaired is not None:
|
||||
sessions.append(
|
||||
{
|
||||
|
||||
@@ -29,10 +29,6 @@ _GOAL_CONTINUATION_SENDER = "system:continuation"
|
||||
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
|
||||
_MAX_GOAL_CONTINUATION_ROUNDS = 12
|
||||
_STRIPPED_INBOUND_META_KEYS = {
|
||||
"_stream_id",
|
||||
"_stream_delta",
|
||||
"_stream_end",
|
||||
"_resuming",
|
||||
INTERNAL_CONTINUATION_PENDING_META,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,15 @@ from typing import Any
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus import progress as bus_progress
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
GoalStateSyncEvent,
|
||||
GoalStatusEvent,
|
||||
RuntimeModelUpdatedEvent,
|
||||
SessionUpdatedEvent,
|
||||
TurnEndEvent,
|
||||
outbound_message_for_event,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import (
|
||||
GoalStateChanged,
|
||||
@@ -206,26 +214,22 @@ async def publish_turn_run_status(
|
||||
if msg.channel != "websocket":
|
||||
return
|
||||
cid = str(msg.chat_id)
|
||||
meta: dict[str, Any] = {
|
||||
**dict(msg.metadata or {}),
|
||||
"_goal_status": True,
|
||||
"goal_status": status,
|
||||
}
|
||||
started_at_event: float | None = None
|
||||
if status == "running":
|
||||
if isinstance(started_at, int | float) and started_at > 0:
|
||||
t0 = float(started_at)
|
||||
else:
|
||||
t0 = time.time()
|
||||
meta["started_at"] = t0
|
||||
started_at_event = t0
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
|
||||
else:
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
|
||||
await bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=cid,
|
||||
content="",
|
||||
metadata=meta,
|
||||
event=GoalStatusEvent(status=status, started_at=started_at_event),
|
||||
metadata=msg.metadata,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -318,28 +322,25 @@ class WebuiTurnCoordinator:
|
||||
if not cid:
|
||||
return
|
||||
await self.bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
outbound_message_for_event(
|
||||
channel=event.context.channel,
|
||||
chat_id=cid,
|
||||
content="",
|
||||
metadata={
|
||||
"_goal_state_sync": True,
|
||||
"goal_state": goal_state_ws_blob(event.session_metadata),
|
||||
},
|
||||
event=GoalStateSyncEvent(
|
||||
goal_state=goal_state_ws_blob(event.session_metadata),
|
||||
),
|
||||
metadata=event.context.metadata,
|
||||
),
|
||||
)
|
||||
|
||||
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
|
||||
await self.bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
outbound_message_for_event(
|
||||
channel="websocket",
|
||||
chat_id="*",
|
||||
content="",
|
||||
metadata={
|
||||
"_runtime_model_updated": True,
|
||||
"model": event.model,
|
||||
"model_preset": event.model_preset,
|
||||
},
|
||||
event=RuntimeModelUpdatedEvent(
|
||||
model=event.model,
|
||||
model_preset=event.model_preset,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -374,17 +375,18 @@ class WebuiTurnCoordinator:
|
||||
if msg.channel != "websocket":
|
||||
return
|
||||
|
||||
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
|
||||
if latency_ms is not None:
|
||||
turn_metadata["latency_ms"] = int(latency_ms)
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content="",
|
||||
metadata=turn_metadata,
|
||||
))
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
event=TurnEndEvent(
|
||||
latency_ms=latency_ms,
|
||||
goal_state=goal_state_ws_blob(session.metadata),
|
||||
),
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
)
|
||||
self._schedule_title_update(msg, session_key=session_key)
|
||||
|
||||
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
|
||||
@@ -404,16 +406,11 @@ class WebuiTurnCoordinator:
|
||||
model=title_llm.model,
|
||||
)
|
||||
if generated:
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
await self._publish_session_metadata_updated(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content="",
|
||||
metadata={
|
||||
**msg.metadata,
|
||||
"_session_updated": True,
|
||||
"_session_update_scope": "metadata",
|
||||
},
|
||||
))
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
|
||||
self.schedule_background(_generate_title_and_notify())
|
||||
|
||||
@@ -438,15 +435,26 @@ class WebuiTurnCoordinator:
|
||||
model=title_llm.model,
|
||||
)
|
||||
if generated:
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
await self._publish_session_metadata_updated(
|
||||
channel=event.context.channel,
|
||||
chat_id=event.context.chat_id,
|
||||
content="",
|
||||
metadata={
|
||||
**event.context.metadata,
|
||||
"_session_updated": True,
|
||||
"_session_update_scope": "metadata",
|
||||
},
|
||||
))
|
||||
metadata=event.context.metadata,
|
||||
)
|
||||
|
||||
self.schedule_background(_generate_title_and_notify())
|
||||
|
||||
async def _publish_session_metadata_updated(
|
||||
self,
|
||||
*,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> None:
|
||||
await self.bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
event=SessionUpdatedEvent(scope="metadata"),
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@ description: Schedule reminders and recurring tasks.
|
||||
|
||||
# Cron
|
||||
|
||||
Use the `cron` tool to schedule reminders or recurring tasks.
|
||||
Use the `cron` tool to schedule reminders or recurring tasks that should report back to the originating chat/session when they run.
|
||||
|
||||
Do not use `cron` for periodic background checks that should stay quiet when there is nothing useful to report. For those, update `HEARTBEAT.md`; the protected heartbeat job runs those checks and only delivers results that pass the notification gate.
|
||||
|
||||
## Three Modes
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Use this file for project-specific preferences, recurring workflow conventions,
|
||||
- Before scheduling reminders, check available skills and follow skill guidance first.
|
||||
- Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`).
|
||||
- Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
|
||||
- Cron jobs run as scheduled turns in the origin chat/session and normally deliver the result back to that channel. Do not use cron for background checks that should stay silent when there is nothing useful to report; use `HEARTBEAT.md` instead.
|
||||
|
||||
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
|
||||
|
||||
@@ -20,4 +21,4 @@ Use this file for project-specific preferences, recurring workflow conventions,
|
||||
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
|
||||
- Use `write_file` for first creation or intentional full-file rewrites.
|
||||
|
||||
When the user asks for a recurring/periodic heartbeat task, update `HEARTBEAT.md` instead of creating a one-time reminder. Use the built-in `cron` tool for separate reminders or custom schedules that should not be part of the heartbeat task list.
|
||||
When the user asks for a recurring/periodic heartbeat task, or for a periodic background check that should only notify on actionable changes, update `HEARTBEAT.md` instead of creating a one-time reminder. Use the built-in `cron` tool for explicit reminders, scheduled tasks that should report every run, or custom schedules that should not be part of the heartbeat task list.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<!--
|
||||
This file is checked periodically by your nanobot agent. When nanobot gateway starts with gateway.heartbeat.enabled=true, it automatically registers a protected heartbeat cron job that reads this file.
|
||||
|
||||
If this file has no tasks (only headers and comments), the agent will skip it. Completed tasks should be deleted, not kept — heartbeat only reads "Active Tasks".
|
||||
Use this file for recurring background checks that should stay quiet unless there is something useful to report. Regular cron jobs are different: they normally deliver each run's result back to the chat/session where they were created.
|
||||
|
||||
If this file has no tasks (only headers and comments), the agent will skip it. Completed tasks should be deleted, not kept - heartbeat only reads "Active Tasks".
|
||||
-->
|
||||
|
||||
## Active Tasks
|
||||
|
||||
@@ -529,6 +529,9 @@ class StreamingFileEditTracker:
|
||||
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
||||
used_canonicals: set[str] = set()
|
||||
for tool_call in final_tool_calls:
|
||||
name = getattr(tool_call, "name", None)
|
||||
if not is_file_edit_tool(name):
|
||||
continue
|
||||
canonical = self.canonical_call_id_for(tool_call)
|
||||
if canonical and canonical not in used_canonicals:
|
||||
try:
|
||||
|
||||
+54
-24
@@ -65,13 +65,31 @@ def _estimate_tools_tokens(
|
||||
return token_count
|
||||
|
||||
|
||||
def _tag_regex(tags: tuple[str, ...]) -> str:
|
||||
return rf"(?:{'|'.join(re.escape(tag) for tag in tags)})"
|
||||
|
||||
|
||||
_THINKING_TAGS = ("think", "thinking", "thought")
|
||||
_THINKING_TAG = _tag_regex(_THINKING_TAGS)
|
||||
_INLINE_SELF_CLOSING_THINKING_TAG = r"(?:thinking)"
|
||||
_THINKING_TAG_PREFIX = "|".join(
|
||||
sorted(
|
||||
{re.escape(tag[:i]) for tag in _THINKING_TAGS for i in range(1, len(tag) + 1)},
|
||||
key=len,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
_PARTIAL_THINKING_TAG = rf"</?(?:{_THINKING_TAG_PREFIX})>?"
|
||||
|
||||
|
||||
def strip_think(text: str) -> str:
|
||||
"""Remove thinking blocks, unclosed trailing tags, and tokenizer-level
|
||||
template leaks occasionally emitted by some models (notably Gemma 4's
|
||||
Ollama renderer).
|
||||
|
||||
Covers:
|
||||
1. Well-formed `<think>...</think>` and `<thought>...</thought>` blocks.
|
||||
1. Well-formed `<think>...</think>`, `<thinking>...</thinking>`,
|
||||
and `<thought>...</thought>` blocks.
|
||||
2. Streaming prefixes where the block is never closed.
|
||||
3. *Malformed* opening tags missing the `>` — e.g. `<think广场…`. The
|
||||
model sometimes emits the tag name directly followed by user-facing
|
||||
@@ -80,8 +98,8 @@ def strip_think(text: str) -> str:
|
||||
4. Harmony-style channel markers like `<channel|>` / `<|channel|>`
|
||||
**at the start of the text** — conservative to avoid eating
|
||||
explanatory prose that mentions these tokens.
|
||||
5. Orphan closing tags `</think>` / `</thought>` **at the very start
|
||||
or end of the text** only, for the same reason.
|
||||
5. Orphan closing tags `</think>` / `</thinking>` / `</thought>`
|
||||
**at the very start or end of the text** only, for the same reason.
|
||||
6. Trailing partial control tags split across stream chunks, such as
|
||||
`<thi`, `<thin`, or `<tho`.
|
||||
|
||||
@@ -91,48 +109,56 @@ def strip_think(text: str) -> str:
|
||||
assistant discusses the tokens themselves.
|
||||
"""
|
||||
# Well-formed blocks first.
|
||||
text = re.sub(r"<think>[\s\S]*?</think>", "", text)
|
||||
text = re.sub(r"^\s*<think>[\s\S]*$", "", text)
|
||||
text = re.sub(r"<thought>[\s\S]*?</thought>", "", text)
|
||||
text = re.sub(r"^\s*<thought>[\s\S]*$", "", text)
|
||||
# Malformed opening tags: `<think` / `<thought` where the next char is
|
||||
text = re.sub(rf"<(?P<tag>{_THINKING_TAG})>[\s\S]*?</(?P=tag)>", "", text)
|
||||
text = re.sub(rf"^\s*<{_THINKING_TAG}>[\s\S]*$", "", text)
|
||||
# Self-closing `<thinking/>` is an empty marker, not user-visible text.
|
||||
text = re.sub(rf"^\s*<{_INLINE_SELF_CLOSING_THINKING_TAG}/>\s*", "", text)
|
||||
text = re.sub(rf"\s*<{_INLINE_SELF_CLOSING_THINKING_TAG}/>\s*$", "", text)
|
||||
# Malformed opening tags: `<think` / `<thinking` / `<thought` where the next char is
|
||||
# NOT one that could continue a valid tag / identifier name. Explicitly
|
||||
# listing ASCII tag-name chars (letters, digits, `_`, `-`, `:`) plus
|
||||
# `>` / `/` — we can't use `\w` here because in Python's default
|
||||
# Unicode regex mode it matches CJK characters too, which would defeat
|
||||
# the primary fix for `<think广场…` leaks.
|
||||
text = re.sub(r"<think(?![A-Za-z0-9_\-:>/])", "", text)
|
||||
text = re.sub(r"<thought(?![A-Za-z0-9_\-:>/])", "", text)
|
||||
text = re.sub(rf"<{_THINKING_TAG}(?![A-Za-z0-9_\-:>/])", "", text)
|
||||
# Edge-only orphan closing tags (start or end of text).
|
||||
text = re.sub(r"^\s*</think>\s*", "", text)
|
||||
text = re.sub(r"\s*</think>\s*$", "", text)
|
||||
text = re.sub(r"^\s*</thought>\s*", "", text)
|
||||
text = re.sub(r"\s*</thought>\s*$", "", text)
|
||||
text = re.sub(rf"^\s*</{_THINKING_TAG}>\s*", "", text)
|
||||
text = re.sub(rf"\s*</{_THINKING_TAG}>\s*$", "", text)
|
||||
# Edge-only channel markers (harmony / Gemma 4 variant leaks).
|
||||
text = re.sub(r"^\s*<\|?channel\|?>\s*", "", text)
|
||||
# Stream chunks may end in the middle of a control tag. Strip only known
|
||||
# control-token prefixes at the very end.
|
||||
partial_control_tag = (
|
||||
r"</?(?:t|th|thi|thin|think|tho|thou|thoug|though|thought)>?"
|
||||
r"|<\|?(?:c|ch|cha|chan|chann|channe|channel)(?:\|?>?)?"
|
||||
rf"{_PARTIAL_THINKING_TAG}|"
|
||||
r"<\|?(?:c|ch|cha|chan|chann|channe|channel)(?:\|?>?)?"
|
||||
)
|
||||
text = re.sub(rf"(?:{partial_control_tag})$", "", text)
|
||||
text = re.sub(r"^\s*<\|?$", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def strip_reasoning_tags(text: object) -> str:
|
||||
"""Remove wrapper tags from text that is already known to be reasoning."""
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
text = re.sub(rf"^\s*<{_THINKING_TAG}/>\s*", "", text)
|
||||
text = re.sub(rf"\s*<{_THINKING_TAG}/>\s*$", "", text)
|
||||
text = re.sub(rf"^\s*<{_THINKING_TAG}>\s*", "", text)
|
||||
text = re.sub(rf"\s*</{_THINKING_TAG}>\s*$", "", text)
|
||||
text = re.sub(rf"\s*(?:{_PARTIAL_THINKING_TAG})$", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def extract_think(text: str) -> tuple[str | None, str]:
|
||||
"""Extract thinking content from inline ``<think>`` / ``<thought>`` blocks.
|
||||
"""Extract thinking content from inline thinking tags.
|
||||
|
||||
Returns ``(thinking_text, cleaned_text)``. Only closed blocks are
|
||||
extracted; unclosed streaming prefixes are stripped from the cleaned
|
||||
text but not surfaced — :func:`strip_think` handles that case.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for m in re.finditer(r"<think>([\s\S]*?)</think>", text):
|
||||
parts.append(m.group(1).strip())
|
||||
for m in re.finditer(r"<thought>([\s\S]*?)</thought>", text):
|
||||
parts.append(m.group(1).strip())
|
||||
for m in re.finditer(rf"<(?P<tag>{_THINKING_TAG})>([\s\S]*?)</(?P=tag)>", text):
|
||||
parts.append(m.group(2).strip())
|
||||
thinking = "\n\n".join(parts) if parts else None
|
||||
return thinking, strip_think(text)
|
||||
|
||||
@@ -194,10 +220,10 @@ def extract_reasoning(
|
||||
final answer.
|
||||
"""
|
||||
if reasoning_content:
|
||||
return reasoning_content, strip_think(content) if content else content
|
||||
return strip_reasoning_tags(reasoning_content), strip_think(content) if content else content
|
||||
if thinking_blocks:
|
||||
parts = [
|
||||
tb.get("thinking", "")
|
||||
strip_reasoning_tags(tb.get("thinking", ""))
|
||||
for tb in thinking_blocks
|
||||
if isinstance(tb, dict) and tb.get("type") == "thinking"
|
||||
]
|
||||
@@ -520,7 +546,11 @@ def build_assistant_message(
|
||||
if tool_calls:
|
||||
msg["tool_calls"] = tool_calls
|
||||
if reasoning_content is not None or thinking_blocks:
|
||||
msg["reasoning_content"] = reasoning_content if reasoning_content is not None else ""
|
||||
msg["reasoning_content"] = (
|
||||
strip_reasoning_tags(reasoning_content)
|
||||
if reasoning_content is not None
|
||||
else ""
|
||||
)
|
||||
if thinking_blocks:
|
||||
msg["thinking_blocks"] = thinking_blocks
|
||||
return msg
|
||||
|
||||
@@ -35,10 +35,15 @@ def format_tool_hints(tool_calls: list, max_length: int = 40) -> str:
|
||||
|
||||
formatted = []
|
||||
for tc in tool_calls:
|
||||
fmt = _TOOL_FORMATS.get(tc.name)
|
||||
name = getattr(tc, "name", None)
|
||||
if not isinstance(name, str) or not name:
|
||||
# Degenerate/malformed tool call (e.g. a model emits name=None);
|
||||
# skip it instead of raising AttributeError on the whole turn.
|
||||
continue
|
||||
fmt = _TOOL_FORMATS.get(name)
|
||||
if fmt:
|
||||
formatted.append(_fmt_known(tc, fmt, max_length))
|
||||
elif tc.name.startswith("mcp_"):
|
||||
elif name.startswith("mcp_"):
|
||||
formatted.append(_fmt_mcp(tc, max_length))
|
||||
else:
|
||||
formatted.append(_fmt_fallback(tc, max_length))
|
||||
|
||||
@@ -26,10 +26,11 @@ from nanobot.session.manager import (
|
||||
_metadata_title,
|
||||
)
|
||||
|
||||
_INDEX_VERSION = 1
|
||||
_INDEX_VERSION = 2
|
||||
_INDEX_FILENAME = ".webui_session_index.json"
|
||||
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
||||
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
||||
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
|
||||
|
||||
|
||||
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
||||
@@ -214,14 +215,45 @@ def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
|
||||
return stored
|
||||
|
||||
|
||||
def _visible_message_timestamp(item: dict[str, Any]) -> str | None:
|
||||
if item.get(CRON_HISTORY_META) is True:
|
||||
return None
|
||||
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
|
||||
return None
|
||||
timestamp = item.get("timestamp")
|
||||
return timestamp if isinstance(timestamp, str) else None
|
||||
|
||||
|
||||
def _last_visible_message_at(messages: list[dict[str, Any]]) -> str | None:
|
||||
latest: str | None = None
|
||||
for item in messages:
|
||||
timestamp = _visible_message_timestamp(item)
|
||||
if timestamp is not None:
|
||||
latest = _latest_updated_at(latest, timestamp)
|
||||
return latest
|
||||
|
||||
|
||||
def _visible_activity_updated_at(
|
||||
stored: str | None,
|
||||
visible_message_at: str | None,
|
||||
webui_activity: str | None,
|
||||
) -> str | None:
|
||||
return _latest_updated_at(visible_message_at, webui_activity) or stored
|
||||
|
||||
|
||||
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
||||
signature = _file_signature(path)
|
||||
activity_signature = _webui_activity_signature(session.key)
|
||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||
visible_message_at = _last_visible_message_at(session.messages)
|
||||
return {
|
||||
"key": session.key,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": _latest_updated_at(session.updated_at.isoformat(), activity_updated_at),
|
||||
"updated_at": _visible_activity_updated_at(
|
||||
session.updated_at.isoformat(),
|
||||
visible_message_at,
|
||||
activity_updated_at,
|
||||
),
|
||||
"title": _metadata_title(session.metadata),
|
||||
"preview": _preview_from_messages(session.messages),
|
||||
"file": path.name,
|
||||
@@ -232,7 +264,8 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
||||
fallback_key = path.stem.replace("_", ":", 1)
|
||||
storage_key = SessionManager._decode_storage_key(path.stem)
|
||||
fallback_key = storage_key or path.stem.replace("_", ":", 1)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
first_line = f.readline().strip()
|
||||
@@ -243,39 +276,55 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
||||
return None
|
||||
preview = ""
|
||||
fallback_preview = ""
|
||||
visible_message_at = None
|
||||
preview_done = False
|
||||
scanned_records = 0
|
||||
scanned_chars = 0
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
scanned_records += 1
|
||||
scanned_chars += len(line)
|
||||
if (
|
||||
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||
):
|
||||
break
|
||||
item = json.loads(line)
|
||||
if item.get("_type") == "metadata":
|
||||
continue
|
||||
if item.get(CRON_HISTORY_META) is True:
|
||||
continue
|
||||
text = _message_preview_text(item)
|
||||
if not text:
|
||||
continue
|
||||
if item.get("role") == "user":
|
||||
preview = text
|
||||
break
|
||||
if not fallback_preview and item.get("role") == "assistant":
|
||||
fallback_preview = text
|
||||
timestamp = _visible_message_timestamp(item)
|
||||
if timestamp is not None:
|
||||
visible_message_at = _latest_updated_at(visible_message_at, timestamp)
|
||||
if not preview_done:
|
||||
scanned_records += 1
|
||||
scanned_chars += len(line)
|
||||
if (
|
||||
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||
):
|
||||
preview_done = True
|
||||
continue
|
||||
if item.get(CRON_HISTORY_META) is True:
|
||||
continue
|
||||
text = _message_preview_text(item)
|
||||
if not text:
|
||||
continue
|
||||
if item.get("role") == "user":
|
||||
preview = text
|
||||
preview_done = True
|
||||
continue
|
||||
if not fallback_preview and item.get("role") == "assistant":
|
||||
fallback_preview = text
|
||||
signature = _file_signature(path)
|
||||
created_at_s = data.get("created_at")
|
||||
updated_at_s = data.get("updated_at")
|
||||
if not created_at_s or not updated_at_s:
|
||||
fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat()
|
||||
created_at_s = created_at_s or fallback_time
|
||||
updated_at_s = updated_at_s or fallback_time
|
||||
key = data.get("key") or fallback_key
|
||||
activity_signature = _webui_activity_signature(key)
|
||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||
return {
|
||||
"key": key,
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": _latest_updated_at(data.get("updated_at"), activity_updated_at),
|
||||
"created_at": created_at_s,
|
||||
"updated_at": _visible_activity_updated_at(
|
||||
updated_at_s,
|
||||
visible_message_at,
|
||||
activity_updated_at,
|
||||
),
|
||||
"title": _metadata_title(data.get("metadata", {})),
|
||||
"preview": preview or fallback_preview,
|
||||
"file": path.name,
|
||||
|
||||
@@ -16,12 +16,13 @@ from zoneinfo import ZoneInfo
|
||||
import httpx
|
||||
|
||||
from nanobot import __version__
|
||||
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
||||
from nanobot.audio.transcription import resolve_transcription_config
|
||||
from nanobot.audio.transcription_registry import (
|
||||
resolve_transcription_provider,
|
||||
transcription_provider_names,
|
||||
)
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars, save_config
|
||||
from nanobot.config.schema import ModelPresetConfig, ProviderConfig
|
||||
from nanobot.providers.image_generation import (
|
||||
get_image_gen_provider,
|
||||
@@ -79,19 +80,7 @@ _NATIVE_RESTART_BEHAVIOR_BY_SECTION = {
|
||||
"apps": "engineRestart",
|
||||
}
|
||||
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
||||
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
||||
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
||||
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
|
||||
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
|
||||
{"name": "jina", "label": "Jina", "credential": "api_key"},
|
||||
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
|
||||
{"name": "exa", "label": "Exa", "credential": "api_key"},
|
||||
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
||||
{"name": "bocha", "label": "Bocha", "credential": "api_key"},
|
||||
{"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"},
|
||||
{"name": "keenable", "label": "Keenable", "credential": "optional_api_key"},
|
||||
)
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
|
||||
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
||||
}
|
||||
@@ -110,47 +99,6 @@ _CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144}
|
||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
_MODEL_LIST_UNSUPPORTED_BACKENDS = {
|
||||
"anthropic",
|
||||
"azure_openai",
|
||||
"bedrock",
|
||||
"github_copilot",
|
||||
"openai_codex",
|
||||
}
|
||||
|
||||
_MODEL_LIST_CATALOG_PROVIDERS = {
|
||||
"aihubmix",
|
||||
"byteplus",
|
||||
"byteplus_coding_plan",
|
||||
"huggingface",
|
||||
"novita",
|
||||
"openrouter",
|
||||
"siliconflow",
|
||||
"volcengine",
|
||||
"volcengine_coding_plan",
|
||||
}
|
||||
|
||||
_MODEL_LIST_OFFICIAL_PROVIDERS = {
|
||||
"ant_ling",
|
||||
"dashscope",
|
||||
"deepseek",
|
||||
"gemini",
|
||||
"groq",
|
||||
"longcat",
|
||||
"minimax",
|
||||
"minimax_anthropic",
|
||||
"mistral",
|
||||
"moonshot",
|
||||
"nvidia",
|
||||
"openai",
|
||||
"qianfan",
|
||||
"skywork",
|
||||
"stepfun",
|
||||
"xiaomi_mimo",
|
||||
"zhipu",
|
||||
}
|
||||
|
||||
|
||||
class WebUISettingsError(ValueError):
|
||||
"""User-facing settings validation failure."""
|
||||
|
||||
@@ -370,7 +318,7 @@ def _resolve_settings_provider(
|
||||
normalized = provider_name.replace("-", "_")
|
||||
for extra_name, provider_config in _dynamic_provider_items(config):
|
||||
if provider_name == extra_name or normalized == extra_name.replace("-", "_"):
|
||||
return create_dynamic_spec(extra_name), extra_name, provider_config
|
||||
return create_dynamic_spec(extra_name, thinking_style=(provider_config.thinking_style or "")), extra_name, provider_config
|
||||
return None
|
||||
|
||||
|
||||
@@ -405,10 +353,13 @@ def _provider_settings_row(
|
||||
|
||||
|
||||
def _model_catalog_kind(spec: Any) -> str:
|
||||
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
|
||||
return "catalog"
|
||||
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS:
|
||||
return "official"
|
||||
catalog = getattr(spec, "model_catalog", "auto")
|
||||
if catalog != "auto":
|
||||
return catalog
|
||||
if spec.is_transcription_only or spec.is_oauth:
|
||||
return "unsupported"
|
||||
if spec.backend != "openai_compat" and spec.name != "minimax_anthropic":
|
||||
return "unsupported"
|
||||
if spec.is_local:
|
||||
return "local"
|
||||
if spec.is_direct:
|
||||
@@ -501,27 +452,20 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
spec, provider_key, provider_config = resolved_provider
|
||||
|
||||
catalog_kind = _model_catalog_kind(spec)
|
||||
base_payload: dict[str, Any] = {
|
||||
"provider": provider_key,
|
||||
"label": spec.label,
|
||||
"catalog_kind": _model_catalog_kind(spec),
|
||||
"catalog_kind": catalog_kind,
|
||||
"models": [],
|
||||
"model_count": 0,
|
||||
"message": None,
|
||||
"fetched_at": time.time(),
|
||||
}
|
||||
if (
|
||||
spec.is_transcription_only
|
||||
or (
|
||||
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
|
||||
and spec.name != "minimax_anthropic"
|
||||
)
|
||||
or spec.is_oauth
|
||||
):
|
||||
if catalog_kind == "unsupported":
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "unsupported",
|
||||
"catalog_kind": "unsupported",
|
||||
"message": "Model list is not available for this provider. Type a model ID manually.",
|
||||
}
|
||||
|
||||
@@ -750,7 +694,7 @@ def settings_payload(
|
||||
providers.append(
|
||||
_provider_settings_row(
|
||||
provider_key,
|
||||
create_dynamic_spec(provider_key),
|
||||
create_dynamic_spec(provider_key, thinking_style=(provider_config.thinking_style or "")),
|
||||
provider_config,
|
||||
)
|
||||
)
|
||||
@@ -1177,14 +1121,19 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
except ImportError:
|
||||
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
|
||||
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
except ValueError as e:
|
||||
raise WebUISettingsError(str(e), status=400) from e
|
||||
token = None
|
||||
with suppress(Exception):
|
||||
token = get_token()
|
||||
token = get_token(proxy=proxy)
|
||||
if not (token and token.access):
|
||||
messages: list[str] = []
|
||||
token = login_oauth_interactive(
|
||||
print_fn=lambda message: messages.append(str(message)),
|
||||
prompt_fn=lambda _prompt: "",
|
||||
proxy=proxy,
|
||||
)
|
||||
if not (token and token.access):
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
|
||||
@@ -1087,9 +1087,20 @@ def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[di
|
||||
def _file_edit_key(edit: dict[str, Any]) -> str:
|
||||
call_id = str(edit.get("call_id") or "")
|
||||
tool = str(edit.get("tool") or "")
|
||||
path = str(edit.get("path") or "")
|
||||
if call_id and path:
|
||||
return f"{call_id}|{tool}|{path}"
|
||||
if call_id:
|
||||
return f"{call_id}|{tool}"
|
||||
return f"{tool}|{edit.get('path') or ''}"
|
||||
return f"{tool}|{path}"
|
||||
|
||||
|
||||
def _file_edit_tool_event_key(edit: dict[str, Any]) -> str:
|
||||
call_id = str(edit.get("call_id") or "")
|
||||
tool = str(edit.get("tool") or "")
|
||||
if call_id:
|
||||
return f"{call_id}|{tool}"
|
||||
return _file_edit_key(edit)
|
||||
|
||||
|
||||
def _message_has_file_edit_for_tool_event(
|
||||
@@ -1102,7 +1113,10 @@ def _message_has_file_edit_for_tool_event(
|
||||
edits = message.get("fileEdits")
|
||||
if not isinstance(edits, list):
|
||||
return False
|
||||
return any(isinstance(edit, dict) and _file_edit_key(edit) == key for edit in edits)
|
||||
return any(
|
||||
isinstance(edit, dict) and _file_edit_tool_event_key(edit) == key
|
||||
for edit in edits
|
||||
)
|
||||
|
||||
|
||||
def _filter_covered_file_edit_tool_events(
|
||||
@@ -1123,7 +1137,7 @@ def _strip_covered_file_edit_tool_hints(
|
||||
edits: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
incoming_keys = {
|
||||
_file_edit_key(edit)
|
||||
_file_edit_tool_event_key(edit)
|
||||
for edit in edits
|
||||
if isinstance(edit, dict)
|
||||
}
|
||||
@@ -1460,6 +1474,11 @@ def replay_transcript_to_ui_messages(
|
||||
edits: list[dict[str, Any]],
|
||||
) -> int | None:
|
||||
incoming_keys = {_file_edit_key(edit) for edit in edits if isinstance(edit, dict)}
|
||||
incoming_tool_event_keys = {
|
||||
_file_edit_tool_event_key(edit)
|
||||
for edit in edits
|
||||
if isinstance(edit, dict)
|
||||
}
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
candidate = messages[i]
|
||||
if candidate.get("role") == "user":
|
||||
@@ -1471,7 +1490,16 @@ def replay_transcript_to_ui_messages(
|
||||
existing_edits = candidate.get("fileEdits")
|
||||
if isinstance(existing_edits, list):
|
||||
for existing in existing_edits:
|
||||
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
|
||||
if not isinstance(existing, dict):
|
||||
continue
|
||||
if (
|
||||
_file_edit_key(existing) in incoming_keys
|
||||
or (
|
||||
not existing.get("path")
|
||||
and existing.get("pending")
|
||||
and _file_edit_tool_event_key(existing) in incoming_tool_event_keys
|
||||
)
|
||||
):
|
||||
return i
|
||||
existing_tool_events = candidate.get("toolEvents")
|
||||
if isinstance(existing_tool_events, list):
|
||||
@@ -1479,7 +1507,7 @@ def replay_transcript_to_ui_messages(
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
key = _tool_event_file_edit_key(event)
|
||||
if key and key in incoming_keys:
|
||||
if key and key in incoming_tool_event_keys:
|
||||
return i
|
||||
return None
|
||||
|
||||
@@ -1535,12 +1563,24 @@ def replay_transcript_to_ui_messages(
|
||||
if not isinstance(edit, dict):
|
||||
continue
|
||||
key = _file_edit_key(edit)
|
||||
if key in index_by_key:
|
||||
pos = index_by_key[key]
|
||||
pos = index_by_key.get(key)
|
||||
if pos is None and edit.get("path"):
|
||||
event_key = _file_edit_tool_event_key(edit)
|
||||
for existing_pos, existing_edit in enumerate(existing):
|
||||
if (
|
||||
isinstance(existing_edit, dict)
|
||||
and not existing_edit.get("path")
|
||||
and existing_edit.get("pending")
|
||||
and _file_edit_tool_event_key(existing_edit) == event_key
|
||||
):
|
||||
pos = existing_pos
|
||||
break
|
||||
if pos is not None:
|
||||
merged = {**existing[pos], **edit}
|
||||
if edit.get("path") and not edit.get("pending"):
|
||||
merged.pop("pending", None)
|
||||
existing[pos] = merged
|
||||
index_by_key[key] = pos
|
||||
else:
|
||||
index_by_key[key] = len(existing)
|
||||
existing.append(dict(edit))
|
||||
|
||||
+6
-5
@@ -31,7 +31,7 @@ dependencies = [
|
||||
"websocket-client>=1.9.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
"ddgs>=9.5.5,<10.0.0",
|
||||
"oauth-cli-kit>=0.1.3,<1.0.0",
|
||||
"oauth-cli-kit>=0.1.6,<1.0.0",
|
||||
"loguru>=0.7.3,<1.0.0",
|
||||
"readability-lxml>=0.8.4,<1.0.0",
|
||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||
@@ -93,6 +93,10 @@ matrix = [
|
||||
discord = [
|
||||
"discord.py>=2.5.2,<3.0.0",
|
||||
]
|
||||
whatsapp = [
|
||||
"neonize>=0.3.18.post0,<0.4.0",
|
||||
"segno>=1.6.1,<2.0.0",
|
||||
]
|
||||
langsmith = [
|
||||
"langsmith>=0.1.0",
|
||||
]
|
||||
@@ -150,14 +154,10 @@ packages = ["nanobot"]
|
||||
[tool.hatch.build.targets.wheel.sources]
|
||||
"nanobot" = "nanobot"
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"bridge" = "nanobot/bridge"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"nanobot/",
|
||||
"nanobot/web/dist/",
|
||||
"bridge/",
|
||||
"hatch_build.py",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
@@ -182,6 +182,7 @@ source = ["nanobot"]
|
||||
omit = ["tests/*", "**/tests/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 75
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
|
||||
+10
-2
@@ -269,7 +269,15 @@ if [ "${NANOBOT_SKIP_WIZARD:-}" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Starting setup wizard..."
|
||||
run_nanobot onboard --wizard
|
||||
if [ -t 0 ]; then
|
||||
info "Starting setup wizard..."
|
||||
run_nanobot onboard --wizard
|
||||
elif : 2>/dev/null < /dev/tty; then
|
||||
info "Starting setup wizard..."
|
||||
run_nanobot onboard --wizard < /dev/tty
|
||||
else
|
||||
info "Skipping setup wizard because no interactive terminal is available."
|
||||
info "Run this later: $(nanobot_try_command) onboard --wizard"
|
||||
fi
|
||||
|
||||
info "Done. Try: $(nanobot_try_command) agent -m \"Hello!\""
|
||||
|
||||
@@ -38,7 +38,6 @@ def make_loop(
|
||||
model: str = "test-model",
|
||||
context_window_tokens: int = 128_000,
|
||||
session_ttl_minutes: int = 0,
|
||||
max_messages: int = 120,
|
||||
unified_session: bool = False,
|
||||
mcp_servers: dict | None = None,
|
||||
tools_config=None,
|
||||
@@ -64,7 +63,6 @@ def make_loop(
|
||||
model=model,
|
||||
context_window_tokens=context_window_tokens,
|
||||
session_ttl_minutes=session_ttl_minutes,
|
||||
max_messages=max_messages,
|
||||
unified_session=unified_session,
|
||||
)
|
||||
if mcp_servers is not None:
|
||||
@@ -79,8 +77,8 @@ def make_loop(
|
||||
if patch_deps:
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
return AgentLoop(**kwargs)
|
||||
return AgentLoop(**kwargs)
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ def _make_fake_compact(
|
||||
|
||||
tail = list(session.messages[session.last_consolidated:])
|
||||
if not tail:
|
||||
session.updated_at = datetime.now()
|
||||
loop.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -103,15 +102,14 @@ def _make_fake_compact(
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(
|
||||
result = probe.retain_recent_legal_suffix(
|
||||
max_suffix,
|
||||
extend_to_user=True,
|
||||
)
|
||||
kept = probe.messages
|
||||
archive_msgs = dropped[already_consolidated:]
|
||||
archive_msgs = result.dropped[result.already_consolidated_count:]
|
||||
|
||||
if not archive_msgs and not kept:
|
||||
session.updated_at = datetime.now()
|
||||
loop.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -132,7 +130,6 @@ def _make_fake_compact(
|
||||
|
||||
session.messages = kept
|
||||
session.last_consolidated = 0
|
||||
session.updated_at = datetime.now()
|
||||
loop.sessions.save(session)
|
||||
return s
|
||||
|
||||
@@ -223,7 +220,7 @@ class TestAgentLoopTTLParam:
|
||||
kwargs = session.get_history.call_args.kwargs
|
||||
assert isinstance(kwargs.get("max_tokens"), int)
|
||||
assert kwargs["max_tokens"] > 0
|
||||
assert kwargs["include_timestamps"] is True
|
||||
assert set(kwargs) == {"max_messages", "max_tokens", "extend_to_user"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
|
||||
@@ -1021,27 +1018,28 @@ class TestProactiveAutoCompact:
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 1
|
||||
|
||||
# Second tick: should NOT re-schedule (updated_at is fresh after clear)
|
||||
# Second tick: should NOT re-schedule because the session has no removable tail.
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_skip_refreshes_updated_at_prevents_reschedule(self, tmp_path):
|
||||
"""Empty session skip refreshes updated_at, preventing immediate re-scheduling."""
|
||||
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
|
||||
"""Empty expired sessions have no removable tail and should not schedule."""
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||
loop.sessions.save(session)
|
||||
|
||||
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||
_fake_compact = _make_fake_compact(loop)
|
||||
loop.consolidator.compact_idle_session = _fake_compact
|
||||
|
||||
# First tick: skips (no messages), refreshes updated_at
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 0
|
||||
assert "cli:test" not in loop.auto_compact._summaries
|
||||
|
||||
# Second tick: should NOT re-schedule because updated_at is fresh
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 0
|
||||
assert "cli:test" not in loop.auto_compact._summaries
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
@@ -200,8 +200,11 @@ class TestCheckExpired:
|
||||
"""Expired session should trigger schedule_background."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}]
|
||||
old_dt = datetime.now() - timedelta(minutes=20)
|
||||
session = _make_session("cli:old", updated_at=old_dt)
|
||||
_add_turns(session, 5)
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_dt.isoformat()}]
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
|
||||
scheduled = []
|
||||
@@ -273,6 +276,24 @@ class TestCheckExpired:
|
||||
scheduler.assert_not_called()
|
||||
assert "dream:20260602-155256" not in ac._archiving
|
||||
|
||||
def test_already_trimmed_session_skips(self):
|
||||
"""Expired session with no removable tail should not be re-scheduled."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
last_active = datetime(2026, 1, 1, 10, 0, 0)
|
||||
session = _make_session("cli:done", updated_at=last_active)
|
||||
_add_turns(session, 2)
|
||||
mock_sm.list_sessions.return_value = [
|
||||
{"key": "cli:done", "updated_at": last_active.isoformat()},
|
||||
]
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
|
||||
scheduler = MagicMock()
|
||||
ac.check_expired(scheduler)
|
||||
|
||||
scheduler.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _archive
|
||||
|
||||
@@ -430,9 +430,11 @@ class TestCompactIdleSession:
|
||||
)
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:test")
|
||||
old_ts = session.updated_at
|
||||
for i in range(20):
|
||||
session.add_message("user", f"user msg {i}")
|
||||
session.add_message("assistant", f"assistant msg {i}")
|
||||
session.updated_at = old_ts
|
||||
sessions.save(session)
|
||||
|
||||
result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8)
|
||||
@@ -445,6 +447,7 @@ class TestCompactIdleSession:
|
||||
assert meta is not None
|
||||
assert meta["text"] == "Summary of old conversation."
|
||||
assert "last_active" in meta
|
||||
assert reloaded.updated_at == old_ts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
|
||||
@@ -518,8 +521,10 @@ class TestCompactIdleSession:
|
||||
assert entries[0]["session_key"] == "cli:test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_session_refreshes_timestamp(self, real_consolidator):
|
||||
"""Empty session with old updated_at → refreshed after call, returns ''."""
|
||||
async def test_empty_session_does_not_refresh_timestamp(
|
||||
self, real_consolidator
|
||||
):
|
||||
"""Empty session with old updated_at does not look active after compaction."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sessions = real_consolidator.sessions
|
||||
@@ -532,7 +537,8 @@ class TestCompactIdleSession:
|
||||
assert result == ""
|
||||
|
||||
reloaded = sessions.get_or_create("cli:empty")
|
||||
assert reloaded.updated_at > old_ts
|
||||
assert reloaded.updated_at == old_ts
|
||||
assert reloaded.metadata == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
|
||||
|
||||
@@ -24,9 +24,14 @@ class TestDreamSessionKey:
|
||||
|
||||
class TestPruneDreamSessions:
|
||||
def test_keeps_n_most_recent(self, tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
|
||||
base_time = time.time() - 100
|
||||
|
||||
for i in range(15):
|
||||
key = f"dream:20260528-{100000 + i:06d}"
|
||||
safe_key = key.replace(":", "_")
|
||||
@@ -37,6 +42,7 @@ class TestPruneDreamSessions:
|
||||
f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.utime(path, (base_time + i, base_time + i))
|
||||
|
||||
normal_path = sessions_dir / "telegram_123.jsonl"
|
||||
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
|
||||
|
||||
@@ -11,7 +11,6 @@ from unittest.mock import patch
|
||||
from nanobot.providers.base import ToolCallRequest
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
|
||||
|
||||
|
||||
@@ -125,6 +124,47 @@ def test_parse_dict_preserves_extra_content() -> None:
|
||||
assert payload["extra_content"] == GEMINI_EXTRA
|
||||
|
||||
|
||||
def test_parse_dict_deduplicates_duplicate_tool_call_ids() -> None:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response_dict = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_same",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
},
|
||||
{
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_same",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = provider._parse(response_dict)
|
||||
|
||||
ids = [tc.id for tc in result.tool_calls]
|
||||
assert len(ids) == 2
|
||||
assert ids[0] == "call_same"
|
||||
assert ids[1] != "call_same"
|
||||
assert len(set(ids)) == 2
|
||||
assert [tc.arguments for tc in result.tool_calls] == [{"path": "a.txt"}, {"path": "b.txt"}]
|
||||
|
||||
|
||||
# ── _parse_chunks: streaming round-trip ───────────────────────────────
|
||||
|
||||
def test_parse_chunks_sdk_preserves_extra_content() -> None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user