mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de9c5f03ae | ||
|
|
1cd3431639 | ||
|
|
c62d0d5fa7 | ||
|
|
9474498e3e | ||
|
|
abf930a381 | ||
|
|
c2b1453b2e | ||
|
|
3fccd9ab9a | ||
|
|
12610138af | ||
|
|
5b9eba4318 | ||
|
|
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.
|
||||
|
||||
+7
-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
|
||||
@@ -109,10 +109,9 @@ File operations have path traversal protection, but:
|
||||
- Timeouts are configured to prevent hanging requests
|
||||
- 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 +126,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 +229,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"]
|
||||
}
|
||||
+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
|
||||
|
||||
|
||||
+147
-4
@@ -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`.
|
||||
@@ -211,6 +245,8 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
|----------|---------|-------------|
|
||||
| `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 +268,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/) |
|
||||
@@ -677,6 +714,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 +985,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 +1505,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 +1852,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 +1963,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.
|
||||
|
||||
@@ -1860,9 +1990,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
|
||||
|
||||
@@ -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. |
|
||||
@@ -94,6 +95,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 +293,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
|
||||
|
||||
+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:
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""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]"
|
||||
|
||||
|
||||
@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.drop_orphan_tool_results(messages)
|
||||
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 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])
|
||||
@@ -190,6 +190,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,
|
||||
@@ -287,6 +288,7 @@ 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
|
||||
@@ -377,6 +379,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,
|
||||
@@ -1180,7 +1183,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)
|
||||
@@ -1459,7 +1461,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)
|
||||
|
||||
@@ -479,6 +479,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 +712,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 +792,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")
|
||||
|
||||
+96
-248
@@ -13,6 +13,10 @@ 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.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
@@ -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,7 @@ from nanobot.utils.runtime import (
|
||||
build_finalization_retry_message,
|
||||
build_goal_continue_message,
|
||||
build_length_recovery_message,
|
||||
ensure_nonempty_tool_result,
|
||||
build_runtime_budget_notice_message,
|
||||
is_blank_text,
|
||||
repeated_external_lookup_error,
|
||||
repeated_workspace_violation_error,
|
||||
@@ -66,17 +68,7 @@ _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]"
|
||||
|
||||
_BUDGET_NOTICE_MIN_ITERATIONS = 20
|
||||
# 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 +126,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 +359,20 @@ class AgentRunner:
|
||||
length_recovery_count = 0
|
||||
had_injections = False
|
||||
injection_cycles = 0
|
||||
budget_notice_level_sent = 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 +380,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 +392,10 @@ 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.drop_orphan_tool_results(messages)
|
||||
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
except Exception:
|
||||
messages_for_model = messages
|
||||
context = AgentHookContext(
|
||||
@@ -462,8 +468,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,
|
||||
@@ -508,6 +514,12 @@ class AgentRunner:
|
||||
)
|
||||
if _drained:
|
||||
had_injections = True
|
||||
budget_notice_level_sent = self._append_runtime_budget_notice_if_needed(
|
||||
spec,
|
||||
messages,
|
||||
completed_iterations=iteration + 1,
|
||||
sent_level=budget_notice_level_sent,
|
||||
)
|
||||
await hook.after_iteration(context)
|
||||
continue
|
||||
|
||||
@@ -770,16 +782,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)
|
||||
@@ -929,6 +949,53 @@ class AgentRunner:
|
||||
retry_messages.append(build_budget_exhausted_finalization_message())
|
||||
return retry_messages
|
||||
|
||||
@classmethod
|
||||
def _append_runtime_budget_notice_if_needed(
|
||||
cls,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
completed_iterations: int,
|
||||
sent_level: int,
|
||||
) -> int:
|
||||
level = cls._runtime_budget_notice_level(
|
||||
max_iterations=spec.max_iterations,
|
||||
completed_iterations=completed_iterations,
|
||||
)
|
||||
if level <= sent_level:
|
||||
return sent_level
|
||||
|
||||
remaining_iterations = max(0, spec.max_iterations - completed_iterations)
|
||||
messages.append(build_runtime_budget_notice_message(
|
||||
level=level,
|
||||
max_iterations=spec.max_iterations,
|
||||
used_iterations=completed_iterations,
|
||||
remaining_iterations=remaining_iterations,
|
||||
))
|
||||
return level
|
||||
|
||||
@staticmethod
|
||||
def _runtime_budget_notice_level(
|
||||
*,
|
||||
max_iterations: int,
|
||||
completed_iterations: int,
|
||||
) -> int:
|
||||
"""Return the convergence-warning level for a long tool loop."""
|
||||
if max_iterations < _BUDGET_NOTICE_MIN_ITERATIONS:
|
||||
return 0
|
||||
|
||||
remaining_iterations = max_iterations - completed_iterations
|
||||
if remaining_iterations <= 0:
|
||||
return 0
|
||||
|
||||
convergence_threshold = max(5, (max_iterations + 9) // 10)
|
||||
final_threshold = max(3, (max_iterations + 32) // 33)
|
||||
if remaining_iterations <= final_threshold:
|
||||
return 2
|
||||
if remaining_iterations <= convergence_threshold:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _max_iterations_fallback(spec: AgentRunSpec) -> str:
|
||||
if spec.max_iterations_message:
|
||||
@@ -1325,225 +1392,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,
|
||||
|
||||
@@ -17,6 +17,13 @@ from nanobot.agent.tools.schema import (
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.agent.verification_state import (
|
||||
VerificationAnalysis,
|
||||
analyze_verification_result,
|
||||
append_verification_feedback,
|
||||
record_verification_observation,
|
||||
)
|
||||
from nanobot.utils.helpers import build_structured_output_summary
|
||||
|
||||
DEFAULT_YIELD_MS = 1000
|
||||
MAX_YIELD_MS = 30_000
|
||||
@@ -37,6 +44,7 @@ class _SessionPoll:
|
||||
terminated: bool = False
|
||||
stdin_closed: bool = False
|
||||
truncated_chars: int = 0
|
||||
analysis: VerificationAnalysis | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -147,7 +155,19 @@ class _ExecSession:
|
||||
output = "".join(self._chunks)
|
||||
self._chunks.clear()
|
||||
|
||||
output, truncated = _truncate_output(output, max_output_chars)
|
||||
analysis = analyze_verification_result(
|
||||
command=self.command,
|
||||
output=output,
|
||||
exit_code=self.process.returncode,
|
||||
timed_out=self._timed_out,
|
||||
)
|
||||
output, truncated = _truncate_output(
|
||||
output,
|
||||
max_output_chars,
|
||||
analysis=analysis,
|
||||
exit_code=self.process.returncode,
|
||||
elapsed_s=max(0.0, time.monotonic() - self.started_at),
|
||||
)
|
||||
return _SessionPoll(
|
||||
output=output,
|
||||
done=self.process.returncode is not None,
|
||||
@@ -157,6 +177,7 @@ class _ExecSession:
|
||||
terminated=terminated,
|
||||
stdin_closed=stdin_closed,
|
||||
truncated_chars=truncated,
|
||||
analysis=analysis,
|
||||
)
|
||||
|
||||
async def kill(self) -> None:
|
||||
@@ -320,15 +341,33 @@ def clamp_session_int(value: int | None, default: int, minimum: int, maximum: in
|
||||
return min(max(value, minimum), maximum)
|
||||
|
||||
|
||||
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
|
||||
def _truncate_output(
|
||||
output: str,
|
||||
max_output_chars: int,
|
||||
*,
|
||||
analysis: VerificationAnalysis | None = None,
|
||||
exit_code: int | None = None,
|
||||
elapsed_s: float | None = None,
|
||||
) -> tuple[str, int]:
|
||||
if len(output) <= max_output_chars:
|
||||
return output, 0
|
||||
half = max_output_chars // 2
|
||||
omitted = len(output) - max_output_chars
|
||||
return (
|
||||
output[:half]
|
||||
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
|
||||
+ output[-half:],
|
||||
build_structured_output_summary(
|
||||
"[tool output truncated]",
|
||||
output,
|
||||
max_chars=max_output_chars,
|
||||
metadata=[
|
||||
("original_size_chars", len(output)),
|
||||
("exit_code", exit_code if exit_code is not None else "running"),
|
||||
("elapsed_s", f"{elapsed_s:.1f}" if elapsed_s is not None else "unknown"),
|
||||
],
|
||||
analysis=analysis,
|
||||
guidance=(
|
||||
"Use the structured summary first. Poll again for new output "
|
||||
"or rerun a narrower command instead of reading broad logs."
|
||||
),
|
||||
),
|
||||
omitted,
|
||||
)
|
||||
|
||||
@@ -351,6 +390,20 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
||||
return "\n".join(parts) if parts else "(no output yet)"
|
||||
|
||||
|
||||
def _format_poll_with_verification(session_id: str, poll: _SessionPoll) -> str:
|
||||
result = format_session_poll(session_id, poll)
|
||||
if not poll.done:
|
||||
return result
|
||||
analysis = poll.analysis or analyze_verification_result(
|
||||
command="",
|
||||
output=result,
|
||||
exit_code=poll.exit_code,
|
||||
timed_out=poll.timed_out,
|
||||
)
|
||||
record_verification_observation(current_request_session_key(), analysis)
|
||||
return append_verification_feedback(result, analysis)
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
tool_parameters_schema(
|
||||
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
|
||||
@@ -492,7 +545,7 @@ class WriteStdinTool(Tool):
|
||||
max_output_chars=output_limit,
|
||||
owner_session_key=current_request_session_key(),
|
||||
)
|
||||
return format_session_poll(session_id, poll)
|
||||
return _format_poll_with_verification(session_id, poll)
|
||||
except KeyError:
|
||||
return f"Error: exec session not found: {session_id}"
|
||||
except Exception as exc:
|
||||
@@ -532,10 +585,10 @@ class WriteStdinTool(Tool):
|
||||
joined = "".join(aggregate)
|
||||
if wait_for in joined:
|
||||
poll.output = joined
|
||||
return format_session_poll(session_id, poll)
|
||||
return _format_poll_with_verification(session_id, poll)
|
||||
if poll.done or remaining_ms <= 0:
|
||||
poll.output = "".join(aggregate)
|
||||
result = format_session_poll(session_id, poll)
|
||||
result = _format_poll_with_verification(session_id, poll)
|
||||
if wait_for not in poll.output:
|
||||
result += f"\nWait target not observed: {wait_for!r}"
|
||||
return result
|
||||
|
||||
@@ -23,6 +23,11 @@ from typing import TYPE_CHECKING, Any
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||
from nanobot.agent.verification_state import (
|
||||
clear_verification_observation,
|
||||
format_completion_gate_message,
|
||||
latest_verification_observation,
|
||||
)
|
||||
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
||||
from nanobot.session.goal_state import (
|
||||
GOAL_STATE_KEY,
|
||||
@@ -187,6 +192,29 @@ class LongTaskTool(Tool, _GoalToolsMixin):
|
||||
max_length=8000,
|
||||
nullable=True,
|
||||
),
|
||||
verification_summary=StringSchema(
|
||||
"For coding or file-producing tasks, summarize how the work was verified. "
|
||||
"Mention the most relevant test/check command and whether it passed. "
|
||||
"If no verification was possible, say why.",
|
||||
max_length=4000,
|
||||
nullable=True,
|
||||
),
|
||||
commands_run=StringSchema(
|
||||
"Optional concise list of verification/build commands run before completion.",
|
||||
max_length=4000,
|
||||
nullable=True,
|
||||
),
|
||||
artifacts_created=StringSchema(
|
||||
"Optional concise list of files, outputs, or artifacts created.",
|
||||
max_length=4000,
|
||||
nullable=True,
|
||||
),
|
||||
remaining_failures=StringSchema(
|
||||
"Known unresolved failures, if intentionally stopping before success. "
|
||||
"Leave empty when verification passes.",
|
||||
max_length=4000,
|
||||
nullable=True,
|
||||
),
|
||||
required=[],
|
||||
)
|
||||
)
|
||||
@@ -222,30 +250,67 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
|
||||
return (
|
||||
"End bookkeeping for the active sustained goal. "
|
||||
"Use when the objective is fully achieved and verified—recap what was delivered. "
|
||||
"For coding/file-producing tasks, run the smallest reliable verification first and include "
|
||||
"verification_summary / commands_run / artifacts_created. "
|
||||
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
|
||||
"what actually happened (not necessarily success). "
|
||||
"If recent verification failed and no later verification passed, this tool will ask you to "
|
||||
"continue fixing unless remaining_failures describes an intentional incomplete stop. "
|
||||
"If no goal is active, the tool reports that and leaves metadata unchanged."
|
||||
)
|
||||
|
||||
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
|
||||
async def execute(
|
||||
self,
|
||||
recap: str | None = None,
|
||||
verification_summary: str | None = None,
|
||||
commands_run: str | None = None,
|
||||
artifacts_created: str | None = None,
|
||||
remaining_failures: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
sess = self._session()
|
||||
if sess is None:
|
||||
return "Error: complete_goal requires an active chat session."
|
||||
|
||||
session_key = self._request_ctx.get().session_key if self._request_ctx.get() else None
|
||||
observation = latest_verification_observation(session_key)
|
||||
if (
|
||||
observation is not None
|
||||
and observation.analysis.status == "failed"
|
||||
and not _has_meaningful_remaining_failures(remaining_failures)
|
||||
):
|
||||
return format_completion_gate_message(observation)
|
||||
|
||||
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."
|
||||
|
||||
ended = _iso_now()
|
||||
sess.metadata[GOAL_STATE_KEY] = {
|
||||
completed = {
|
||||
**prior,
|
||||
"status": "completed",
|
||||
"completed_at": ended,
|
||||
"recap": (recap or "").strip(),
|
||||
}
|
||||
if verification_summary:
|
||||
completed["verification_summary"] = verification_summary.strip()
|
||||
if commands_run:
|
||||
completed["commands_run"] = commands_run.strip()
|
||||
if artifacts_created:
|
||||
completed["artifacts_created"] = artifacts_created.strip()
|
||||
if remaining_failures:
|
||||
completed["remaining_failures"] = remaining_failures.strip()
|
||||
sess.metadata[GOAL_STATE_KEY] = completed
|
||||
discard_legacy_goal_state_key(sess.metadata)
|
||||
self._sessions.save(sess)
|
||||
clear_verification_observation(session_key)
|
||||
await self._publish_goal_state_changed(sess.metadata)
|
||||
tail = (recap or "").strip()
|
||||
if tail:
|
||||
return f"Goal marked complete ({ended}). Recap:\n{tail}"
|
||||
return f"Goal marked complete ({ended})."
|
||||
|
||||
|
||||
def _has_meaningful_remaining_failures(value: str | None) -> bool:
|
||||
text = (value or "").strip().lower()
|
||||
return bool(text and text not in {"none", "no", "n/a", "na", "no remaining failures"})
|
||||
|
||||
+47
-21
@@ -797,31 +797,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
|
||||
|
||||
+161
-27
@@ -6,14 +6,17 @@ import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.context import current_request_session_key
|
||||
@@ -33,12 +36,19 @@ from nanobot.agent.tools.schema import (
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.agent.verification_state import (
|
||||
analyze_verification_result,
|
||||
append_verification_feedback,
|
||||
record_verification_observation,
|
||||
)
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config_base import Base
|
||||
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
|
||||
from nanobot.security.workspace_policy import is_path_within
|
||||
from nanobot.utils.helpers import build_structured_output_summary
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
_DETACHED_EXIT_GRACE_S = 1.0 if _IS_WINDOWS else 0.2
|
||||
|
||||
|
||||
# Policy note appended to recoverable workspace-boundary guard errors.
|
||||
@@ -55,6 +65,13 @@ class ExecToolConfig(Base):
|
||||
"""Shell exec tool configuration."""
|
||||
enable: bool = True
|
||||
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
||||
allow_local_service_access: bool = Field(
|
||||
default=False,
|
||||
validation_alias=AliasChoices(
|
||||
"allowLocalServiceAccess",
|
||||
"allow_local_service_access",
|
||||
),
|
||||
) # allow shell commands to reach literal localhost/loopback services
|
||||
path_prepend: str = ""
|
||||
path_append: str = ""
|
||||
sandbox: str = ""
|
||||
@@ -93,8 +110,8 @@ class _PreparedCommand:
|
||||
nullable=True,
|
||||
),
|
||||
login=BooleanSchema(
|
||||
description="Whether to run bash/zsh with login shell semantics (default true).",
|
||||
default=True,
|
||||
description="Whether to run bash/zsh with login shell semantics (default false).",
|
||||
default=False,
|
||||
nullable=True,
|
||||
),
|
||||
yield_time_ms=IntegerSchema(
|
||||
@@ -126,6 +143,16 @@ class _PreparedCommand:
|
||||
maximum=MAX_OUTPUT_CHARS,
|
||||
nullable=True,
|
||||
),
|
||||
detach=BooleanSchema(
|
||||
description=(
|
||||
"Run the command as a detached background process that can "
|
||||
"survive after the agent finishes. Use for local servers, "
|
||||
"dev servers, mock APIs, or other services that must remain "
|
||||
"available for later commands or external verification."
|
||||
),
|
||||
default=False,
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
class ExecTool(Tool):
|
||||
@@ -149,6 +176,7 @@ class ExecTool(Tool):
|
||||
working_dir=ctx.workspace,
|
||||
timeout=cfg.timeout,
|
||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||
allow_local_service_access=cfg.allow_local_service_access,
|
||||
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
|
||||
sandbox=cfg.sandbox,
|
||||
path_prepend=cfg.path_prepend,
|
||||
@@ -165,6 +193,7 @@ class ExecTool(Tool):
|
||||
deny_patterns: list[str] | None = None,
|
||||
allow_patterns: list[str] | None = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
allow_local_service_access: bool = False,
|
||||
webui_allow_local_service_access: bool = True,
|
||||
allow_local_preview_access: bool | None = None,
|
||||
sandbox: str = "",
|
||||
@@ -197,6 +226,7 @@ class ExecTool(Tool):
|
||||
]
|
||||
self.allow_patterns = allow_patterns or []
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.allow_local_service_access = allow_local_service_access
|
||||
if allow_local_preview_access is not None:
|
||||
webui_allow_local_service_access = allow_local_preview_access
|
||||
self.webui_allow_local_service_access = webui_allow_local_service_access
|
||||
@@ -236,8 +266,11 @@ class ExecTool(Tool):
|
||||
"Use -y or --yes flags to avoid interactive prompts. "
|
||||
"For long-running or interactive commands, pass yield_time_ms; "
|
||||
"if the command keeps running, exec returns a session_id that can "
|
||||
"be polled or written to with write_stdin. Output is truncated at "
|
||||
"10 000 chars; timeout defaults to 60s."
|
||||
"be polled or written to with write_stdin. For services that "
|
||||
"must remain available after you finish, pass detach=true instead "
|
||||
"of yield_time_ms; detached output is written to a log file and "
|
||||
"the tool returns a pid. Output is truncated at 10 000 chars; "
|
||||
"timeout defaults to 60s."
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -251,6 +284,7 @@ class ExecTool(Tool):
|
||||
login: bool | None = None, yield_time_ms: int | None = None,
|
||||
max_output_chars: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
detach: bool | None = False,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
command = command or cmd
|
||||
@@ -264,10 +298,14 @@ class ExecTool(Tool):
|
||||
if isinstance(prepared, str):
|
||||
return prepared
|
||||
|
||||
if detach:
|
||||
return await self._execute_detached(prepared)
|
||||
|
||||
if yield_time_ms is not None:
|
||||
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
||||
|
||||
try:
|
||||
started_at = time.monotonic()
|
||||
process = await self._spawn(
|
||||
prepared.command,
|
||||
prepared.cwd,
|
||||
@@ -283,7 +321,15 @@ class ExecTool(Tool):
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self._kill_process(process)
|
||||
return f"Error: Command timed out after {prepared.timeout} seconds"
|
||||
result = f"Error: Command timed out after {prepared.timeout} seconds"
|
||||
analysis = analyze_verification_result(
|
||||
command=prepared.command,
|
||||
output=result,
|
||||
exit_code=None,
|
||||
timed_out=True,
|
||||
)
|
||||
record_verification_observation(current_request_session_key(), analysis)
|
||||
return append_verification_feedback(result, analysis)
|
||||
except asyncio.CancelledError:
|
||||
await self._kill_process(process)
|
||||
raise
|
||||
@@ -301,17 +347,35 @@ class ExecTool(Tool):
|
||||
output_parts.append(f"\nExit code: {process.returncode}")
|
||||
|
||||
result = "\n".join(output_parts) if output_parts else "(no output)"
|
||||
elapsed_s = max(0.0, time.monotonic() - started_at)
|
||||
|
||||
analysis = analyze_verification_result(
|
||||
command=prepared.command,
|
||||
output=result,
|
||||
exit_code=process.returncode,
|
||||
)
|
||||
|
||||
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
|
||||
if len(result) > max_len:
|
||||
half = max_len // 2
|
||||
result = (
|
||||
result[:half]
|
||||
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
|
||||
+ result[-half:]
|
||||
result = build_structured_output_summary(
|
||||
"[tool output truncated]",
|
||||
result,
|
||||
max_chars=max_len,
|
||||
metadata=[
|
||||
("original_size_chars", len(result)),
|
||||
("exit_code", process.returncode),
|
||||
("duration_s", f"{elapsed_s:.1f}"),
|
||||
],
|
||||
analysis=analysis,
|
||||
guidance=(
|
||||
"Use the structured summary first. Rerun a narrower "
|
||||
"command, grep a specific failure, or inspect the "
|
||||
"named artifact instead of rerunning broad noisy logs."
|
||||
),
|
||||
)
|
||||
|
||||
return result
|
||||
record_verification_observation(current_request_session_key(), analysis)
|
||||
return append_verification_feedback(result, analysis)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
@@ -339,10 +403,71 @@ class ExecTool(Tool):
|
||||
MAX_OUTPUT_CHARS,
|
||||
),
|
||||
)
|
||||
return format_session_poll(session_id, poll)
|
||||
result = format_session_poll(session_id, poll)
|
||||
if poll.done:
|
||||
analysis = analyze_verification_result(
|
||||
command=prepared.command,
|
||||
output=result,
|
||||
exit_code=poll.exit_code,
|
||||
timed_out=poll.timed_out,
|
||||
)
|
||||
record_verification_observation(current_request_session_key(), analysis)
|
||||
return append_verification_feedback(result, analysis)
|
||||
return result
|
||||
except Exception as exc:
|
||||
return f"Error executing command: {exc}"
|
||||
|
||||
async def _execute_detached(self, prepared: _PreparedCommand) -> str:
|
||||
log_dir = Path(prepared.cwd) / ".nanobot" / "exec-logs"
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / f"detached-{uuid.uuid4().hex[:12]}.log"
|
||||
except Exception as exc:
|
||||
return f"Error preparing detached command log directory: {exc}"
|
||||
|
||||
log_handle = None
|
||||
try:
|
||||
log_handle = open(log_path, "ab", buffering=0)
|
||||
process = await self._spawn(
|
||||
prepared.command,
|
||||
prepared.cwd,
|
||||
prepared.env,
|
||||
prepared.shell_program,
|
||||
prepared.login,
|
||||
stdout=log_handle,
|
||||
stderr=log_handle,
|
||||
start_new_session=not _IS_WINDOWS,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if _IS_WINDOWS else 0,
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"Error starting detached command: {exc}"
|
||||
finally:
|
||||
if log_handle is not None:
|
||||
with suppress(Exception):
|
||||
log_handle.close()
|
||||
|
||||
try:
|
||||
exit_code = await asyncio.wait_for(process.wait(), timeout=_DETACHED_EXIT_GRACE_S)
|
||||
except asyncio.TimeoutError:
|
||||
return (
|
||||
"Detached process started.\n"
|
||||
f"pid: {process.pid}\n"
|
||||
f"cwd: {prepared.cwd}\n"
|
||||
f"log: {log_path}\n"
|
||||
"Poll the log or run a health check to verify the service is ready."
|
||||
)
|
||||
|
||||
log_text = ""
|
||||
with suppress(Exception):
|
||||
log_text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
if len(log_text) > 4000:
|
||||
log_text = log_text[-4000:]
|
||||
return (
|
||||
f"Detached process exited immediately with code {exit_code}.\n"
|
||||
f"log: {log_path}\n"
|
||||
f"{log_text}"
|
||||
)
|
||||
|
||||
def _resolve_timeout(self, timeout: int | None) -> int | None:
|
||||
"""Resolve the effective hard timeout in seconds (None = no limit).
|
||||
|
||||
@@ -432,7 +557,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,9 +586,13 @@ 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,
|
||||
stdout: Any = asyncio.subprocess.PIPE,
|
||||
stderr: Any = asyncio.subprocess.PIPE,
|
||||
start_new_session: bool = False,
|
||||
creationflags: int = 0,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Launch *command* in a platform-appropriate shell."""
|
||||
if _IS_WINDOWS:
|
||||
@@ -471,18 +600,20 @@ class ExecTool(Tool):
|
||||
return await asyncio.create_subprocess_exec(
|
||||
"powershell", "-NoProfile", "-Command", command,
|
||||
stdin=stdin,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
return await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdin=stdin,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||
args = [shell_program]
|
||||
@@ -493,10 +624,11 @@ class ExecTool(Tool):
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdin=stdin,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
start_new_session=start_new_session,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -541,8 +673,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,7 +735,7 @@ 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:
|
||||
@@ -613,11 +746,12 @@ class ExecTool(Tool):
|
||||
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
||||
|
||||
from nanobot.security.network import contains_internal_url
|
||||
allow_loopback = self.allow_local_service_access or current_scope_allows_loopback(
|
||||
enabled=self.webui_allow_local_service_access,
|
||||
)
|
||||
if contains_internal_url(
|
||||
cmd,
|
||||
allow_loopback=current_scope_allows_loopback(
|
||||
enabled=self.webui_allow_local_service_access,
|
||||
),
|
||||
allow_loopback=allow_loopback,
|
||||
):
|
||||
# The runner turns this marker into a non-retryable security hint.
|
||||
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Lightweight verification-result detection for coding workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
VerificationStatus = Literal["passed", "failed"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerificationAnalysis:
|
||||
"""Structured summary of a command that appears to be verification."""
|
||||
|
||||
status: VerificationStatus
|
||||
command: str
|
||||
exit_code: int | None
|
||||
failed_tests: tuple[str, ...] = ()
|
||||
primary_errors: tuple[str, ...] = ()
|
||||
missing_artifacts: tuple[str, ...] = ()
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerificationObservation:
|
||||
"""Latest verification signal observed for a session."""
|
||||
|
||||
analysis: VerificationAnalysis
|
||||
sequence: int
|
||||
|
||||
|
||||
_OBSERVATIONS: dict[str, VerificationObservation] = {}
|
||||
_SEQUENCE = 0
|
||||
|
||||
_TEST_COMMAND_RE = re.compile(
|
||||
r"(?ix)"
|
||||
r"("
|
||||
r"\bpytest\b|\bpy\.test\b|\bunittest\b|\bnosetests\b|"
|
||||
r"\btest_outputs\.py\b|\brun_tests?(?:\.sh|\.py)?\b|"
|
||||
r"\bnpm\s+(?:run\s+)?test\b|\byarn\s+test\b|\bpnpm\s+test\b|"
|
||||
r"\bcargo\s+test\b|\bgo\s+test\b|\bctest\b|"
|
||||
r"\bmake\s+(?:[^;&|]*\s+)?test\b"
|
||||
r")"
|
||||
)
|
||||
_ARTIFACT_CHECK_COMMAND_RE = re.compile(
|
||||
r"(?ix)"
|
||||
r"("
|
||||
r"\bcmp\b|"
|
||||
r"\bdiff\b|"
|
||||
r"\bsha(?:1|224|256|384|512)?sum\b|"
|
||||
r"\bmd5sum\b|"
|
||||
r"\bgcc\b.*(?:&&|;).*\./|"
|
||||
r"\bclang\b.*(?:&&|;).*\./|"
|
||||
r"\bpython3?\b.*<<['\"]?PY\b.*\bassert\b"
|
||||
r")"
|
||||
)
|
||||
_COMPARISON_COMMAND_RE = re.compile(r"(?i)\b(?:cmp|diff)\b")
|
||||
_FAILURE_RE = re.compile(
|
||||
r"(?im)"
|
||||
r"("
|
||||
r"^FAILED\s+|"
|
||||
r"\b\d+\s+failed\b|"
|
||||
r"\bAssertionError\b|"
|
||||
r"\bFileNotFoundError\b|"
|
||||
r"\bTimeoutError\b|"
|
||||
r"\bcommand not found\b|"
|
||||
r"\bError:\s+Command timed out\b|"
|
||||
r"\bFAILURES?\b|"
|
||||
r"\bTEST FAILED\b"
|
||||
r")"
|
||||
)
|
||||
_SUCCESS_RE = re.compile(
|
||||
r"(?im)"
|
||||
r"("
|
||||
r"\b\d+\s+passed\b|"
|
||||
r"\bOK\b|"
|
||||
r"\bTEST PASSED\b|"
|
||||
r"\bExit code:\s*0\b"
|
||||
r")"
|
||||
)
|
||||
_ARTIFACT_SUCCESS_RE = re.compile(
|
||||
r"(?im)"
|
||||
r"("
|
||||
r"\b(?:cmp|diff|test|verify)_exit:\s*0\b|"
|
||||
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*0\s*$"
|
||||
r")"
|
||||
)
|
||||
_ARTIFACT_FAILURE_RE = re.compile(
|
||||
r"(?im)"
|
||||
r"("
|
||||
r"\b(?:cmp|diff|test|verify)_exit:\s*[1-9]\d*\b|"
|
||||
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*[1-9]\d*\s*$"
|
||||
r")"
|
||||
)
|
||||
_FAILED_TEST_RE = re.compile(r"(?m)^FAILED\s+([^\s]+)")
|
||||
_PYTEST_SHORT_RE = re.compile(r"(?m)^_{3,}\s+([A-Za-z0-9_./:-]+)\s+_{3,}$")
|
||||
_ERROR_LINE_RE = re.compile(
|
||||
r"(?m)"
|
||||
r"^\s*(?:E\s+)?("
|
||||
r"(?:AssertionError|FileNotFoundError|TimeoutError|ValueError|TypeError|RuntimeError)"
|
||||
r"(?::[^\n]*)?|"
|
||||
r"assert\s+[^\n]+|"
|
||||
r"[^:\n]+:\s+line\s+\d+:\s+[^:\n]+:\s+command not found|"
|
||||
r"Error:\s+[^\n]+|"
|
||||
r"TEST FAILED[^\n]*"
|
||||
r")"
|
||||
)
|
||||
_MISSING_PATH_RE = re.compile(
|
||||
r"(?i)"
|
||||
r"(?:No such file or directory:\s*['\"]([^'\"]+)['\"]|"
|
||||
r"(?:file|path)\s+([^\s'\"]+)\s+does not exist|"
|
||||
r"cannot open file\s+['\"]([^'\"]+)['\"])"
|
||||
)
|
||||
|
||||
|
||||
def analyze_verification_result(
|
||||
*,
|
||||
command: str,
|
||||
output: str,
|
||||
exit_code: int | None,
|
||||
timed_out: bool = False,
|
||||
) -> VerificationAnalysis | None:
|
||||
"""Return a verification summary when a command/output looks like a test."""
|
||||
|
||||
command = " ".join((command or "").split())
|
||||
looks_like_test_command = bool(_TEST_COMMAND_RE.search(command))
|
||||
looks_like_artifact_check = bool(_ARTIFACT_CHECK_COMMAND_RE.search(command))
|
||||
looks_like_comparison_command = bool(_COMPARISON_COMMAND_RE.search(command))
|
||||
looks_like_verification = looks_like_test_command or looks_like_artifact_check
|
||||
failure_seen = bool(_FAILURE_RE.search(output))
|
||||
success_seen = bool(_SUCCESS_RE.search(output))
|
||||
artifact_success_seen = bool(_ARTIFACT_SUCCESS_RE.search(output)) and (
|
||||
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*0\b", output, flags=re.I))
|
||||
)
|
||||
artifact_failure_seen = bool(_ARTIFACT_FAILURE_RE.search(output)) and (
|
||||
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*[1-9]\d*\b", output, flags=re.I))
|
||||
)
|
||||
|
||||
if not looks_like_test_command and not failure_seen:
|
||||
if not (looks_like_artifact_check and artifact_success_seen and exit_code == 0):
|
||||
return None
|
||||
|
||||
if (
|
||||
(timed_out and looks_like_verification)
|
||||
or (exit_code not in (None, 0) and (looks_like_verification or failure_seen))
|
||||
or failure_seen
|
||||
or artifact_failure_seen
|
||||
):
|
||||
return VerificationAnalysis(
|
||||
status="failed",
|
||||
command=command,
|
||||
exit_code=exit_code,
|
||||
failed_tests=_unique(_FAILED_TEST_RE.findall(output), limit=8),
|
||||
primary_errors=_extract_primary_errors(output),
|
||||
missing_artifacts=_extract_missing_artifacts(output),
|
||||
timed_out=timed_out,
|
||||
)
|
||||
|
||||
if looks_like_test_command and exit_code == 0 and success_seen:
|
||||
return VerificationAnalysis(
|
||||
status="passed",
|
||||
command=command,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
if looks_like_artifact_check and exit_code == 0 and artifact_success_seen:
|
||||
return VerificationAnalysis(
|
||||
status="passed",
|
||||
command=command,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def append_verification_feedback(output: str, analysis: VerificationAnalysis | None) -> str:
|
||||
"""Append model-facing feedback for failed verification results."""
|
||||
|
||||
if analysis is None or analysis.status != "failed":
|
||||
return output
|
||||
|
||||
lines = [
|
||||
"",
|
||||
"[Verification Feedback]",
|
||||
"Verification status: failed.",
|
||||
"Do not call complete_goal or present the task as finished until this is fixed and a verification passes.",
|
||||
]
|
||||
if analysis.command:
|
||||
lines.append(f"Command: {analysis.command[:240]}")
|
||||
if analysis.exit_code is not None:
|
||||
lines.append(f"Exit code: {analysis.exit_code}")
|
||||
if analysis.timed_out:
|
||||
lines.append("Failure type: command timeout")
|
||||
if analysis.failed_tests:
|
||||
lines.append("Failed tests:")
|
||||
lines.extend(f"- {item}" for item in analysis.failed_tests)
|
||||
if analysis.primary_errors:
|
||||
lines.append("Primary errors:")
|
||||
lines.extend(f"- {item}" for item in analysis.primary_errors)
|
||||
if analysis.missing_artifacts:
|
||||
lines.append("Missing artifacts:")
|
||||
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
|
||||
lines.append("Next action: inspect the failing assertion, fix the implementation or artifact, then rerun the most specific verification command.")
|
||||
lines.append("[/Verification Feedback]")
|
||||
return output.rstrip() + "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def record_verification_observation(session_key: str | None, analysis: VerificationAnalysis | None) -> None:
|
||||
"""Remember the latest verification signal for a session."""
|
||||
|
||||
if not session_key or analysis is None:
|
||||
return
|
||||
global _SEQUENCE
|
||||
_SEQUENCE += 1
|
||||
_OBSERVATIONS[session_key] = VerificationObservation(
|
||||
analysis=analysis,
|
||||
sequence=_SEQUENCE,
|
||||
)
|
||||
|
||||
|
||||
def latest_verification_observation(session_key: str | None) -> VerificationObservation | None:
|
||||
if not session_key:
|
||||
return None
|
||||
return _OBSERVATIONS.get(session_key)
|
||||
|
||||
|
||||
def clear_verification_observation(session_key: str | None) -> None:
|
||||
if session_key:
|
||||
_OBSERVATIONS.pop(session_key, None)
|
||||
|
||||
|
||||
def format_completion_gate_message(observation: VerificationObservation) -> str:
|
||||
"""Build the complete_goal soft-gate message for unresolved failures."""
|
||||
|
||||
analysis = observation.analysis
|
||||
lines = [
|
||||
"Recent verification appears to have failed, so the goal is not marked complete yet.",
|
||||
"Continue fixing the task and rerun verification before completing.",
|
||||
]
|
||||
if analysis.command:
|
||||
lines.append(f"Last failed verification command: {analysis.command[:240]}")
|
||||
if analysis.failed_tests:
|
||||
lines.append("Failed tests: " + ", ".join(analysis.failed_tests[:5]))
|
||||
if analysis.primary_errors:
|
||||
lines.append("Primary error: " + analysis.primary_errors[0])
|
||||
if analysis.missing_artifacts:
|
||||
lines.append("Missing artifact: " + analysis.missing_artifacts[0])
|
||||
lines.append(
|
||||
"If you are intentionally stopping with known failures, call complete_goal again with remaining_failures describing them honestly."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _extract_primary_errors(output: str) -> tuple[str, ...]:
|
||||
candidates: list[str] = []
|
||||
for match in _ERROR_LINE_RE.findall(output):
|
||||
text = " ".join(match.split())
|
||||
if text and text not in candidates:
|
||||
candidates.append(text[:240])
|
||||
if len(candidates) >= 8:
|
||||
break
|
||||
if not candidates:
|
||||
for match in _PYTEST_SHORT_RE.findall(output):
|
||||
text = " ".join(match.split())
|
||||
if text and text not in candidates:
|
||||
candidates.append(text[:240])
|
||||
if len(candidates) >= 4:
|
||||
break
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _extract_missing_artifacts(output: str) -> tuple[str, ...]:
|
||||
paths: list[str] = []
|
||||
for groups in _MISSING_PATH_RE.findall(output):
|
||||
path = next((item for item in groups if item), "")
|
||||
if path and path not in paths:
|
||||
paths.append(path[:240])
|
||||
if len(paths) >= 8:
|
||||
break
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
def _unique(items: list[str], *, limit: int) -> tuple[str, ...]:
|
||||
out: list[str] = []
|
||||
for item in items:
|
||||
text = " ".join(item.split())
|
||||
if text and text not in out:
|
||||
out.append(text[:240])
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return tuple(out)
|
||||
@@ -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: {}...",
|
||||
|
||||
@@ -199,6 +199,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:
|
||||
|
||||
@@ -396,7 +396,7 @@ class ChannelManager:
|
||||
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_delta messages 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,7 +404,8 @@ class ChannelManager:
|
||||
Returns:
|
||||
tuple of (merged_message, list_of_non_matching_messages)
|
||||
"""
|
||||
target_key = (first_msg.channel, first_msg.chat_id)
|
||||
first_metadata = first_msg.metadata or {}
|
||||
target_key = (first_msg.channel, first_msg.chat_id, first_metadata.get("_stream_id"))
|
||||
combined_content = first_msg.content
|
||||
final_metadata = dict(first_msg.metadata or {})
|
||||
non_matching: list[OutboundMessage] = []
|
||||
@@ -418,9 +419,14 @@ 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_metadata = next_msg.metadata or {}
|
||||
same_target = (
|
||||
next_msg.channel,
|
||||
next_msg.chat_id,
|
||||
next_metadata.get("_stream_id"),
|
||||
) == target_key
|
||||
is_delta = next_metadata.get("_stream_delta")
|
||||
is_end = next_metadata.get("_stream_end")
|
||||
|
||||
if same_target and is_delta and not final_metadata.get("_stream_end"):
|
||||
# Accumulate content
|
||||
|
||||
@@ -351,6 +351,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"
|
||||
@@ -803,6 +805,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(
|
||||
@@ -907,8 +910,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}
|
||||
|
||||
+589
-329
File diff suppressed because it is too large
Load Diff
+38
-12
@@ -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
|
||||
@@ -61,6 +61,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 +154,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 +217,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1064,17 +1094,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 +1171,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:
|
||||
|
||||
+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
|
||||
|
||||
@@ -626,7 +626,7 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
|
||||
|
||||
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
|
||||
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap plus verification_summary / commands_run / artifacts_created when applicable. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
|
||||
|
||||
Goal:
|
||||
{goal}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
@@ -179,6 +183,29 @@ 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)
|
||||
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 +244,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 +263,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):
|
||||
@@ -301,7 +331,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:
|
||||
|
||||
+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,
|
||||
))
|
||||
|
||||
@@ -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,7 +54,7 @@ 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"
|
||||
@@ -89,7 +99,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,7 +119,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),
|
||||
spec=spec,
|
||||
extra_body=p.extra_body if p else None,
|
||||
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
||||
@@ -191,13 +201,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,
|
||||
@@ -209,13 +220,14 @@ def provider_signature(
|
||||
fallback.context_window_tokens,
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
@@ -26,6 +27,25 @@ from nanobot.providers.openai_responses import (
|
||||
|
||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
DEFAULT_ORIGINATOR = "nanobot"
|
||||
_RESPONSE_FAILED_PREFIX = "Response failed:"
|
||||
_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
|
||||
"overloaded",
|
||||
"overloaded_error",
|
||||
"rate_limit_exceeded",
|
||||
"request_limit_exceeded",
|
||||
"requests_limit_exceeded",
|
||||
"server_error",
|
||||
"server_is_overloaded",
|
||||
"service_unavailable",
|
||||
"temporarily_unavailable",
|
||||
"too_many_requests",
|
||||
})
|
||||
_NON_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
|
||||
"content_filter",
|
||||
"content_policy_violation",
|
||||
"cyber_policy",
|
||||
"safety_violation",
|
||||
})
|
||||
|
||||
|
||||
class OpenAICodexProvider(LLMProvider):
|
||||
@@ -246,6 +266,8 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
||||
|
||||
status_code = getattr(exc, "status_code", None)
|
||||
error_kind: str | None = None
|
||||
error_type = getattr(exc, "error_type", None)
|
||||
error_code = getattr(exc, "error_code", None)
|
||||
default_detail: str | None = None
|
||||
should_retry: bool | None = getattr(exc, "should_retry", None)
|
||||
|
||||
@@ -265,12 +287,20 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
||||
error_kind = "http"
|
||||
default_detail = "HTTP request failed"
|
||||
|
||||
failed_type, failed_code = _extract_response_failed_error(detail)
|
||||
if failed_type or failed_code:
|
||||
error_kind = error_kind or "provider"
|
||||
error_type = failed_type or error_type
|
||||
error_code = failed_code or error_code
|
||||
if should_retry is None:
|
||||
should_retry = _should_retry_response_failed(error_type, error_code, detail)
|
||||
|
||||
if status_code is not None and should_retry is None:
|
||||
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
||||
should_retry = _should_retry_status(
|
||||
int(status_code),
|
||||
getattr(exc, "error_type", None),
|
||||
getattr(exc, "error_code", None),
|
||||
error_type,
|
||||
error_code,
|
||||
retry_content,
|
||||
)
|
||||
|
||||
@@ -283,13 +313,56 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
||||
retry_after=retry_after,
|
||||
error_status_code=int(status_code) if status_code is not None else None,
|
||||
error_kind=error_kind,
|
||||
error_type=getattr(exc, "error_type", None),
|
||||
error_code=getattr(exc, "error_code", None),
|
||||
error_type=error_type,
|
||||
error_code=error_code,
|
||||
error_retry_after_s=retry_after,
|
||||
error_should_retry=should_retry,
|
||||
)
|
||||
|
||||
|
||||
def _extract_response_failed_error(detail: str) -> tuple[str | None, str | None]:
|
||||
"""Extract provider semantic error fields from Responses SSE failures."""
|
||||
if _RESPONSE_FAILED_PREFIX not in detail:
|
||||
return None, None
|
||||
|
||||
payload = detail.split(_RESPONSE_FAILED_PREFIX, 1)[1].strip()
|
||||
if not payload:
|
||||
return None, None
|
||||
|
||||
parsed: Any = None
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
except Exception:
|
||||
try:
|
||||
parsed = ast.literal_eval(payload)
|
||||
except Exception:
|
||||
parsed = None
|
||||
|
||||
error_type, error_code = LLMProvider._extract_error_type_code(parsed or payload)
|
||||
return error_type, error_code
|
||||
|
||||
|
||||
def _should_retry_response_failed(
|
||||
error_type: str | None,
|
||||
error_code: str | None,
|
||||
detail: str,
|
||||
) -> bool | None:
|
||||
semantic_tokens = {
|
||||
token for token in (
|
||||
LLMProvider._normalize_error_token(error_type),
|
||||
LLMProvider._normalize_error_token(error_code),
|
||||
)
|
||||
if token is not None
|
||||
}
|
||||
if any(token in _NON_RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
|
||||
return False
|
||||
if any(token in _RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
|
||||
return True
|
||||
if LLMProvider._is_transient_error(detail):
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
|
||||
"""Return a bounded diagnostic summary without request body or raw upstream payload."""
|
||||
if response.error_status_code is not None:
|
||||
|
||||
@@ -1131,14 +1131,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,
|
||||
|
||||
@@ -37,8 +37,9 @@ class ProviderSpec:
|
||||
# "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 +177,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",
|
||||
@@ -391,6 +418,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 +628,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 +640,5 @@ def create_dynamic_spec(name: str) -> ProviderSpec:
|
||||
backend="openai_compat",
|
||||
is_direct=True,
|
||||
strip_model_prefixes=strip_prefixes,
|
||||
thinking_style=thinking_style,
|
||||
)
|
||||
|
||||
+73
-33
@@ -1,5 +1,6 @@
|
||||
"""Session management for conversation history."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -118,25 +119,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 = {
|
||||
@@ -153,7 +135,6 @@ class Session:
|
||||
max_messages: int = 120,
|
||||
*,
|
||||
max_tokens: int = 0,
|
||||
include_timestamps: bool = False,
|
||||
extend_to_user: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return unconsolidated messages for LLM input.
|
||||
@@ -243,8 +224,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
|
||||
@@ -425,14 +404,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 +475,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
|
||||
@@ -582,6 +615,7 @@ class SessionManager:
|
||||
the most recent writes.
|
||||
"""
|
||||
path = self._get_session_path(session.key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||
|
||||
try:
|
||||
@@ -645,7 +679,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 +844,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 +853,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,11 +882,12 @@ 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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Those belong to the execution phase after the marker is set.
|
||||
|
||||
- **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass.
|
||||
|
||||
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
|
||||
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). For coding or file-producing tasks, include **`verification_summary`**, **`commands_run`**, and **`artifacts_created`** when possible; if stopping with known unresolved issues, fill **`remaining_failures`** honestly. Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
|
||||
|
||||
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
|
||||
|
||||
@@ -68,7 +68,7 @@ Use this when the goal is to **build or reshape a codebase** (app, service, tool
|
||||
|
||||
1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact.
|
||||
2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs.
|
||||
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter.
|
||||
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter. Before `complete_goal`, run the smallest reliable verification you can and summarize it in `verification_summary`.
|
||||
|
||||
## Look things up instead of guessing
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+154
-45
@@ -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"
|
||||
]
|
||||
@@ -264,7 +290,8 @@ def current_time_str(timezone: str | None = None) -> str:
|
||||
|
||||
|
||||
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||
_TOOL_RESULT_PREVIEW_CHARS = 1200
|
||||
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS = 800
|
||||
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS = 80
|
||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
||||
_TOOL_RESULT_MAX_BUCKETS = 32
|
||||
@@ -378,22 +405,106 @@ def stringify_text_blocks(content: list[dict[str, Any]]) -> str | None:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _render_tool_result_reference(
|
||||
filepath: Path,
|
||||
def build_structured_output_summary(
|
||||
title: str,
|
||||
text: str,
|
||||
*,
|
||||
original_size: int,
|
||||
preview: str,
|
||||
truncated_preview: bool,
|
||||
max_chars: int,
|
||||
metadata: list[tuple[str, Any]] | None = None,
|
||||
analysis: Any | None = None,
|
||||
guidance: str | None = None,
|
||||
) -> str:
|
||||
result = (
|
||||
f"[tool output persisted]\n"
|
||||
f"Full output saved to: {filepath}\n"
|
||||
f"Original size: {original_size} chars\n"
|
||||
f"Preview:\n{preview}"
|
||||
"""Return a compact, structured head/tail summary for oversized tool output."""
|
||||
|
||||
if max_chars <= 0:
|
||||
return text
|
||||
edge_chars = min(
|
||||
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS,
|
||||
max(_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS, max_chars // 3),
|
||||
)
|
||||
while True:
|
||||
head = text[:edge_chars]
|
||||
if len(text) > edge_chars * 2:
|
||||
tail: str | None = text[-edge_chars:]
|
||||
omitted_middle_chars = len(text) - len(head) - len(tail)
|
||||
else:
|
||||
tail = None
|
||||
omitted_middle_chars = 0
|
||||
result = _render_structured_output_summary(
|
||||
title,
|
||||
metadata=metadata or [],
|
||||
guidance=guidance,
|
||||
analysis=analysis,
|
||||
head=head,
|
||||
tail=tail,
|
||||
omitted_middle_chars=omitted_middle_chars,
|
||||
)
|
||||
if len(result) <= max_chars or edge_chars <= _TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS:
|
||||
return truncate_text(result, max_chars)
|
||||
overflow = len(result) - max_chars
|
||||
edge_chars = max(
|
||||
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS,
|
||||
edge_chars - max(overflow // 2 + 1, 16),
|
||||
)
|
||||
|
||||
|
||||
def _render_structured_output_summary(
|
||||
title: str,
|
||||
*,
|
||||
metadata: list[tuple[str, Any]],
|
||||
guidance: str | None,
|
||||
analysis: Any | None,
|
||||
head: str,
|
||||
tail: str | None,
|
||||
omitted_middle_chars: int,
|
||||
) -> str:
|
||||
lines = [title]
|
||||
lines.extend(f"{key}: {value}" for key, value in metadata)
|
||||
if omitted_middle_chars:
|
||||
lines.append(f"truncation: {omitted_middle_chars:,} chars truncated from the middle")
|
||||
if guidance:
|
||||
lines.append(f"guidance: {guidance}")
|
||||
lines.extend(_verification_summary_lines(analysis))
|
||||
lines.extend(["head:", head])
|
||||
if tail is not None:
|
||||
lines.extend(["tail:", tail])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _verification_summary_lines(analysis: Any | None) -> list[str]:
|
||||
if analysis is None or getattr(analysis, "status", None) != "failed":
|
||||
return []
|
||||
lines = ["verification_status: failed"]
|
||||
if getattr(analysis, "timed_out", False):
|
||||
lines.append("failure_type: command timeout")
|
||||
if getattr(analysis, "failed_tests", ()):
|
||||
lines.append("failed_tests:")
|
||||
lines.extend(f"- {item}" for item in analysis.failed_tests)
|
||||
if getattr(analysis, "primary_errors", ()):
|
||||
lines.append("primary_errors:")
|
||||
lines.extend(f"- {item}" for item in analysis.primary_errors)
|
||||
if getattr(analysis, "missing_artifacts", ()):
|
||||
lines.append("missing_artifacts:")
|
||||
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
|
||||
return lines
|
||||
|
||||
|
||||
def _build_tool_result_reference(filepath: Path, text: str, *, max_chars: int) -> str:
|
||||
return build_structured_output_summary(
|
||||
"[tool output persisted]",
|
||||
text,
|
||||
max_chars=max_chars,
|
||||
metadata=[
|
||||
("tool_output_id", filepath.stem),
|
||||
("original_size_chars", len(text)),
|
||||
("storage", "internal audit artifact"),
|
||||
],
|
||||
guidance=(
|
||||
"Use this head/tail summary first. Avoid reading persisted "
|
||||
"tool-output files wholesale; rerun a narrower command when "
|
||||
"more detail is needed."
|
||||
),
|
||||
)
|
||||
if truncated_preview:
|
||||
result += "\n...\n(Read the saved file if you need the full output.)"
|
||||
return result
|
||||
|
||||
|
||||
def _bucket_mtime(path: Path) -> float:
|
||||
@@ -468,13 +579,7 @@ def maybe_persist_tool_result(
|
||||
else:
|
||||
_write_text_atomic(path, text_payload)
|
||||
|
||||
preview = text_payload[:_TOOL_RESULT_PREVIEW_CHARS]
|
||||
return _render_tool_result_reference(
|
||||
path,
|
||||
original_size=len(text_payload),
|
||||
preview=preview,
|
||||
truncated_preview=len(text_payload) > _TOOL_RESULT_PREVIEW_CHARS,
|
||||
)
|
||||
return _build_tool_result_reference(path, text_payload, max_chars=max_chars)
|
||||
|
||||
|
||||
def split_message(content: str, max_len: int = 2000) -> list[str]:
|
||||
@@ -520,7 +625,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
|
||||
|
||||
@@ -42,6 +42,27 @@ SUSTAINED_GOAL_CONTINUE_PROMPT = (
|
||||
"objective using your tools, or call complete_goal if the work is truly finished."
|
||||
)
|
||||
|
||||
RUNTIME_BUDGET_CONVERGENCE_PROMPT = """\
|
||||
[Runtime Budget Notice]
|
||||
You have used {used_iterations} of {max_iterations} model/tool iterations for this turn. \
|
||||
{remaining_iterations} iteration(s) remain before NanoBot must finalize without more tools.
|
||||
|
||||
Switch to convergence mode: stop broad exploration, choose the smallest high-signal command or edit, \
|
||||
verify the likely solution, and preserve enough budget for a final answer. For coding or \
|
||||
file-producing tasks, do not mark the work complete until the smallest reliable verification passes, \
|
||||
or clearly state remaining failures.
|
||||
[/Runtime Budget Notice]"""
|
||||
|
||||
RUNTIME_BUDGET_FINAL_PROMPT = """\
|
||||
[Runtime Budget Notice]
|
||||
Only {remaining_iterations} of {max_iterations} model/tool iteration(s) remain before NanoBot must \
|
||||
finalize without more tools.
|
||||
|
||||
Finalize the solution path now: avoid new broad searches or builds unless essential, make the \
|
||||
smallest final fix or artifact, run one targeted verification if possible, then answer honestly with \
|
||||
the evidence or remaining failures.
|
||||
[/Runtime Budget Notice]"""
|
||||
|
||||
|
||||
def empty_tool_result_message(tool_name: str) -> str:
|
||||
"""Short prompt-safe marker for tools that completed without visible output."""
|
||||
@@ -88,6 +109,25 @@ def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
|
||||
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
||||
|
||||
|
||||
def build_runtime_budget_notice_message(
|
||||
*,
|
||||
level: int,
|
||||
max_iterations: int,
|
||||
used_iterations: int,
|
||||
remaining_iterations: int,
|
||||
) -> dict[str, str]:
|
||||
"""Prompt the model to converge as the generic tool-iteration budget runs low."""
|
||||
template = RUNTIME_BUDGET_FINAL_PROMPT if level >= 2 else RUNTIME_BUDGET_CONVERGENCE_PROMPT
|
||||
return {
|
||||
"role": "user",
|
||||
"content": template.format(
|
||||
max_iterations=max_iterations,
|
||||
used_iterations=used_iterations,
|
||||
remaining_iterations=remaining_iterations,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
||||
"""Stable signature for repeated external lookups we want to throttle."""
|
||||
if not isinstance(arguments, dict):
|
||||
|
||||
@@ -232,7 +232,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()
|
||||
@@ -269,13 +270,19 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
||||
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": _latest_updated_at(updated_at_s, activity_updated_at),
|
||||
"title": _metadata_title(data.get("metadata", {})),
|
||||
"preview": preview or fallback_preview,
|
||||
"file": path.name,
|
||||
|
||||
@@ -16,6 +16,7 @@ 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,
|
||||
@@ -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
|
||||
}
|
||||
@@ -370,7 +359,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
|
||||
|
||||
|
||||
@@ -750,7 +739,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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
+5
-4
@@ -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__",
|
||||
|
||||
@@ -223,7 +223,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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1222,11 +1222,9 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
||||
non_system = [m for m in seen["initial_messages"] if m.get("role") != "system"]
|
||||
assert "question" in non_system[0]["content"]
|
||||
assert "working" in non_system[1]["content"]
|
||||
# User turns carry the timestamp prefix so the model can reason about
|
||||
# relative time. Assistant turns do NOT, otherwise the model treats those
|
||||
# past replies as in-context examples and starts its own outputs with
|
||||
# ``[Message Time: ...]`` (which then leaks back to the user).
|
||||
assert "[Message Time:" in non_system[0]["content"]
|
||||
# Persisted timestamps stay in session records, but replay content is not
|
||||
# rewritten with volatile ``[Message Time: ...]`` prefixes.
|
||||
assert "[Message Time:" not in non_system[0]["content"]
|
||||
assert "[Message Time:" not in non_system[1]["content"]
|
||||
assert non_system[2]["content"].count("subagent result") == 1
|
||||
assert "Current Time:" in non_system[2]["content"]
|
||||
|
||||
@@ -330,6 +330,27 @@ class TestDreamCursor:
|
||||
def test_initial_cursor_is_zero(self, store):
|
||||
assert store.get_last_dream_cursor() == 0
|
||||
|
||||
def test_returns_zero_when_empty(self, store):
|
||||
assert store.get_latest_cursor() == 0
|
||||
|
||||
def test_returns_cursor_of_last_entry(self, store):
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
store.append_history("event 3")
|
||||
|
||||
assert store.get_latest_cursor() == 3
|
||||
|
||||
def test_returns_zero_when_no_entries(self, store):
|
||||
store.history_file.write_text("", encoding="utf-8")
|
||||
|
||||
assert store.get_latest_cursor() == 0
|
||||
|
||||
def test_matches_next_cursor_minus_one(self, store):
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
|
||||
assert store.get_latest_cursor() == max(store._next_cursor() - 1, 0)
|
||||
|
||||
def test_set_and_get_cursor(self, store):
|
||||
store.set_last_dream_cursor(5)
|
||||
assert store.get_last_dream_cursor() == 5
|
||||
|
||||
@@ -1998,3 +1998,27 @@ class TestModelPresetWizard:
|
||||
defaults = AgentDefaults()
|
||||
_handle_provider_field(defaults, "provider", "Provider", "auto")
|
||||
assert defaults.provider == "anthropic"
|
||||
|
||||
def test_search_provider_field_handler(self, monkeypatch):
|
||||
"""_handle_search_provider_field should set the search engine from choices."""
|
||||
from nanobot.agent.tools.web import WebSearchConfig
|
||||
from nanobot.cli.onboard import _handle_search_provider_field
|
||||
|
||||
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "keenable")
|
||||
|
||||
cfg = WebSearchConfig()
|
||||
_handle_search_provider_field(cfg, "provider", "Provider", "duckduckgo")
|
||||
assert cfg.provider == "keenable"
|
||||
|
||||
def test_provider_field_dispatch_is_model_type_aware(self):
|
||||
"""WebSearchConfig.provider must not be hijacked by the LLM provider handler."""
|
||||
from nanobot.agent.tools.web import WebSearchConfig
|
||||
from nanobot.cli.onboard import (
|
||||
_handle_provider_field,
|
||||
_handle_search_provider_field,
|
||||
_resolve_field_handler,
|
||||
)
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
assert _resolve_field_handler(WebSearchConfig(), "provider") is _handle_search_provider_field
|
||||
assert _resolve_field_handler(AgentDefaults(), "provider") is _handle_provider_field
|
||||
|
||||
@@ -2,16 +2,45 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context_governance import (
|
||||
BACKFILL_CONTENT,
|
||||
MICROCOMPACT_KEEP_RECENT,
|
||||
ContextGovernanceConfig,
|
||||
ContextGovernor,
|
||||
)
|
||||
from nanobot.agent.runner import AgentRunSpec
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
def _governance_config(
|
||||
provider,
|
||||
tools,
|
||||
spec: AgentRunSpec,
|
||||
*,
|
||||
inflight_start_index: int = 0,
|
||||
) -> ContextGovernanceConfig:
|
||||
return ContextGovernanceConfig(
|
||||
provider=provider,
|
||||
model=spec.model,
|
||||
tools=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=inflight_start_index,
|
||||
)
|
||||
|
||||
|
||||
def _make_loop(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -22,13 +51,14 @@ def _make_loop(tmp_path):
|
||||
|
||||
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)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
||||
return loop
|
||||
|
||||
|
||||
async def test_runner_uses_raw_messages_when_context_governance_fails():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_messages: list[dict] = []
|
||||
@@ -46,7 +76,9 @@ async def test_runner_uses_raw_messages_when_context_governance_fails():
|
||||
]
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
runner._snip_history = MagicMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
||||
runner.context_governor.prepare_for_model = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=RuntimeError("boom")
|
||||
)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=initial_messages,
|
||||
tools=tools,
|
||||
@@ -57,13 +89,12 @@ async def test_runner_uses_raw_messages_when_context_governance_fails():
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert captured_messages == initial_messages
|
||||
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
|
||||
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
|
||||
provider = MagicMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
runner = AgentRunner(provider)
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
@@ -85,7 +116,10 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
|
||||
context_block_limit=100,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_args, **_kwargs: (500, None))
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
|
||||
lambda *_args, **_kwargs: (500, None),
|
||||
)
|
||||
token_sizes = {
|
||||
"old user": 120,
|
||||
"tool call": 120,
|
||||
@@ -94,11 +128,11 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
|
||||
"system": 0,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.runner.estimate_message_tokens",
|
||||
"nanobot.agent.context_governance.estimate_message_tokens",
|
||||
lambda msg: token_sizes.get(str(msg.get("content")), 40),
|
||||
)
|
||||
|
||||
trimmed = runner._snip_history(spec, messages)
|
||||
trimmed = ContextGovernor().snip_history(_governance_config(provider, tools, spec), messages)
|
||||
|
||||
# After the fix, the user message is recovered so the sequence is valid
|
||||
# for providers that require system → user (e.g. GLM error 1214).
|
||||
@@ -108,12 +142,9 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch
|
||||
|
||||
|
||||
def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "large_tool"}}]
|
||||
runner = AgentRunner(provider)
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
@@ -139,7 +170,7 @@ def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
|
||||
assert estimate_tools == tools.get_definitions.return_value
|
||||
return 350, None
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", _estimate)
|
||||
monkeypatch.setattr("nanobot.agent.context_governance.estimate_prompt_tokens_chain", _estimate)
|
||||
token_sizes = {
|
||||
"system": 50,
|
||||
"old user": 200,
|
||||
@@ -149,11 +180,11 @@ def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
|
||||
"recent two": 200,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.runner.estimate_message_tokens",
|
||||
"nanobot.agent.context_governance.estimate_message_tokens",
|
||||
lambda msg: token_sizes.get(str(msg.get("content")), 40),
|
||||
)
|
||||
|
||||
trimmed = runner._snip_history(spec, messages)
|
||||
trimmed = ContextGovernor().snip_history(_governance_config(provider, tools, spec), messages)
|
||||
|
||||
contents = [message.get("content") for message in trimmed]
|
||||
assert contents == ["system", "recent two"]
|
||||
@@ -161,7 +192,6 @@ def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
|
||||
|
||||
async def test_backfill_missing_tool_results_inserts_error():
|
||||
"""Orphaned tool_use (no matching tool_result) should get a synthetic error."""
|
||||
from nanobot.agent.runner import AgentRunner, _BACKFILL_CONTENT
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
@@ -175,18 +205,16 @@ async def test_backfill_missing_tool_results_inserts_error():
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_a", "name": "exec", "content": "ok"},
|
||||
]
|
||||
result = AgentRunner._backfill_missing_tool_results(messages)
|
||||
result = ContextGovernor.backfill_missing_tool_results(messages)
|
||||
tool_msgs = [m for m in result if m.get("role") == "tool"]
|
||||
assert len(tool_msgs) == 2
|
||||
backfilled = [m for m in tool_msgs if m.get("tool_call_id") == "call_b"]
|
||||
assert len(backfilled) == 1
|
||||
assert backfilled[0]["content"] == _BACKFILL_CONTENT
|
||||
assert backfilled[0]["content"] == BACKFILL_CONTENT
|
||||
assert backfilled[0]["name"] == "read_file"
|
||||
|
||||
|
||||
def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
@@ -202,7 +230,7 @@ def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
|
||||
{"role": "assistant", "content": "after tool"},
|
||||
]
|
||||
|
||||
cleaned = AgentRunner._drop_orphan_tool_results(messages)
|
||||
cleaned = ContextGovernor.drop_orphan_tool_results(messages)
|
||||
|
||||
assert cleaned == [
|
||||
{"role": "system", "content": "system"},
|
||||
@@ -222,8 +250,6 @@ def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_noop_when_complete():
|
||||
"""Complete message chains should not be modified."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
@@ -236,13 +262,13 @@ async def test_backfill_noop_when_complete():
|
||||
{"role": "tool", "tool_call_id": "call_x", "name": "exec", "content": "done"},
|
||||
{"role": "assistant", "content": "all good"},
|
||||
]
|
||||
result = AgentRunner._backfill_missing_tool_results(messages)
|
||||
result = ContextGovernor.backfill_missing_tool_results(messages)
|
||||
assert result is messages # same object — no copy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_drops_orphan_tool_results_before_model_request():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_messages: list[dict] = []
|
||||
@@ -283,7 +309,6 @@ async def test_runner_drops_orphan_tool_results_before_model_request():
|
||||
async def test_backfill_repairs_model_context_without_shifting_save_turn_boundary(tmp_path):
|
||||
"""Historical backfill should not duplicate old tail messages on persist."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import _BACKFILL_CONTENT
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
@@ -335,7 +360,7 @@ async def test_backfill_repairs_model_context_without_shifting_save_turn_boundar
|
||||
if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing"
|
||||
]
|
||||
assert len(synthetic) == 1
|
||||
assert synthetic[0]["content"] == _BACKFILL_CONTENT
|
||||
assert synthetic[0]["content"] == BACKFILL_CONTENT
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert [
|
||||
@@ -367,7 +392,7 @@ async def test_backfill_repairs_model_context_without_shifting_save_turn_boundar
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_backfill_only_mutates_model_context_not_returned_messages():
|
||||
"""Runner should repair orphaned tool calls for the model without rewriting result.messages."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner, _BACKFILL_CONTENT
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_messages: list[dict] = []
|
||||
@@ -413,7 +438,7 @@ async def test_runner_backfill_only_mutates_model_context_not_returned_messages(
|
||||
if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing"
|
||||
]
|
||||
assert len(synthetic) == 1
|
||||
assert synthetic[0]["content"] == _BACKFILL_CONTENT
|
||||
assert synthetic[0]["content"] == BACKFILL_CONTENT
|
||||
|
||||
assert [
|
||||
{
|
||||
@@ -447,96 +472,254 @@ async def test_runner_backfill_only_mutates_model_context_not_returned_messages(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_microcompact_replaces_old_tool_results():
|
||||
"""Tool results beyond _MICROCOMPACT_KEEP_RECENT should be summarized."""
|
||||
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT
|
||||
|
||||
total = _MICROCOMPACT_KEEP_RECENT + 5
|
||||
long_content = "x" * 600
|
||||
def _microcompact_messages(*, total: int, tool_name: str, content: str) -> list[dict]:
|
||||
messages: list[dict] = [{"role": "system", "content": "sys"}]
|
||||
for i in range(total):
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}],
|
||||
"tool_calls": [{
|
||||
"id": f"c{i}",
|
||||
"type": "function",
|
||||
"function": {"name": tool_name, "arguments": "{}"},
|
||||
}],
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": f"c{i}", "name": "read_file",
|
||||
"content": long_content,
|
||||
"role": "tool",
|
||||
"tool_call_id": f"c{i}",
|
||||
"name": tool_name,
|
||||
"content": content,
|
||||
})
|
||||
return messages
|
||||
|
||||
result = AgentRunner._microcompact(messages)
|
||||
|
||||
def test_microcompact_skips_when_prompt_under_hard_budget(monkeypatch):
|
||||
"""Cache-friendly path: in-flight tool results stay stable while prompt fits."""
|
||||
provider = MagicMock()
|
||||
provider.generation = SimpleNamespace(max_tokens=0)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
||||
long_content = "x" * 600
|
||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
max_tokens=0,
|
||||
context_window_tokens=20_000,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
|
||||
lambda *_args, **_kwargs: (1000, "test"),
|
||||
)
|
||||
|
||||
result = ContextGovernor().compact_inflight_overflow(
|
||||
_governance_config(provider, tools, spec),
|
||||
messages,
|
||||
set(),
|
||||
)
|
||||
|
||||
assert result is messages
|
||||
|
||||
|
||||
def test_microcompact_overflow_compacts_to_low_watermark(monkeypatch):
|
||||
"""Overflow path: compact in-flight stale results with headroom for later calls."""
|
||||
provider = MagicMock()
|
||||
provider.generation = SimpleNamespace(max_tokens=0)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 8
|
||||
long_content = "x" * 600
|
||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
max_tokens=0,
|
||||
context_window_tokens=2224, # input budget 1200, low target 1020
|
||||
)
|
||||
|
||||
def estimate(_provider, _model, msgs, _tools):
|
||||
return sum(
|
||||
100 if (content := msg.get("content")) == long_content
|
||||
else 1 if isinstance(content, str) and "omitted from context" in content
|
||||
else 0
|
||||
for msg in msgs
|
||||
if msg.get("role") == "tool"
|
||||
), "test"
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.context_governance.estimate_prompt_tokens_chain", estimate)
|
||||
|
||||
result = ContextGovernor().compact_inflight_overflow(
|
||||
_governance_config(provider, tools, spec),
|
||||
messages,
|
||||
set(),
|
||||
)
|
||||
tool_msgs = [m for m in result if m.get("role") == "tool"]
|
||||
stale_count = total - _MICROCOMPACT_KEEP_RECENT
|
||||
compacted = [m for m in tool_msgs if "omitted from context" in str(m.get("content", ""))]
|
||||
preserved = [m for m in tool_msgs if m.get("content") == long_content]
|
||||
assert len(compacted) == stale_count
|
||||
assert len(preserved) == _MICROCOMPACT_KEEP_RECENT
|
||||
|
||||
assert len(compacted) == 8
|
||||
assert len(preserved) == total - 8
|
||||
assert [m["tool_call_id"] for m in compacted] == [f"c{i}" for i in range(8)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_microcompact_preserves_short_results():
|
||||
"""Short tool results (< _MICROCOMPACT_MIN_CHARS) should not be replaced."""
|
||||
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT
|
||||
def test_microcompact_compacts_newest_when_it_alone_overflows(monkeypatch):
|
||||
"""The newest result is preserved only while the request can still fit."""
|
||||
provider = MagicMock()
|
||||
provider.generation = SimpleNamespace(max_tokens=0)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = _MICROCOMPACT_KEEP_RECENT + 5
|
||||
messages: list[dict] = []
|
||||
for i in range(total):
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "exec", "arguments": "{}"}}],
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": f"c{i}", "name": "exec",
|
||||
"content": "short",
|
||||
})
|
||||
long_content = "x" * 600
|
||||
messages = _microcompact_messages(total=1, tool_name="read_file", content=long_content)
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
max_tokens=0,
|
||||
context_window_tokens=2000,
|
||||
context_block_limit=500,
|
||||
)
|
||||
|
||||
result = AgentRunner._microcompact(messages)
|
||||
def estimate(_provider, _model, msgs, _tools):
|
||||
return sum(
|
||||
1000 if msg.get("content") == long_content else 1
|
||||
for msg in msgs
|
||||
if msg.get("role") == "tool"
|
||||
), "test"
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.context_governance.estimate_prompt_tokens_chain", estimate)
|
||||
|
||||
compacted_tool_call_ids: set[str] = set()
|
||||
result = ContextGovernor().compact_inflight_overflow(
|
||||
_governance_config(provider, tools, spec),
|
||||
messages,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
|
||||
tool_msg = next(m for m in result if m.get("role") == "tool")
|
||||
assert "omitted from context" in tool_msg["content"]
|
||||
assert compacted_tool_call_ids == {"c0"}
|
||||
|
||||
|
||||
def test_context_governor_keeps_compaction_boundary_stable(monkeypatch):
|
||||
provider = MagicMock()
|
||||
provider.generation = SimpleNamespace(max_tokens=0)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 8
|
||||
long_content = "x" * 600
|
||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
max_tokens=0,
|
||||
context_window_tokens=2224,
|
||||
)
|
||||
|
||||
def estimate(_provider, _model, msgs, _tools):
|
||||
return sum(
|
||||
100 if msg.get("content") == long_content else 1
|
||||
for msg in msgs
|
||||
if msg.get("role") == "tool"
|
||||
), "test"
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.context_governance.estimate_prompt_tokens_chain", estimate)
|
||||
|
||||
governor = ContextGovernor()
|
||||
compacted_tool_call_ids: set[str] = set()
|
||||
config = _governance_config(provider, tools, spec, inflight_start_index=0)
|
||||
first = governor.compact_inflight_overflow(config, messages, compacted_tool_call_ids)
|
||||
first_ids = set(compacted_tool_call_ids)
|
||||
|
||||
second = governor.compact_inflight_overflow(config, messages, compacted_tool_call_ids)
|
||||
|
||||
assert compacted_tool_call_ids == first_ids
|
||||
assert [m.get("content") for m in second] == [m.get("content") for m in first]
|
||||
|
||||
|
||||
def test_microcompact_preserves_short_results(monkeypatch):
|
||||
"""Short tool results below the compaction threshold should not be replaced."""
|
||||
provider = MagicMock()
|
||||
provider.generation = SimpleNamespace(max_tokens=0)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
||||
messages = _microcompact_messages(total=total, tool_name="exec", content="short")
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
max_tokens=0,
|
||||
context_window_tokens=2024,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
|
||||
lambda *_args, **_kwargs: (2000, "test"),
|
||||
)
|
||||
|
||||
result = ContextGovernor().compact_inflight_overflow(
|
||||
_governance_config(provider, tools, spec),
|
||||
messages,
|
||||
set(),
|
||||
)
|
||||
assert result is messages # no copy needed — all stale results are short
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_microcompact_skips_non_compactable_tools():
|
||||
def test_microcompact_skips_non_compactable_tools(monkeypatch):
|
||||
"""Non-compactable tools (e.g. 'message') should never be replaced."""
|
||||
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT
|
||||
provider = MagicMock()
|
||||
provider.generation = SimpleNamespace(max_tokens=0)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
total = _MICROCOMPACT_KEEP_RECENT + 5
|
||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
||||
long_content = "y" * 1000
|
||||
messages: list[dict] = []
|
||||
for i in range(total):
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "message", "arguments": "{}"}}],
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": f"c{i}", "name": "message",
|
||||
"content": long_content,
|
||||
})
|
||||
messages = _microcompact_messages(total=total, tool_name="message", content=long_content)
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
max_tokens=0,
|
||||
context_window_tokens=2024,
|
||||
)
|
||||
|
||||
result = AgentRunner._microcompact(messages)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
|
||||
lambda *_args, **_kwargs: (2000, "test"),
|
||||
)
|
||||
|
||||
result = ContextGovernor().compact_inflight_overflow(
|
||||
_governance_config(provider, tools, spec),
|
||||
messages,
|
||||
set(),
|
||||
)
|
||||
assert result is messages # no compactable tools found
|
||||
|
||||
|
||||
def test_governance_repairs_orphans_after_snip():
|
||||
"""After _snip_history clips an assistant+tool_calls, the second
|
||||
_drop_orphan_tool_results pass must clean up the resulting orphans."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old msg"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"id": "tc_old", "type": "function",
|
||||
"function": {"name": "search", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "tc_old", "name": "search",
|
||||
"content": "old result"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "new msg"},
|
||||
]
|
||||
|
||||
"""After snipping clips an assistant+tool_calls, orphan repair cleans up the tail."""
|
||||
# Simulate snipping that keeps only the tail: drop the assistant with
|
||||
# tool_calls but keep its tool result (orphan).
|
||||
snipped = [
|
||||
@@ -547,7 +730,7 @@ def test_governance_repairs_orphans_after_snip():
|
||||
{"role": "user", "content": "new msg"},
|
||||
]
|
||||
|
||||
cleaned = AgentRunner._drop_orphan_tool_results(snipped)
|
||||
cleaned = ContextGovernor.drop_orphan_tool_results(snipped)
|
||||
# The orphan tool result should be removed.
|
||||
assert not any(
|
||||
m.get("role") == "tool" and m.get("tool_call_id") == "tc_old"
|
||||
@@ -556,10 +739,7 @@ def test_governance_repairs_orphans_after_snip():
|
||||
|
||||
|
||||
def test_governance_fallback_still_repairs_orphans():
|
||||
"""When full governance fails, the fallback must still run
|
||||
_drop_orphan_tool_results and _backfill_missing_tool_results."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
"""When full governance fails, the fallback must still repair orphans."""
|
||||
# Messages with an orphan tool result (no matching assistant tool_call).
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
@@ -568,10 +748,12 @@ def test_governance_fallback_still_repairs_orphans():
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
|
||||
repaired = AgentRunner._drop_orphan_tool_results(messages)
|
||||
repaired = AgentRunner._backfill_missing_tool_results(repaired)
|
||||
repaired = ContextGovernor.drop_orphan_tool_results(messages)
|
||||
repaired = ContextGovernor.backfill_missing_tool_results(repaired)
|
||||
# Orphan tool result should be gone.
|
||||
assert not any(m.get("tool_call_id") == "orphan_tc" for m in repaired)
|
||||
|
||||
|
||||
def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
|
||||
"""When _snip_history truncates messages and the only user message ends up
|
||||
outside the kept window, the method must recover the nearest user message
|
||||
@@ -585,12 +767,9 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
|
||||
- _snip_history activates, keeping only recent assistant/tool pairs.
|
||||
- The injected user message is in the truncated prefix and gets lost.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
runner = AgentRunner(provider)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
@@ -621,7 +800,10 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
|
||||
)
|
||||
|
||||
# Make estimate_prompt_tokens_chain report above budget so _snip_history activates.
|
||||
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
|
||||
lambda *_a, **_kw: (500, None),
|
||||
)
|
||||
# Make kept window small: only the last 2 messages fit the budget.
|
||||
token_sizes = {
|
||||
"system": 0,
|
||||
@@ -631,11 +813,11 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
|
||||
"tool output 2": 80,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.runner.estimate_message_tokens",
|
||||
"nanobot.agent.context_governance.estimate_message_tokens",
|
||||
lambda msg: token_sizes.get(str(msg.get("content")), 100),
|
||||
)
|
||||
|
||||
trimmed = runner._snip_history(spec, messages)
|
||||
trimmed = ContextGovernor().snip_history(_governance_config(provider, tools, spec), messages)
|
||||
|
||||
# The first non-system message MUST be user (not assistant).
|
||||
non_system = [m for m in trimmed if m.get("role") != "system"]
|
||||
@@ -649,12 +831,9 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
|
||||
def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
|
||||
"""Edge case: if non_system has zero user messages, _snip_history should
|
||||
still return a valid sequence (not crash or produce system→assistant)."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
runner = AgentRunner(provider)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
@@ -674,13 +853,16 @@ def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
|
||||
context_block_limit=100,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.runner.estimate_message_tokens",
|
||||
"nanobot.agent.context_governance.estimate_prompt_tokens_chain",
|
||||
lambda *_a, **_kw: (500, None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.context_governance.estimate_message_tokens",
|
||||
lambda msg: 100,
|
||||
)
|
||||
|
||||
trimmed = runner._snip_history(spec, messages)
|
||||
trimmed = ContextGovernor().snip_history(_governance_config(provider, tools, spec), messages)
|
||||
|
||||
# Should not crash. The result should still be a valid list.
|
||||
assert isinstance(trimmed, list)
|
||||
|
||||
@@ -6,15 +6,13 @@ import os
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
captured_second_call: list[dict] = []
|
||||
@@ -50,7 +48,13 @@ async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
|
||||
assert result.final_content == "done"
|
||||
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
|
||||
assert "[tool output persisted]" in tool_message["content"]
|
||||
assert "tool-results" in tool_message["content"]
|
||||
assert "tool_output_id: call_big" in tool_message["content"]
|
||||
assert "original_size_chars: 20000" in tool_message["content"]
|
||||
assert "head:" in tool_message["content"]
|
||||
assert "tail:" in tool_message["content"]
|
||||
assert "Read the saved file" not in tool_message["content"]
|
||||
assert str(tmp_path) not in tool_message["content"]
|
||||
assert len(tool_message["content"]) <= 2048
|
||||
assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists()
|
||||
|
||||
|
||||
@@ -78,6 +82,8 @@ def test_persist_tool_result_prunes_old_session_buckets(tmp_path):
|
||||
)
|
||||
|
||||
assert "[tool output persisted]" in persisted
|
||||
assert "tool_output_id: call_big" in persisted
|
||||
assert "tool-results" not in persisted
|
||||
assert not old_bucket.exists()
|
||||
assert recent_bucket.exists()
|
||||
assert (root / "current_session" / "call_big.txt").exists()
|
||||
@@ -172,7 +178,7 @@ async def test_read_file_result_is_not_offloaded(tmp_path):
|
||||
|
||||
|
||||
async def test_runner_keeps_going_when_tool_result_persistence_fails():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
captured_second_call: list[dict] = []
|
||||
@@ -195,7 +201,10 @@ async def test_runner_keeps_going_when_tool_result_persistence_fails():
|
||||
tools.execute = AsyncMock(return_value="tool result")
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
with patch("nanobot.agent.runner.maybe_persist_tool_result", side_effect=RuntimeError("disk full")):
|
||||
with patch(
|
||||
"nanobot.agent.context_governance.maybe_persist_tool_result",
|
||||
side_effect=RuntimeError("disk full"),
|
||||
):
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
|
||||
@@ -369,3 +369,78 @@ async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup():
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert hook.emitted == ["part1", "part2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_strips_thinking_tags_from_native_thinking_deltas():
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
async def chat_stream_with_retry(
|
||||
*, on_content_delta=None, on_thinking_delta=None, **kwargs
|
||||
):
|
||||
if on_thinking_delta:
|
||||
await on_thinking_delta("<thinking")
|
||||
await on_thinking_delta(">Preparing final response")
|
||||
await on_thinking_delta("</thinking>")
|
||||
if on_content_delta:
|
||||
await on_content_delta("done")
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
hook = _StreamRecordingHook()
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "q"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert hook.emitted == ["Preparing final response"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_ignores_empty_thinking_marker_before_final_reasoning():
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
async def chat_stream_with_retry(
|
||||
*, on_content_delta=None, on_thinking_delta=None, **kwargs
|
||||
):
|
||||
if on_thinking_delta:
|
||||
await on_thinking_delta("<thinking/>")
|
||||
if on_content_delta:
|
||||
await on_content_delta("done")
|
||||
return LLMResponse(
|
||||
content="done",
|
||||
reasoning_content="Preparing final response",
|
||||
tool_calls=[],
|
||||
usage={},
|
||||
)
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
hook = _StreamRecordingHook()
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "q"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert hook.emitted == ["Preparing final response"]
|
||||
|
||||
@@ -358,3 +358,79 @@ async def test_runner_blocks_repeated_external_fetches():
|
||||
if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3"
|
||||
][0]
|
||||
assert "repeated external lookup blocked" in blocked_tool_message["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_adds_budget_notice_near_long_tool_budget():
|
||||
provider = MagicMock()
|
||||
captured_final_call: list[dict] = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] <= 16:
|
||||
return LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="work", arguments={})],
|
||||
usage={},
|
||||
)
|
||||
captured_final_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="tool result")
|
||||
|
||||
result = await AgentRunner(provider).run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "finish a large task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=20,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
notices = [
|
||||
msg["content"]
|
||||
for msg in captured_final_call
|
||||
if msg.get("role") == "user" and "[Runtime Budget Notice]" in str(msg.get("content"))
|
||||
]
|
||||
assert len(notices) == 1
|
||||
assert "15 of 20 model/tool iterations" in notices[0]
|
||||
assert "Switch to convergence mode" in notices[0]
|
||||
assert tools.execute.await_count == 16
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_budget_notice_does_not_affect_short_runs():
|
||||
provider = MagicMock()
|
||||
captured_final_call: list[dict] = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] <= 2:
|
||||
return LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="work", arguments={})],
|
||||
usage={},
|
||||
)
|
||||
captured_final_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="tool result")
|
||||
|
||||
result = await AgentRunner(provider).run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "small task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=4,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert all("[Runtime Budget Notice]" not in str(msg.get("content")) for msg in captured_final_call)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for atomic session save and corrupt-file repair."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -36,6 +37,17 @@ class TestAtomicSave:
|
||||
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
|
||||
assert tmp_files == []
|
||||
|
||||
def test_save_recreates_deleted_sessions_dir(self, tmp_path: Path):
|
||||
mgr = SessionManager(tmp_path)
|
||||
shutil.rmtree(mgr.sessions_dir)
|
||||
|
||||
session = Session(key="test:recreate")
|
||||
session.add_message("user", "hello")
|
||||
mgr.save(session)
|
||||
|
||||
path = mgr._get_session_path("test:recreate")
|
||||
assert path.exists()
|
||||
|
||||
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
|
||||
mgr = SessionManager(tmp_path)
|
||||
session = Session(key="test:fail")
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Regression tests for collision-resistant session filenames."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
|
||||
def _manager(tmp_path: Path, monkeypatch) -> SessionManager:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.manager.get_legacy_sessions_dir",
|
||||
lambda: tmp_path / "legacy_sessions",
|
||||
)
|
||||
return SessionManager(tmp_path / "workspace")
|
||||
|
||||
|
||||
def _write_session_file(path: Path, key: str, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
metadata = {
|
||||
"_type": "metadata",
|
||||
"key": key,
|
||||
"created_at": datetime(2025, 1, 1).isoformat(),
|
||||
"updated_at": datetime(2025, 1, 1).isoformat(),
|
||||
"metadata": {"source": "test"},
|
||||
"last_consolidated": 0,
|
||||
}
|
||||
message = {"role": "user", "content": content}
|
||||
path.write_text(
|
||||
json.dumps(metadata) + "\n" + json.dumps(message) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_distinct_keys_have_distinct_filenames(tmp_path: Path, monkeypatch) -> None:
|
||||
sm = _manager(tmp_path, monkeypatch)
|
||||
|
||||
first = sm._get_session_path("telegram:a_b")
|
||||
second = sm._get_session_path("telegram:a:b")
|
||||
|
||||
assert first.name != second.name
|
||||
assert sm.safe_key("telegram:a_b") == sm.safe_key("telegram:a:b")
|
||||
assert sm._storage_key("telegram:a_b") != sm._storage_key("telegram:a:b")
|
||||
|
||||
|
||||
def test_save_uses_new_path_not_lossy(tmp_path: Path, monkeypatch) -> None:
|
||||
sm = _manager(tmp_path, monkeypatch)
|
||||
key = "telegram:a:b"
|
||||
session = Session(key=key)
|
||||
session.add_message("user", "first")
|
||||
sm.save(session)
|
||||
|
||||
new_path = sm._get_session_path(key)
|
||||
lossy_path = sm._get_legacy_lossy_path(key)
|
||||
_write_session_file(lossy_path, key, "stale lossy content")
|
||||
stale_lossy = lossy_path.read_text(encoding="utf-8")
|
||||
|
||||
session.add_message("assistant", "latest content")
|
||||
sm.save(session)
|
||||
|
||||
assert new_path.exists()
|
||||
assert lossy_path.exists()
|
||||
assert "latest content" in new_path.read_text(encoding="utf-8")
|
||||
assert lossy_path.read_text(encoding="utf-8") == stale_lossy
|
||||
|
||||
|
||||
def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
|
||||
sm = _manager(tmp_path, monkeypatch)
|
||||
key = "telegram:legacy:lossy"
|
||||
lossy_path = sm._get_legacy_lossy_path(key)
|
||||
_write_session_file(lossy_path, key, "loaded from lossy")
|
||||
|
||||
session = sm._load(key)
|
||||
|
||||
assert session is not None
|
||||
assert session.metadata == {"source": "test"}
|
||||
assert session.messages[0]["content"] == "loaded from lossy"
|
||||
|
||||
|
||||
def test_load_migrates_lossy_to_new_path(tmp_path: Path, monkeypatch) -> None:
|
||||
sm = _manager(tmp_path, monkeypatch)
|
||||
key = "telegram:migrate:lossy"
|
||||
new_path = sm._get_session_path(key)
|
||||
lossy_path = sm._get_legacy_lossy_path(key)
|
||||
_write_session_file(lossy_path, key, "migrate me")
|
||||
|
||||
session = sm._load(key)
|
||||
|
||||
assert session is not None
|
||||
assert session.messages[0]["content"] == "migrate me"
|
||||
assert new_path.exists()
|
||||
assert not lossy_path.exists()
|
||||
|
||||
|
||||
def test_load_does_not_migrate_lossy_path_for_different_stored_key(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
sm = _manager(tmp_path, monkeypatch)
|
||||
first_key = "telegram:a_b"
|
||||
second_key = "telegram:a:b"
|
||||
lossy_path = sm._get_legacy_lossy_path(first_key)
|
||||
assert lossy_path == sm._get_legacy_lossy_path(second_key)
|
||||
_write_session_file(lossy_path, first_key, "belongs to first")
|
||||
|
||||
loaded_second = sm._load(second_key)
|
||||
|
||||
assert loaded_second is None
|
||||
assert lossy_path.exists()
|
||||
assert not sm._get_session_path(second_key).exists()
|
||||
|
||||
loaded_first = sm._load(first_key)
|
||||
|
||||
assert loaded_first is not None
|
||||
assert loaded_first.messages[0]["content"] == "belongs to first"
|
||||
assert sm._get_session_path(first_key).exists()
|
||||
assert not lossy_path.exists()
|
||||
|
||||
|
||||
def test_safe_key_is_lossy() -> None:
|
||||
assert SessionManager.safe_key("telegram:a_b") == SessionManager.safe_key("telegram:a:b")
|
||||
|
||||
|
||||
def test_storage_key_is_collision_resistant() -> None:
|
||||
encoded = {
|
||||
SessionManager._storage_key("a:b"),
|
||||
SessionManager._storage_key("a_b"),
|
||||
SessionManager._storage_key("a:b:c"),
|
||||
}
|
||||
|
||||
assert len(encoded) == 3
|
||||
assert SessionManager._storage_key("telegram:a_b") != SessionManager._storage_key("telegram:a:b")
|
||||
|
||||
|
||||
def test_lossy_path_helper_returns_expected_path(tmp_path: Path, monkeypatch) -> None:
|
||||
sm = _manager(tmp_path, monkeypatch)
|
||||
key = "telegram:a:b"
|
||||
expected = sm.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
||||
|
||||
assert sm._get_legacy_lossy_path(key) == expected
|
||||
|
||||
|
||||
def test_storage_paths_are_distinct_when_keys_collide_under_safe_key(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
sm = _manager(tmp_path, monkeypatch)
|
||||
first = Session(key="telegram:a_b")
|
||||
first.add_message("user", "underscore history")
|
||||
second = Session(key="telegram:a:b")
|
||||
second.add_message("user", "colon history")
|
||||
|
||||
sm.save(first)
|
||||
sm.save(second)
|
||||
|
||||
assert sm.safe_key(first.key) == sm.safe_key(second.key)
|
||||
assert sm._get_session_path(first.key).exists()
|
||||
assert sm._get_session_path(second.key).exists()
|
||||
assert sm._get_session_path(first.key) != sm._get_session_path(second.key)
|
||||
|
||||
sm.invalidate(first.key)
|
||||
sm.invalidate(second.key)
|
||||
loaded_first = sm._load(first.key)
|
||||
loaded_second = sm._load(second.key)
|
||||
|
||||
assert loaded_first is not None
|
||||
assert loaded_second is not None
|
||||
assert loaded_first.messages[0]["content"] == "underscore history"
|
||||
assert loaded_second.messages[0]["content"] == "colon history"
|
||||
@@ -58,11 +58,11 @@ def test_read_session_file_missing(tmp_path: Path) -> None:
|
||||
assert sm.read_session_file("nope:none") is None
|
||||
|
||||
|
||||
def test_safe_key_matches_internal_path(tmp_path: Path) -> None:
|
||||
def test_storage_key_matches_internal_path(tmp_path: Path) -> None:
|
||||
sm = SessionManager(tmp_path)
|
||||
key = "telegram:abc/def"
|
||||
expected = sm._get_session_path(key).name
|
||||
assert SessionManager.safe_key(key) + ".jsonl" == expected
|
||||
assert SessionManager._storage_key(key) + ".jsonl" == expected
|
||||
|
||||
|
||||
def _write_legacy_session(legacy_dir: Path, key: str, roles: list[str]) -> Path:
|
||||
|
||||
@@ -266,13 +266,8 @@ def test_get_history_preserves_reasoning_content():
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_annotates_user_turns_but_not_assistant_turns():
|
||||
"""Only user turns carry the timestamp prefix.
|
||||
|
||||
Annotating assistant turns trains the model (via in-context examples) to
|
||||
start its own replies with ``[Message Time: ...]``. User-side stamps are
|
||||
enough to pin adjacent assistant replies for relative-time reasoning.
|
||||
"""
|
||||
def test_get_history_does_not_inject_persisted_timestamps_into_replay_content():
|
||||
"""Persisted timestamps are session metadata, not prompt content."""
|
||||
session = Session(key="test:timestamps")
|
||||
session.messages.append({
|
||||
"role": "user",
|
||||
@@ -285,12 +280,14 @@ def test_get_history_annotates_user_turns_but_not_assistant_turns():
|
||||
"timestamp": "2026-04-26T22:00:05",
|
||||
})
|
||||
|
||||
history = session.get_history(max_messages=500, include_timestamps=True)
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert session.messages[0]["timestamp"] == "2026-04-26T22:00:00"
|
||||
assert session.messages[1]["timestamp"] == "2026-04-26T22:00:05"
|
||||
assert history == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[Message Time: 2026-04-26T22:00:00]\n10 点提醒是昨天发生的",
|
||||
"content": "10 点提醒是昨天发生的",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -299,8 +296,8 @@ def test_get_history_annotates_user_turns_but_not_assistant_turns():
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_does_not_annotate_proactive_assistant_deliveries_with_timestamps():
|
||||
"""Assistant-side timestamp examples can leak back into future replies."""
|
||||
def test_get_history_keeps_proactive_delivery_timestamps_out_of_replay_content():
|
||||
"""Timestamp metadata remains persisted without becoming prompt text."""
|
||||
session = Session(key="test:proactive-timestamps")
|
||||
session.messages.append({
|
||||
"role": "assistant",
|
||||
@@ -314,8 +311,10 @@ def test_get_history_does_not_annotate_proactive_assistant_deliveries_with_times
|
||||
"timestamp": "2026-04-26T18:00:00",
|
||||
})
|
||||
|
||||
history = session.get_history(max_messages=500, include_timestamps=True)
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert session.messages[0]["timestamp"] == "2026-04-26T15:00:00"
|
||||
assert session.messages[1]["timestamp"] == "2026-04-26T18:00:00"
|
||||
assert history == [
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -323,18 +322,18 @@ def test_get_history_does_not_annotate_proactive_assistant_deliveries_with_times
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "[Message Time: 2026-04-26T18:00:00]\n好",
|
||||
"content": "好",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_get_history_does_not_annotate_tool_results_with_timestamps():
|
||||
def test_get_history_does_not_inject_tool_result_timestamps():
|
||||
session = Session(key="test:tool-timestamps")
|
||||
session.messages.append({"role": "user", "content": "run tool"})
|
||||
session.messages.extend(_tool_turn("ts", 0))
|
||||
session.messages[-1]["timestamp"] = "2026-04-26T22:00:10"
|
||||
|
||||
history = session.get_history(max_messages=500, include_timestamps=True)
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
tool_result = history[-1]
|
||||
assert tool_result["role"] == "tool"
|
||||
@@ -555,7 +554,7 @@ def test_get_history_sanitizes_existing_assistant_replay_artifacts():
|
||||
}
|
||||
)
|
||||
|
||||
history = session.get_history(max_messages=500, include_timestamps=True)
|
||||
history = session.get_history(max_messages=500)
|
||||
|
||||
assert history == [{"role": "assistant", "content": "来了 🎨"}]
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Tests for SubagentManager."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
@@ -79,3 +80,33 @@ def test_subagent_respects_file_tool_toggle(tmp_path):
|
||||
"write_file",
|
||||
}
|
||||
assert file_tools.isdisjoint(tools.tool_names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_forwards_fail_on_tool_error_to_runner(tmp_path):
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.get_default_model.return_value = "test"
|
||||
sm = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=MessageBus(),
|
||||
model="test",
|
||||
max_tool_result_chars=16_000,
|
||||
fail_on_tool_error=False,
|
||||
)
|
||||
sm.runner.run = AsyncMock(
|
||||
return_value=AgentRunResult(final_content="ok", messages=[], stop_reason="completed")
|
||||
)
|
||||
sm._announce_result = AsyncMock()
|
||||
|
||||
status = SubagentStatus(
|
||||
task_id="t1",
|
||||
label="label",
|
||||
task_description="task",
|
||||
started_at=0.0,
|
||||
)
|
||||
|
||||
await sm._run_subagent("t1", "task", "label", {"channel": "cli", "chat_id": "direct"}, status)
|
||||
|
||||
spec = sm.runner.run.call_args.args[0]
|
||||
assert spec.fail_on_tool_error is False
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from nanobot.agent.verification_state import (
|
||||
analyze_verification_result,
|
||||
append_verification_feedback,
|
||||
)
|
||||
|
||||
|
||||
def test_analyze_pytest_failure_extracts_actionable_summary():
|
||||
output = """\
|
||||
FAILED ../tests/test_outputs.py::test_regex_matches_dates - AssertionError: Expected dates
|
||||
E AssertionError: Expected ['2025-01-09'], but got ['bad']
|
||||
E FileNotFoundError: [Errno 2] No such file or directory: '/app/out.txt'
|
||||
============================== 1 failed in 0.05s ===============================
|
||||
Exit code: 1
|
||||
"""
|
||||
|
||||
analysis = analyze_verification_result(
|
||||
command="pytest /tests/test_outputs.py",
|
||||
output=output,
|
||||
exit_code=1,
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis.status == "failed"
|
||||
assert analysis.failed_tests == ("../tests/test_outputs.py::test_regex_matches_dates",)
|
||||
assert any("AssertionError" in item for item in analysis.primary_errors)
|
||||
assert "/app/out.txt" in analysis.missing_artifacts
|
||||
|
||||
|
||||
def test_append_verification_feedback_tells_agent_not_to_finish():
|
||||
analysis = analyze_verification_result(
|
||||
command="python /app/test_outputs.py",
|
||||
output="FAILED test_outputs.py::test_file\nAssertionError: missing\nExit code: 1",
|
||||
exit_code=1,
|
||||
)
|
||||
|
||||
feedback = append_verification_feedback("raw output\nExit code: 1", analysis)
|
||||
|
||||
assert "[Verification Feedback]" in feedback
|
||||
assert "Do not call complete_goal" in feedback
|
||||
assert "Next action" in feedback
|
||||
|
||||
|
||||
def test_analyze_passing_test_records_success_without_feedback():
|
||||
analysis = analyze_verification_result(
|
||||
command="pytest",
|
||||
output="============================== 3 passed in 0.10s ==============================\nExit code: 0",
|
||||
exit_code=0,
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis.status == "passed"
|
||||
assert append_verification_feedback("ok", analysis) == "ok"
|
||||
|
||||
|
||||
def test_analyze_command_not_found_as_failed_check():
|
||||
output = """\
|
||||
STDERR:
|
||||
/usr/bin/bash: line 1: python3: command not found
|
||||
|
||||
Exit code: 127
|
||||
"""
|
||||
|
||||
analysis = analyze_verification_result(
|
||||
command="python3 - <<'PY'\nprint('quick verification')\nPY",
|
||||
output=output,
|
||||
exit_code=127,
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis.status == "failed"
|
||||
assert any("command not found" in item for item in analysis.primary_errors)
|
||||
|
||||
|
||||
def test_analyze_artifact_comparison_success_records_pass():
|
||||
output = """\
|
||||
run_exit:0
|
||||
0d115b98 /app/image.ppm
|
||||
0d115b98 /tmp/orig.ppm
|
||||
cmp_exit:0
|
||||
7 21 1024
|
||||
|
||||
Exit code: 0
|
||||
"""
|
||||
|
||||
analysis = analyze_verification_result(
|
||||
command=(
|
||||
"cd /usr/bin && gcc -static -o /app/reversed_final /app/mystery.c -lm "
|
||||
"&& (cd /app && ./reversed_final >/tmp/final_out 2>/tmp/final_err); "
|
||||
"sha256sum /app/image.ppm /tmp/orig.ppm; "
|
||||
"cmp -s /app/image.ppm /tmp/orig.ppm; echo cmp_exit:$?"
|
||||
),
|
||||
output=output,
|
||||
exit_code=0,
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis.status == "passed"
|
||||
assert append_verification_feedback("ok", analysis) == "ok"
|
||||
|
||||
|
||||
def test_analyze_plain_checksum_without_success_marker_is_ignored():
|
||||
analysis = analyze_verification_result(
|
||||
command="sha256sum /app/image.ppm /tmp/orig.ppm",
|
||||
output="0d115b98 /app/image.ppm\n0d115b98 /tmp/orig.ppm\nExit code: 0",
|
||||
exit_code=0,
|
||||
)
|
||||
|
||||
assert analysis is None
|
||||
|
||||
|
||||
def test_analyze_named_comparison_markers_record_pass():
|
||||
output = """\
|
||||
ppm:0
|
||||
stderr:0
|
||||
stdout:0
|
||||
4 26 1011
|
||||
1821 mystery.c
|
||||
|
||||
Exit code: 0
|
||||
"""
|
||||
|
||||
analysis = analyze_verification_result(
|
||||
command=(
|
||||
"gcc -static -O2 -o reversed mystery.c -lm\n"
|
||||
"./reversed > vrout.txt 2> vrerr.txt\n"
|
||||
"cp image.ppm rev.ppm\n"
|
||||
"./mystery > voout.txt 2> voerr.txt\n"
|
||||
"cmp image.ppm rev.ppm\n"
|
||||
"printf 'ppm:%s\\n' $?\n"
|
||||
"cmp voerr.txt vrerr.txt\n"
|
||||
"printf 'stderr:%s\\n' $?\n"
|
||||
"cmp voout.txt vrout.txt\n"
|
||||
"printf 'stdout:%s\\n' $?"
|
||||
),
|
||||
output=output,
|
||||
exit_code=0,
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis.status == "passed"
|
||||
|
||||
|
||||
def test_analyze_named_comparison_marker_failure_records_failed():
|
||||
output = """\
|
||||
ppm:0
|
||||
stderr:1
|
||||
stdout:0
|
||||
|
||||
Exit code: 0
|
||||
"""
|
||||
|
||||
analysis = analyze_verification_result(
|
||||
command=(
|
||||
"cmp image.ppm rev.ppm; printf 'ppm:%s\\n' $?; "
|
||||
"cmp voerr.txt vrerr.txt; printf 'stderr:%s\\n' $?; "
|
||||
"cmp voout.txt vrout.txt; printf 'stdout:%s\\n' $?"
|
||||
),
|
||||
output=output,
|
||||
exit_code=0,
|
||||
)
|
||||
|
||||
assert analysis is not None
|
||||
assert analysis.status == "failed"
|
||||
|
||||
|
||||
def test_analyze_plain_run_status_marker_without_comparison_is_ignored():
|
||||
analysis = analyze_verification_result(
|
||||
command="gcc -static -O2 -o reversed mystery.c -lm && ./reversed",
|
||||
output="rc:0\nExit code: 0",
|
||||
exit_code=0,
|
||||
)
|
||||
|
||||
assert analysis is None
|
||||
@@ -13,6 +13,11 @@ from nanobot.agent.tools.long_task import (
|
||||
CompleteGoalTool,
|
||||
LongTaskTool,
|
||||
)
|
||||
from nanobot.agent.verification_state import (
|
||||
VerificationAnalysis,
|
||||
clear_verification_observation,
|
||||
record_verification_observation,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||
@@ -192,6 +197,66 @@ async def test_complete_goal_without_active_is_noop_message(tmp_path):
|
||||
assert "No active" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_goal_blocks_unresolved_verification_failure(tmp_path):
|
||||
sm = SessionManager(tmp_path)
|
||||
lt, cg = _tools(sm)
|
||||
await lt.execute(goal="Fix the tests")
|
||||
record_verification_observation(
|
||||
"websocket:c1",
|
||||
VerificationAnalysis(
|
||||
status="failed",
|
||||
command="pytest /tests/test_outputs.py",
|
||||
exit_code=1,
|
||||
failed_tests=("test_outputs.py::test_output",),
|
||||
primary_errors=("AssertionError: wrong output",),
|
||||
),
|
||||
)
|
||||
|
||||
out = await cg.execute(recap="Done.")
|
||||
|
||||
assert "not marked complete" in out
|
||||
assert "test_outputs.py::test_output" in out
|
||||
assert sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]["status"] == "active"
|
||||
clear_verification_observation("websocket:c1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_goal_allows_after_later_successful_verification(tmp_path):
|
||||
sm = SessionManager(tmp_path)
|
||||
lt, cg = _tools(sm)
|
||||
await lt.execute(goal="Fix the tests")
|
||||
record_verification_observation(
|
||||
"websocket:c1",
|
||||
VerificationAnalysis(
|
||||
status="failed",
|
||||
command="pytest /tests/test_outputs.py",
|
||||
exit_code=1,
|
||||
failed_tests=("test_outputs.py::test_output",),
|
||||
),
|
||||
)
|
||||
record_verification_observation(
|
||||
"websocket:c1",
|
||||
VerificationAnalysis(
|
||||
status="passed",
|
||||
command="pytest /tests/test_outputs.py",
|
||||
exit_code=0,
|
||||
),
|
||||
)
|
||||
|
||||
out = await cg.execute(
|
||||
recap="Done.",
|
||||
verification_summary="pytest /tests/test_outputs.py passed",
|
||||
commands_run="pytest /tests/test_outputs.py",
|
||||
artifacts_created="/app/out.txt",
|
||||
)
|
||||
|
||||
assert "marked complete" in out
|
||||
blob = sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]
|
||||
assert blob["status"] == "completed"
|
||||
assert blob["verification_summary"] == "pytest /tests/test_outputs.py passed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_task_skips_ws_publish_without_bus(tmp_path):
|
||||
sm = SessionManager(tmp_path)
|
||||
|
||||
@@ -142,6 +142,31 @@ class TestDeltaCoalescing:
|
||||
assert pending[0].chat_id == "chat2"
|
||||
assert pending[0].content == "World"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deltas_different_stream_ids_not_coalesced(self, manager, bus):
|
||||
"""Deltas for the same chat but different streams should not be merged."""
|
||||
await bus.publish_outbound(OutboundMessage(
|
||||
channel="mock",
|
||||
chat_id="chat1",
|
||||
content="A1",
|
||||
metadata={"_stream_delta": True, "_stream_id": "stream-a"},
|
||||
))
|
||||
await bus.publish_outbound(OutboundMessage(
|
||||
channel="mock",
|
||||
chat_id="chat1",
|
||||
content="B1",
|
||||
metadata={"_stream_delta": True, "_stream_id": "stream-b"},
|
||||
))
|
||||
|
||||
first_msg = await bus.consume_outbound()
|
||||
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
||||
|
||||
assert merged.content == "A1"
|
||||
assert merged.metadata.get("_stream_id") == "stream-a"
|
||||
assert len(pending) == 1
|
||||
assert pending[0].content == "B1"
|
||||
assert pending[0].metadata.get("_stream_id") == "stream-b"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_terminates_coalescing(self, manager, bus):
|
||||
"""_stream_end should stop coalescing and be included in final message."""
|
||||
|
||||
@@ -259,6 +259,150 @@ async def test_handler_processes_file_message(monkeypatch) -> None:
|
||||
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
|
||||
|
||||
|
||||
def _rich_text_message(rich_text_list):
|
||||
class _FakeRichTextChatbotMessage:
|
||||
text = None
|
||||
extensions = {}
|
||||
image_content = None
|
||||
rich_text_content = SimpleNamespace(rich_text_list=rich_text_list)
|
||||
sender_staff_id = "user1"
|
||||
sender_id = "fallback-user"
|
||||
sender_nick = "Alice"
|
||||
message_type = "richText"
|
||||
|
||||
@staticmethod
|
||||
def from_dict(_data):
|
||||
return _FakeRichTextChatbotMessage()
|
||||
|
||||
return _FakeRichTextChatbotMessage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_richtext_keeps_formatted_segments(monkeypatch) -> None:
|
||||
"""richText segments with non-'text' types (bold/italic/code/pre) must be kept
|
||||
and mapped to Markdown, not dropped (issue #4497)."""
|
||||
bus = MessageBus()
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
|
||||
bus,
|
||||
)
|
||||
handler = NanobotDingTalkHandler(channel)
|
||||
|
||||
fake_msg = _rich_text_message([
|
||||
{"type": "bold", "text": "Title"},
|
||||
{"type": "text", "text": "plain"},
|
||||
{"type": "italic", "text": "em"},
|
||||
{"type": "inlineCode", "text": "x = 1"},
|
||||
{"type": "pre", "text": "block"},
|
||||
])
|
||||
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
|
||||
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
|
||||
|
||||
status, body = await handler.process(
|
||||
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
|
||||
)
|
||||
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||
|
||||
assert (status, body) == ("OK", "OK")
|
||||
assert msg.content == "**Title** plain *em* `x = 1` ```\nblock\n```"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_richtext_all_formatted_not_dropped(monkeypatch) -> None:
|
||||
"""A richText message made only of formatted segments must not end up with empty
|
||||
content and fall through to the 'unsupported message type' path (issue #4497)."""
|
||||
bus = MessageBus()
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
|
||||
bus,
|
||||
)
|
||||
handler = NanobotDingTalkHandler(channel)
|
||||
|
||||
fake_msg = _rich_text_message([{"type": "bold", "text": "Important"}])
|
||||
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
|
||||
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
|
||||
|
||||
status, body = await handler.process(
|
||||
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
|
||||
)
|
||||
# Before the fix this message produced empty content and never reached the bus,
|
||||
# so consume_inbound would block here.
|
||||
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||
|
||||
assert (status, body) == ("OK", "OK")
|
||||
assert msg.content == "**Important**"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_richtext_item_with_text_and_download(monkeypatch) -> None:
|
||||
"""A rich-text item carrying both text and a downloadCode must yield both the
|
||||
text and the downloaded file, not drop the attachment (issue #4497)."""
|
||||
bus = MessageBus()
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
|
||||
bus,
|
||||
)
|
||||
handler = NanobotDingTalkHandler(channel)
|
||||
|
||||
fake_msg = _rich_text_message([
|
||||
{"text": "see attached", "downloadCode": "abc123", "fileName": "report.xlsx"},
|
||||
])
|
||||
|
||||
async def fake_download(download_code, filename, sender_id):
|
||||
return f"/tmp/nanobot_dingtalk/{sender_id}/{filename}"
|
||||
|
||||
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", fake_msg)
|
||||
monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
|
||||
monkeypatch.setattr(channel, "_download_dingtalk_file", fake_download)
|
||||
|
||||
status, body = await handler.process(
|
||||
SimpleNamespace(data={"conversationType": "1", "text": {"content": ""}})
|
||||
)
|
||||
await asyncio.gather(*list(channel._background_tasks))
|
||||
msg = await asyncio.wait_for(bus.consume_inbound(), timeout=2.0)
|
||||
|
||||
assert (status, body) == ("OK", "OK")
|
||||
assert "see attached" in msg.content
|
||||
assert "/tmp/nanobot_dingtalk/user1/report.xlsx" in msg.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_configures_http_timeout(monkeypatch) -> None:
|
||||
"""The shared httpx client must be created with an explicit timeout so file/image
|
||||
downloads don't hit httpx's 5s default and ConnectTimeout (issue #4497)."""
|
||||
channel = DingTalkChannel(
|
||||
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
|
||||
class _FakeStreamClient:
|
||||
def __init__(self, _credential):
|
||||
pass
|
||||
|
||||
def register_callback_handler(self, _topic, _handler):
|
||||
pass
|
||||
|
||||
async def start(self):
|
||||
# Exit the reconnect loop after one iteration.
|
||||
channel._running = False
|
||||
|
||||
monkeypatch.setattr(dingtalk_module, "DINGTALK_AVAILABLE", True)
|
||||
monkeypatch.setattr(dingtalk_module, "Credential", lambda *a, **k: object())
|
||||
monkeypatch.setattr(dingtalk_module, "DingTalkStreamClient", _FakeStreamClient)
|
||||
monkeypatch.setattr(dingtalk_module, "ChatbotMessage", SimpleNamespace(TOPIC="topic"))
|
||||
|
||||
await channel.start()
|
||||
|
||||
assert channel._http is not None
|
||||
timeout = channel._http.timeout
|
||||
assert timeout.connect == 10.0
|
||||
assert timeout.read == 30.0
|
||||
assert timeout.write == 30.0
|
||||
assert timeout.pool == 10.0
|
||||
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_dingtalk_file(tmp_path, monkeypatch) -> None:
|
||||
"""Test the two-step file download flow (get URL then download content)."""
|
||||
|
||||
@@ -35,7 +35,20 @@ def test_feishu_channel_constructor_does_not_import_lark_oapi():
|
||||
def test_lark_runtime_thread_import_clears_sdk_import_loop():
|
||||
out = _run_import_probe(
|
||||
"import asyncio\n"
|
||||
"import sys\n"
|
||||
"import tempfile\n"
|
||||
"from pathlib import Path\n"
|
||||
"from nanobot.channels.feishu import _load_lark_runtime\n"
|
||||
"root = Path(tempfile.mkdtemp())\n"
|
||||
"pkg = root / 'lark_oapi'\n"
|
||||
"(pkg / 'ws').mkdir(parents=True)\n"
|
||||
"(pkg / 'core').mkdir(parents=True)\n"
|
||||
"(pkg / '__init__.py').write_text('class LogLevel:\\n INFO = 20\\n')\n"
|
||||
"(pkg / 'ws' / '__init__.py').write_text('')\n"
|
||||
"(pkg / 'ws' / 'client.py').write_text('import asyncio\\nloop = asyncio.new_event_loop()\\n')\n"
|
||||
"(pkg / 'core' / '__init__.py').write_text('')\n"
|
||||
"(pkg / 'core' / 'const.py').write_text(\"FEISHU_DOMAIN = 'feishu'\\nLARK_DOMAIN = 'lark'\\n\")\n"
|
||||
"sys.path.insert(0, str(root))\n"
|
||||
"async def main():\n"
|
||||
" await asyncio.to_thread(_load_lark_runtime)\n"
|
||||
" import lark_oapi.ws.client as ws\n"
|
||||
|
||||
@@ -471,7 +471,7 @@ async def test_send_rich_capability_error_latches_and_falls_back() -> None:
|
||||
from telegram.error import BadRequest
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
@@ -490,7 +490,7 @@ async def test_send_rich_bad_request_does_not_latch_capability() -> None:
|
||||
from telegram.error import BadRequest
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], rich_messages=True),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
@@ -505,6 +505,23 @@ async def test_send_rich_bad_request_does_not_latch_capability() -> None:
|
||||
assert len(channel._app.bot.sent_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_messages_default_skips_send_rich_message() -> None:
|
||||
"""By default, sendRichMessage should not be called."""
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._app = _FakeApp(lambda: None)
|
||||
channel._app.bot.do_api_request = AsyncMock()
|
||||
|
||||
await channel.send(OutboundMessage(channel="telegram", chat_id="123", content="**hello**"))
|
||||
|
||||
channel._app.bot.do_api_request.assert_not_called()
|
||||
assert len(channel._app.bot.sent_messages) == 1
|
||||
assert channel._app.bot.sent_messages[0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None:
|
||||
from telegram.error import NetworkError
|
||||
|
||||
@@ -136,7 +136,7 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
|
||||
"""Run GET in a thread to avoid blocking the asyncio loop shared with websockets."""
|
||||
return await asyncio.to_thread(
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0)
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ async def _http_get(
|
||||
url: str, headers: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
return await asyncio.to_thread(
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0)
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
|
||||
)
|
||||
|
||||
|
||||
@@ -506,12 +506,11 @@ async def test_cli_apps_catalog_does_not_block_other_webui_http_routes(
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
started = time.perf_counter()
|
||||
catalog_task = asyncio.create_task(
|
||||
_http_get("http://127.0.0.1:29935/api/settings/cli-apps", headers=auth)
|
||||
)
|
||||
assert await asyncio.wait_for(entered.wait(), 2.0)
|
||||
assert time.perf_counter() - started < 1.0
|
||||
assert not catalog_task.done()
|
||||
|
||||
workspaces_started = time.perf_counter()
|
||||
workspaces = await _http_get("http://127.0.0.1:29935/api/workspaces", headers=auth)
|
||||
|
||||
@@ -90,7 +90,7 @@ async def _http_get(
|
||||
url: str, headers: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
return await asyncio.to_thread(
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0)
|
||||
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Boundary tests for pure WebSocket protocol helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket import (
|
||||
_extract_data_url_mime,
|
||||
_is_valid_chat_id,
|
||||
_parse_envelope,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_id_validator_accepts_only_compact_capability_keys() -> None:
|
||||
valid = [
|
||||
"a",
|
||||
"A-Z_09:chat-id",
|
||||
"x" * 64,
|
||||
]
|
||||
invalid = [
|
||||
"",
|
||||
"x" * 65,
|
||||
"../escape",
|
||||
"chat/id",
|
||||
"chat id",
|
||||
"chat\nid",
|
||||
None,
|
||||
123,
|
||||
]
|
||||
|
||||
for value in valid:
|
||||
assert _is_valid_chat_id(value), value
|
||||
for value in invalid:
|
||||
assert not _is_valid_chat_id(value), repr(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected_type"),
|
||||
[
|
||||
("plain text", None),
|
||||
("{not json", None),
|
||||
("[]", None),
|
||||
("{}", None),
|
||||
('{"type": 42}', None),
|
||||
('{"type": "message", "content": "hi"}', "message"),
|
||||
(' {"type": "new_chat"} ', "new_chat"),
|
||||
],
|
||||
)
|
||||
def test_parse_envelope_only_accepts_typed_json_objects(
|
||||
raw: str,
|
||||
expected_type: str | None,
|
||||
) -> None:
|
||||
parsed = _parse_envelope(raw)
|
||||
if expected_type is None:
|
||||
assert parsed is None
|
||||
else:
|
||||
assert parsed is not None
|
||||
assert parsed["type"] == expected_type
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
("data:image/png;base64,AAAA", "image/png"),
|
||||
("data:IMAGE/JPEG;charset=utf-8;base64,AAAA", "image/jpeg"),
|
||||
("data:video/webm;codecs=vp9;base64,AAAA", "video/webm"),
|
||||
("data:image/svg+xml;base64,AAAA", "image/svg+xml"),
|
||||
("data:image/png,AAAA", None),
|
||||
("data:;base64,AAAA", None),
|
||||
("https://example.invalid/image.png", None),
|
||||
],
|
||||
)
|
||||
def test_extract_data_url_mime_normalizes_only_base64_data_urls(
|
||||
url: str,
|
||||
expected: str | None,
|
||||
) -> None:
|
||||
assert _extract_data_url_mime(url) == expected
|
||||
@@ -1,507 +1,455 @@
|
||||
"""Tests for WhatsApp channel outbound media support."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.channels.whatsapp import (
|
||||
WhatsAppChannel,
|
||||
_load_or_create_bridge_token,
|
||||
)
|
||||
from nanobot.channels import whatsapp as whatsapp_module
|
||||
from nanobot.channels.whatsapp import WhatsAppChannel, _legacy_bridge_config_fields, _NeonizeAPI
|
||||
|
||||
|
||||
def _make_channel() -> WhatsAppChannel:
|
||||
bus = MagicMock()
|
||||
ch = WhatsAppChannel({"enabled": True}, bus)
|
||||
ch._ws = AsyncMock()
|
||||
ch._connected = True
|
||||
class _Proto:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
def HasField(self, name: str) -> bool: # noqa: N802 - protobuf compatibility
|
||||
return _is_set(getattr(self, name, None))
|
||||
|
||||
def ListFields(self): # noqa: N802 - protobuf compatibility
|
||||
return [
|
||||
(SimpleNamespace(name=name), value)
|
||||
for name, value in self.__dict__.items()
|
||||
if _is_set(value)
|
||||
]
|
||||
|
||||
|
||||
def _is_set(value) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, (str, bytes, list, tuple, dict, set)):
|
||||
return bool(value)
|
||||
return True
|
||||
|
||||
|
||||
def _jid(user: str, server: str) -> _Proto:
|
||||
return _Proto(User=user, Server=server, IsEmpty=False)
|
||||
|
||||
|
||||
def _event(
|
||||
*,
|
||||
message: _Proto,
|
||||
message_id: str = "m1",
|
||||
chat: _Proto | None = None,
|
||||
sender: _Proto | None = None,
|
||||
sender_alt: _Proto | None = None,
|
||||
is_group: bool = False,
|
||||
timestamp: int = 1,
|
||||
is_from_me: bool = False,
|
||||
) -> _Proto:
|
||||
source = _Proto(
|
||||
Chat=chat or _jid("15551234567", "s.whatsapp.net"),
|
||||
Sender=sender,
|
||||
SenderAlt=sender_alt,
|
||||
IsGroup=is_group,
|
||||
IsFromMe=is_from_me,
|
||||
)
|
||||
return _Proto(
|
||||
Info=_Proto(ID=message_id, Timestamp=timestamp, MessageSource=source),
|
||||
Message=message,
|
||||
)
|
||||
|
||||
|
||||
def _make_channel(config: dict | None = None) -> WhatsAppChannel:
|
||||
merged = {"enabled": True, "allowFrom": ["*"]}
|
||||
if config:
|
||||
merged.update(config)
|
||||
ch = WhatsAppChannel(merged, MagicMock())
|
||||
ch._started_at = 0
|
||||
return ch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_only():
|
||||
ch = _make_channel()
|
||||
msg = OutboundMessage(channel="whatsapp", chat_id="123@s.whatsapp.net", content="hello")
|
||||
|
||||
await ch.send(msg)
|
||||
|
||||
ch._ws.send.assert_called_once()
|
||||
payload = json.loads(ch._ws.send.call_args[0][0])
|
||||
assert payload["type"] == "send"
|
||||
assert payload["text"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_dispatches_send_media_command():
|
||||
ch = _make_channel()
|
||||
msg = OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="123@s.whatsapp.net",
|
||||
content="check this out",
|
||||
media=["/tmp/photo.jpg"],
|
||||
def _patch_neonize_api(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
whatsapp_module,
|
||||
"_NEONIZE_API",
|
||||
_NeonizeAPI(
|
||||
NewAClient=object,
|
||||
ConnectedEv=object(),
|
||||
DisconnectedEv=object(),
|
||||
MessageEv=object(),
|
||||
PairStatusEv=object(),
|
||||
build_jid=lambda user, server="s.whatsapp.net": (user, server),
|
||||
),
|
||||
)
|
||||
|
||||
await ch.send(msg)
|
||||
|
||||
assert ch._ws.send.call_count == 2
|
||||
text_payload = json.loads(ch._ws.send.call_args_list[0][0][0])
|
||||
media_payload = json.loads(ch._ws.send.call_args_list[1][0][0])
|
||||
class _FakeLoginClient:
|
||||
def __init__(self) -> None:
|
||||
self.handlers = {}
|
||||
self.me = _Proto(JID=_jid("bot", "s.whatsapp.net"), LID=_jid("BOTLID", "lid"))
|
||||
self.stop = AsyncMock()
|
||||
|
||||
assert text_payload["type"] == "send"
|
||||
assert text_payload["text"] == "check this out"
|
||||
def event(self, event_type):
|
||||
def register(func):
|
||||
self.handlers[event_type] = func
|
||||
return func
|
||||
|
||||
assert media_payload["type"] == "send_media"
|
||||
assert media_payload["filePath"] == "/tmp/photo.jpg"
|
||||
assert media_payload["mimetype"] == "image/jpeg"
|
||||
assert media_payload["fileName"] == "photo.jpg"
|
||||
return register
|
||||
|
||||
def qr(self, func):
|
||||
self.qr_handler = func
|
||||
return func
|
||||
|
||||
async def connect(self) -> None:
|
||||
await self.handlers[whatsapp_module._NEONIZE_API.ConnectedEv](self, _Proto())
|
||||
|
||||
|
||||
class _FailingConnectLoginClient(_FakeLoginClient):
|
||||
async def connect(self) -> asyncio.Task[None]:
|
||||
async def fail() -> None:
|
||||
raise RuntimeError("dial failed")
|
||||
|
||||
return asyncio.create_task(fail())
|
||||
|
||||
|
||||
def test_default_config_has_no_bridge_fields() -> None:
|
||||
config = WhatsAppChannel.default_config()
|
||||
|
||||
assert "bridgeUrl" not in config
|
||||
assert "bridgeToken" not in config
|
||||
assert config["databasePath"] == ""
|
||||
|
||||
|
||||
def test_legacy_bridge_config_fields_are_detected() -> None:
|
||||
assert _legacy_bridge_config_fields({"bridgeUrl": "ws://localhost:3001"}) == ["bridgeUrl"]
|
||||
assert _legacy_bridge_config_fields({"bridgeToken": "secret"}) == ["bridgeToken"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_only_no_text():
|
||||
async def test_login_succeeds_when_connected(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = _FakeLoginClient()
|
||||
ch = _make_channel()
|
||||
msg = OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="123@s.whatsapp.net",
|
||||
content="",
|
||||
media=["/tmp/doc.pdf"],
|
||||
ch._new_client = MagicMock(return_value=client)
|
||||
|
||||
assert await ch.login() is True
|
||||
assert ch._self_jids == {"bot@s.whatsapp.net", "bot", "BOTLID@lid", "BOTLID"}
|
||||
client.stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = _FailingConnectLoginClient()
|
||||
ch = _make_channel()
|
||||
ch._new_client = MagicMock(return_value=client)
|
||||
|
||||
assert await ch.login() is False
|
||||
client.stop.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = SimpleNamespace(
|
||||
send_message=AsyncMock(),
|
||||
send_image=AsyncMock(),
|
||||
send_video=AsyncMock(),
|
||||
send_audio=AsyncMock(),
|
||||
send_document=AsyncMock(),
|
||||
)
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
await ch.send(OutboundMessage(channel="whatsapp", chat_id="12345@s.whatsapp.net", content="hi"))
|
||||
|
||||
client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "hi")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = SimpleNamespace(
|
||||
send_message=AsyncMock(),
|
||||
send_image=AsyncMock(),
|
||||
send_video=AsyncMock(),
|
||||
send_audio=AsyncMock(),
|
||||
send_document=AsyncMock(),
|
||||
)
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
await ch.send(
|
||||
OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"],
|
||||
)
|
||||
)
|
||||
|
||||
await ch.send(msg)
|
||||
|
||||
ch._ws.send.assert_called_once()
|
||||
payload = json.loads(ch._ws.send.call_args[0][0])
|
||||
assert payload["type"] == "send_media"
|
||||
assert payload["mimetype"] == "application/pdf"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_multiple_media():
|
||||
ch = _make_channel()
|
||||
msg = OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="123@s.whatsapp.net",
|
||||
content="",
|
||||
media=["/tmp/a.png", "/tmp/b.mp4"],
|
||||
jid = ("12345", "s.whatsapp.net")
|
||||
client.send_image.assert_awaited_once_with(jid, "photo.jpg")
|
||||
client.send_video.assert_awaited_once_with(jid, "clip.mp4")
|
||||
client.send_audio.assert_awaited_once_with(jid, "voice.ogg")
|
||||
client.send_document.assert_awaited_once_with(
|
||||
jid,
|
||||
"report.pdf",
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
)
|
||||
|
||||
await ch.send(msg)
|
||||
|
||||
assert ch._ws.send.call_count == 2
|
||||
p1 = json.loads(ch._ws.send.call_args_list[0][0][0])
|
||||
p2 = json.loads(ch._ws.send.call_args_list[1][0][0])
|
||||
assert p1["mimetype"] == "image/png"
|
||||
assert p2["mimetype"] == "video/mp4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_when_disconnected_is_noop():
|
||||
async def test_send_when_disconnected_raises() -> None:
|
||||
ch = _make_channel()
|
||||
ch._connected = False
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="123@s.whatsapp.net",
|
||||
content="hello",
|
||||
media=["/tmp/x.jpg"],
|
||||
)
|
||||
await ch.send(msg)
|
||||
|
||||
ch._ws.send.assert_not_called()
|
||||
with pytest.raises(RuntimeError, match="not connected"):
|
||||
await ch.send(OutboundMessage(channel="whatsapp", chat_id="123", content="hi"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_mention_skips_unmentioned_group_message():
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
|
||||
async def test_group_policy_mention_skips_unmentioned_group_message() -> None:
|
||||
ch = _make_channel({"groupPolicy": "mention"})
|
||||
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "message",
|
||||
"id": "m1",
|
||||
"sender": "12345@g.us",
|
||||
"pn": "user@s.whatsapp.net",
|
||||
"content": "hello group",
|
||||
"timestamp": 1,
|
||||
"isGroup": True,
|
||||
"wasMentioned": False,
|
||||
}
|
||||
)
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="hello group"),
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("SENDERLID", "lid"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
ch._handle_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_mention_accepts_mentioned_group_message():
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
|
||||
async def test_group_policy_mention_accepts_mention_and_prefers_phone_sender() -> None:
|
||||
ch = _make_channel({"groupPolicy": "mention"})
|
||||
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
|
||||
ch._handle_message = AsyncMock()
|
||||
context = _Proto(mentionedJID=["bot@s.whatsapp.net"])
|
||||
message = _Proto(extendedTextMessage=_Proto(text="hello @bot", contextInfo=context))
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "message",
|
||||
"id": "m1",
|
||||
"sender": "12345@g.us",
|
||||
"pn": "user@s.whatsapp.net",
|
||||
"content": "hello @bot",
|
||||
"timestamp": 1,
|
||||
"isGroup": True,
|
||||
"wasMentioned": True,
|
||||
}
|
||||
)
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=message,
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("LID99", "lid"),
|
||||
sender_alt=_jid("15559998888", "s.whatsapp.net"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
ch._handle_message.assert_awaited_once()
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["chat_id"] == "12345@g.us"
|
||||
assert kwargs["sender_id"] == "user"
|
||||
assert kwargs["sender_id"] == "15559998888"
|
||||
assert kwargs["chat_id"] == "120363000@g.us"
|
||||
assert kwargs["metadata"]["lid"] == "LID99"
|
||||
assert kwargs["metadata"]["phone"] == "15559998888"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_mention_accepts_reply_to_bot_message():
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock())
|
||||
async def test_group_policy_mention_accepts_reply_to_bot() -> None:
|
||||
ch = _make_channel({"groupPolicy": "mention"})
|
||||
ch._self_jids = {"bot@s.whatsapp.net", "bot"}
|
||||
ch._handle_message = AsyncMock()
|
||||
context = _Proto(participant="bot@s.whatsapp.net")
|
||||
message = _Proto(extendedTextMessage=_Proto(text="reply", contextInfo=context))
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "message",
|
||||
"id": "m-reply",
|
||||
"sender": "12345@g.us",
|
||||
"pn": "user@s.whatsapp.net",
|
||||
"content": "replying to bot",
|
||||
"timestamp": 1,
|
||||
"isGroup": True,
|
||||
"wasMentioned": False,
|
||||
"isReplyToBot": True,
|
||||
}
|
||||
)
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=message,
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("SENDERLID", "lid"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
ch._handle_message.assert_awaited_once()
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["metadata"]["is_reply_to_bot"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_id_prefers_phone_jid_over_lid():
|
||||
"""sender_id should resolve to phone number when @s.whatsapp.net JID is present."""
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "lid1",
|
||||
"sender": "ABC123@lid.whatsapp.net",
|
||||
"pn": "5551234@s.whatsapp.net",
|
||||
"content": "hi",
|
||||
"timestamp": 1,
|
||||
})
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["sender_id"] == "5551234"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_sender_id_uses_participant_when_phone_jid_missing():
|
||||
"""Group messages should identify the participant, not the group chat JID."""
|
||||
async def test_group_sender_id_uses_participant_not_group_jid() -> None:
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock())
|
||||
ch._started_at = 0
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "group-lid",
|
||||
"sender": "12345@g.us",
|
||||
"pn": "",
|
||||
"participant": "SENDERLID@lid.whatsapp.net",
|
||||
"content": "hi",
|
||||
"timestamp": 1,
|
||||
"isGroup": True,
|
||||
})
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="hi"),
|
||||
chat=_jid("120363000", "g.us"),
|
||||
sender=_jid("SENDERLID", "lid"),
|
||||
is_group=True,
|
||||
),
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["sender_id"] == "SENDERLID"
|
||||
assert kwargs["metadata"]["participant"] == "SENDERLID@lid.whatsapp.net"
|
||||
assert kwargs["metadata"]["participant"] == "SENDERLID@lid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lid_to_phone_cache_resolves_lid_only_messages():
|
||||
"""When only LID is present, a cached LID→phone mapping should be used."""
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||
async def test_lid_to_phone_cache_resolves_lid_only_messages() -> None:
|
||||
ch = _make_channel()
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
# First message: both phone and LID → builds cache
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "c1",
|
||||
"sender": "LID99@lid.whatsapp.net",
|
||||
"pn": "5559999@s.whatsapp.net",
|
||||
"content": "first",
|
||||
"timestamp": 1,
|
||||
})
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="first"),
|
||||
message_id="c1",
|
||||
chat=_jid("LID99", "lid"),
|
||||
sender=_jid("LID99", "lid"),
|
||||
sender_alt=_jid("5559999", "s.whatsapp.net"),
|
||||
),
|
||||
)
|
||||
# Second message: only LID, no phone
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "c2",
|
||||
"sender": "LID99@lid.whatsapp.net",
|
||||
"pn": "",
|
||||
"content": "second",
|
||||
"timestamp": 2,
|
||||
})
|
||||
await ch._handle_neonize_message(
|
||||
SimpleNamespace(download_any=AsyncMock()),
|
||||
_event(
|
||||
message=_Proto(conversation="second"),
|
||||
message_id="c2",
|
||||
chat=_jid("LID99", "lid"),
|
||||
sender=_jid("LID99", "lid"),
|
||||
),
|
||||
)
|
||||
|
||||
second_kwargs = ch._handle_message.await_args_list[1].kwargs
|
||||
assert second_kwargs["sender_id"] == "5559999"
|
||||
assert ch._handle_message.await_args_list[1].kwargs["sender_id"] == "5559999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_transcription_uses_media_path():
|
||||
"""Voice messages are transcribed when media path is available."""
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
ch.transcribe_audio = AsyncMock(return_value="Hello world")
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "v1",
|
||||
"sender": "12345@s.whatsapp.net",
|
||||
"pn": "",
|
||||
"content": "[Voice Message]",
|
||||
"timestamp": 1,
|
||||
"media": ["/tmp/voice.ogg"],
|
||||
})
|
||||
)
|
||||
|
||||
ch.transcribe_audio.assert_awaited_once_with("/tmp/voice.ogg")
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"].startswith("Hello world")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forwarded_voice_message_preserves_metadata_after_transcription():
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
ch.transcribe_audio = AsyncMock(return_value="Forwarded audio text")
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "v-forwarded",
|
||||
"sender": "12345@s.whatsapp.net",
|
||||
"pn": "",
|
||||
"content": "[Voice Message]",
|
||||
"timestamp": 1,
|
||||
"media": ["/tmp/voice.ogg"],
|
||||
"isForwarded": True,
|
||||
})
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"] == "Forwarded audio text"
|
||||
assert kwargs["metadata"]["is_forwarded"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_voice_message_does_not_transcribe() -> None:
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
ch.transcribe_audio = AsyncMock(return_value="Hello world")
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "v-blocked",
|
||||
"sender": "blocked@s.whatsapp.net",
|
||||
"pn": "",
|
||||
"content": "[Voice Message]",
|
||||
"timestamp": 1,
|
||||
"media": ["/tmp/voice.ogg"],
|
||||
})
|
||||
)
|
||||
|
||||
ch.transcribe_audio.assert_not_awaited()
|
||||
ch._handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_no_media_shows_not_available():
|
||||
"""Voice messages without media produce a fallback placeholder."""
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock())
|
||||
ch._handle_message = AsyncMock()
|
||||
|
||||
await ch._handle_bridge_message(
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"id": "v2",
|
||||
"sender": "12345@s.whatsapp.net",
|
||||
"pn": "",
|
||||
"content": "[Voice Message]",
|
||||
"timestamp": 1,
|
||||
})
|
||||
)
|
||||
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"] == "[Voice Message: Audio not available]"
|
||||
|
||||
|
||||
def test_load_or_create_bridge_token_persists_generated_secret(tmp_path):
|
||||
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
|
||||
|
||||
first = _load_or_create_bridge_token(token_path)
|
||||
second = _load_or_create_bridge_token(token_path)
|
||||
|
||||
assert first == second
|
||||
assert token_path.read_text(encoding="utf-8") == first
|
||||
assert len(first) >= 32
|
||||
if os.name != "nt":
|
||||
assert token_path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_configured_bridge_token_skips_local_token_file(monkeypatch, tmp_path):
|
||||
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
|
||||
ch = WhatsAppChannel({"enabled": True, "bridgeToken": "manual-secret"}, MagicMock())
|
||||
|
||||
assert ch._effective_bridge_token() == "manual-secret"
|
||||
assert not token_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_exports_effective_bridge_token(monkeypatch, tmp_path):
|
||||
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
|
||||
bridge_dir = tmp_path / "bridge"
|
||||
bridge_dir.mkdir()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp._ensure_bridge_setup", lambda: bridge_dir)
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp.shutil.which", lambda _: "/usr/bin/npm")
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp.subprocess.run", fake_run)
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
|
||||
assert await ch.login() is True
|
||||
assert len(calls) == 1
|
||||
|
||||
_, kwargs = calls[0]
|
||||
assert kwargs["cwd"] == bridge_dir
|
||||
assert kwargs["env"]["AUTH_DIR"] == str(token_path.parent)
|
||||
assert kwargs["env"]["BRIDGE_TOKEN"] == token_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sends_auth_message_with_generated_token(monkeypatch, tmp_path):
|
||||
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
|
||||
sent_messages: list[str] = []
|
||||
|
||||
class FakeWS:
|
||||
def __init__(self) -> None:
|
||||
self.close = AsyncMock()
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
sent_messages.append(message)
|
||||
ch._running = False
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
raise StopAsyncIteration
|
||||
|
||||
class FakeConnect:
|
||||
def __init__(self, ws):
|
||||
self.ws = ws
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.ws
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"websockets",
|
||||
types.SimpleNamespace(connect=lambda url: FakeConnect(FakeWS())),
|
||||
)
|
||||
|
||||
ch = WhatsAppChannel({"enabled": True, "bridgeUrl": "ws://localhost:3001"}, MagicMock())
|
||||
await ch.start()
|
||||
|
||||
assert sent_messages == [
|
||||
json.dumps({"type": "auth", "token": token_path.read_text(encoding="utf-8")})
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LID -> phone mapping seeding (startup): static config + bridge reverse files.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lid_mappings_from_config():
|
||||
def test_lid_mappings_from_config() -> None:
|
||||
ch = WhatsAppChannel(
|
||||
{"enabled": True, "lidMappings": {"123456789012345": "15551234567"}},
|
||||
MagicMock(),
|
||||
)
|
||||
assert ch._lid_to_phone["123456789012345"] == "15551234567"
|
||||
|
||||
assert ch._lid_to_phone == {"123456789012345": "15551234567"}
|
||||
|
||||
|
||||
def test_lid_mappings_from_bridge_reverse_files(tmp_path, monkeypatch):
|
||||
auth_dir = tmp_path / "whatsapp-auth"
|
||||
auth_dir.mkdir()
|
||||
(auth_dir / "lid-mapping-999888777666555_reverse.json").write_text(
|
||||
json.dumps("15559998888"), encoding="utf-8"
|
||||
)
|
||||
# malformed / empty files must be ignored, not crash startup
|
||||
(auth_dir / "lid-mapping-broken_reverse.json").write_text("{not json", encoding="utf-8")
|
||||
(auth_dir / "lid-mapping-empty_reverse.json").write_text(json.dumps(""), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.paths.get_runtime_subdir", lambda name: auth_dir
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_media_is_downloaded_and_forwarded(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
|
||||
ch = _make_channel()
|
||||
ch._handle_message = AsyncMock()
|
||||
client = SimpleNamespace(download_any=AsyncMock())
|
||||
message = _Proto(
|
||||
imageMessage=_Proto(
|
||||
caption="look",
|
||||
mimetype="image/jpeg",
|
||||
)
|
||||
)
|
||||
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
assert ch._lid_to_phone == {"999888777666555": "15559998888"}
|
||||
|
||||
|
||||
def test_lid_mappings_config_takes_precedence_over_files(tmp_path, monkeypatch):
|
||||
auth_dir = tmp_path / "whatsapp-auth"
|
||||
auth_dir.mkdir()
|
||||
(auth_dir / "lid-mapping-555_reverse.json").write_text(
|
||||
json.dumps("from-file"), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.paths.get_runtime_subdir", lambda name: auth_dir
|
||||
await ch._handle_neonize_message(
|
||||
client,
|
||||
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
|
||||
)
|
||||
|
||||
ch = WhatsAppChannel(
|
||||
{"enabled": True, "lidMappings": {"555": "from-config"}}, MagicMock()
|
||||
)
|
||||
assert ch._lid_to_phone["555"] == "from-config"
|
||||
client.download_any.assert_awaited_once()
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"].startswith("look\n[image: ")
|
||||
assert len(kwargs["media"]) == 1
|
||||
assert kwargs["media"][0].endswith(".jpg")
|
||||
|
||||
|
||||
def test_lid_mappings_empty_when_no_auth_dir(tmp_path, monkeypatch):
|
||||
missing = tmp_path / "does-not-exist"
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.paths.get_runtime_subdir", lambda name: missing
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_transcribes_and_drops_media_when_successful(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
|
||||
ch = _make_channel()
|
||||
ch._handle_message = AsyncMock()
|
||||
ch.transcribe_audio = AsyncMock(return_value="Hello from audio")
|
||||
client = SimpleNamespace(download_any=AsyncMock())
|
||||
message = _Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True))
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
client,
|
||||
_event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")),
|
||||
)
|
||||
ch = WhatsAppChannel({"enabled": True}, MagicMock())
|
||||
assert ch._lid_to_phone == {}
|
||||
|
||||
ch.transcribe_audio.assert_awaited_once()
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["content"] == "Hello from audio"
|
||||
assert kwargs["media"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_voice_message_does_not_download_or_transcribe(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel)
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())
|
||||
ch._started_at = 0
|
||||
ch._handle_message = AsyncMock()
|
||||
ch.transcribe_audio = AsyncMock(return_value="blocked audio")
|
||||
client = SimpleNamespace(download_any=AsyncMock())
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
client,
|
||||
_event(
|
||||
message=_Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True)),
|
||||
chat=_jid("blocked", "s.whatsapp.net"),
|
||||
sender=_jid("blocked", "s.whatsapp.net"),
|
||||
),
|
||||
)
|
||||
|
||||
client.download_any.assert_not_awaited()
|
||||
ch.transcribe_audio.assert_not_awaited()
|
||||
ch._handle_message.assert_awaited_once()
|
||||
kwargs = ch._handle_message.await_args.kwargs
|
||||
assert kwargs["sender_id"] == "blocked"
|
||||
assert kwargs["content"] == ""
|
||||
assert kwargs["media"] == []
|
||||
assert kwargs["is_dm"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_dm_uses_base_pairing_flow(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH")
|
||||
monkeypatch.setattr("nanobot.channels.base.is_approved", lambda _ch, _sid: False)
|
||||
client = SimpleNamespace(send_message=AsyncMock(), download_any=AsyncMock())
|
||||
ch = WhatsAppChannel({"enabled": True, "allowFrom": []}, MagicMock())
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
ch._started_at = 0
|
||||
|
||||
await ch._handle_neonize_message(
|
||||
client,
|
||||
_event(
|
||||
message=_Proto(conversation="hello"),
|
||||
chat=_jid("blocked", "s.whatsapp.net"),
|
||||
sender=_jid("blocked", "s.whatsapp.net"),
|
||||
),
|
||||
)
|
||||
|
||||
client.download_any.assert_not_awaited()
|
||||
client.send_message.assert_awaited_once()
|
||||
assert client.send_message.await_args.args[0] == ("blocked", "s.whatsapp.net")
|
||||
assert "ABCD-EFGH" in client.send_message.await_args.args[1]
|
||||
|
||||
|
||||
def test_reset_database_removes_sqlite_sidecars(tmp_path) -> None:
|
||||
db = tmp_path / "neonize.db"
|
||||
wal = tmp_path / "neonize.db-wal"
|
||||
shm = tmp_path / "neonize.db-shm"
|
||||
for path in (db, wal, shm):
|
||||
path.write_text("x", encoding="utf-8")
|
||||
|
||||
WhatsAppChannel._reset_database(db)
|
||||
|
||||
assert not db.exists()
|
||||
assert not wal.exists()
|
||||
assert not shm.exists()
|
||||
|
||||
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.cli import commands as cli_commands
|
||||
from nanobot.cli.commands import app
|
||||
@@ -140,6 +141,19 @@ def test_gateway_tty_signal_mode_restores_ctrl_c(monkeypatch) -> None:
|
||||
os.close(slave_fd)
|
||||
|
||||
|
||||
def test_disabled_dream_cursor_only_advances_when_behind(tmp_path) -> None:
|
||||
store = MemoryStore(tmp_path)
|
||||
store.append_history("first")
|
||||
store.append_history("second")
|
||||
|
||||
cli_commands._advance_dream_cursor_if_behind(store)
|
||||
assert store.get_last_dream_cursor() == 2
|
||||
|
||||
store.set_last_dream_cursor(10)
|
||||
cli_commands._advance_dream_cursor_if_behind(store)
|
||||
assert store.get_last_dream_cursor() == 10
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_paths():
|
||||
"""Mock config/workspace paths for test isolation."""
|
||||
@@ -1247,6 +1261,21 @@ def test_heartbeat_skips_bundled_template():
|
||||
assert _heartbeat_has_active_tasks(load_bundled_template("HEARTBEAT.md")) is False
|
||||
|
||||
|
||||
def test_heartbeat_target_skips_archived_webui_sessions():
|
||||
from nanobot.cli.commands import _pick_heartbeat_target_from_sessions
|
||||
|
||||
target = _pick_heartbeat_target_from_sessions(
|
||||
enabled_channels=["websocket"],
|
||||
archived_keys=["websocket:archived"],
|
||||
sessions=[
|
||||
{"key": "websocket:archived"},
|
||||
{"key": "websocket:active"},
|
||||
],
|
||||
)
|
||||
|
||||
assert target == ("websocket", "active")
|
||||
|
||||
|
||||
def _write_instance_config(tmp_path: Path) -> Path:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
|
||||
@@ -246,3 +246,16 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
|
||||
config = load_config(config_path)
|
||||
|
||||
assert config.tools.webui_allow_local_service_access is False
|
||||
|
||||
|
||||
def test_load_config_accepts_exec_local_service_access(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"tools": {"exec": {"allowLocalServiceAccess": True}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path)
|
||||
|
||||
assert config.tools.exec.allow_local_service_access is True
|
||||
assert not hasattr(config.tools, "allow_local_service_access")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.paths import (
|
||||
get_bridge_install_dir,
|
||||
get_cli_history_path,
|
||||
get_cron_dir,
|
||||
get_data_dir,
|
||||
@@ -34,7 +33,6 @@ def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> No
|
||||
|
||||
def test_shared_and_legacy_paths_remain_global() -> None:
|
||||
assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history"
|
||||
assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge"
|
||||
assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions"
|
||||
|
||||
|
||||
|
||||
@@ -29,12 +29,18 @@ def test_dream_config_honors_legacy_cron_override() -> None:
|
||||
assert cfg.describe_schedule() == "cron 0 */4 * * * (legacy)"
|
||||
|
||||
|
||||
def test_dream_config_dump_uses_interval_h_and_hides_legacy_cron() -> None:
|
||||
def test_dream_config_dump_preserves_legacy_cron_override() -> None:
|
||||
cfg = DreamConfig.model_validate({"intervalH": 5, "cron": "0 */4 * * *"})
|
||||
|
||||
dumped = cfg.model_dump(by_alias=True)
|
||||
|
||||
assert dumped["intervalH"] == 5
|
||||
assert dumped["cron"] == "0 */4 * * *"
|
||||
|
||||
|
||||
def test_dream_config_dump_omits_empty_legacy_cron() -> None:
|
||||
dumped = DreamConfig().model_dump(by_alias=True)
|
||||
|
||||
assert "cron" not in dumped
|
||||
|
||||
|
||||
|
||||
@@ -81,6 +81,52 @@ class TestResolveConfig:
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["channels"]["telegram"]["token"] == "${MY_TOKEN}"
|
||||
|
||||
def test_save_preserves_dream_legacy_cron(self, tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{"agents": {"defaults": {"dream": {"cron": "0 */4 * * *"}}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path)
|
||||
config.agents.defaults.max_tokens = 1234
|
||||
save_config(config, config_path)
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["agents"]["defaults"]["dream"]["cron"] == "0 */4 * * *"
|
||||
|
||||
reloaded = load_config(config_path)
|
||||
schedule = reloaded.agents.defaults.dream.build_schedule("UTC")
|
||||
assert schedule.kind == "cron"
|
||||
assert schedule.expr == "0 */4 * * *"
|
||||
|
||||
def test_save_keeps_oauth_provider_configs_excluded(self, tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"dream": {"cron": "0 */4 * * *"}}},
|
||||
"providers": {
|
||||
"openaiCodex": {"apiKey": "codex-secret"},
|
||||
"githubCopilot": {"apiKey": "copilot-secret"},
|
||||
"groq": {"apiKey": "groq-secret"},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path)
|
||||
save_config(config, config_path)
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["agents"]["defaults"]["dream"]["cron"] == "0 */4 * * *"
|
||||
assert "openaiCodex" not in saved["providers"]
|
||||
assert "githubCopilot" not in saved["providers"]
|
||||
assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
|
||||
|
||||
def test_preserves_excluded_fields_when_no_env_refs(self, tmp_path):
|
||||
"""Regression: fields with ``exclude=True`` (e.g. ProviderConfig.openai_codex)
|
||||
must survive ``resolve_config_env_vars`` when the config has no
|
||||
|
||||
@@ -103,6 +103,7 @@ def test_launchd_install_dry_run_renders_plist(tmp_path):
|
||||
"/Users/test/.nanobot/config.json",
|
||||
]
|
||||
assert payload["KeepAlive"] == {"SuccessfulExit": False}
|
||||
assert payload["RunAtLoad"] is True
|
||||
assert ("launchctl", "bootstrap", _expected_launchd_domain(), str(result.path)) in result.commands
|
||||
|
||||
|
||||
@@ -118,11 +119,34 @@ def test_launchd_no_enable_start_still_bootstraps(tmp_path):
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result.content is not None
|
||||
payload = plistlib.loads(result.content.encode("utf-8"))
|
||||
assert payload["RunAtLoad"] is False
|
||||
assert result.commands[0][:2] == ("launchctl", "bootstrap")
|
||||
assert not any(command[1] == "enable" for command in result.commands)
|
||||
assert any(command[1] == "kickstart" for command in result.commands)
|
||||
|
||||
|
||||
def test_launchd_enable_without_start_sets_run_at_load_without_bootstrap(tmp_path):
|
||||
installer = GatewayServiceInstaller(platform_name="Darwin", home=tmp_path)
|
||||
|
||||
result = installer.install(
|
||||
GatewayServiceOptions(
|
||||
start=GatewayStartOptions(port=18790),
|
||||
enable=True,
|
||||
start_now=False,
|
||||
),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result.content is not None
|
||||
payload = plistlib.loads(result.content.encode("utf-8"))
|
||||
assert payload["RunAtLoad"] is True
|
||||
assert not any(command[1] == "bootstrap" for command in result.commands)
|
||||
assert any(command[1] == "enable" for command in result.commands)
|
||||
assert not any(command[1] == "kickstart" for command in result.commands)
|
||||
|
||||
|
||||
def test_launchd_no_enable_start_reinstall_boots_out_existing_label(tmp_path):
|
||||
commands: list[list[str]] = []
|
||||
installer = GatewayServiceInstaller(
|
||||
|
||||
@@ -139,10 +139,32 @@ def test_stop_terminates_recorded_process(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
|
||||
terminated: list[int] = []
|
||||
monkeypatch.setattr(runtime, "_terminate", lambda pid, timeout_s: terminated.append(pid))
|
||||
|
||||
def fake_terminate(pid, timeout_s):
|
||||
terminated.append(pid)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(runtime, "_terminate", fake_terminate)
|
||||
|
||||
result = runtime.stop()
|
||||
|
||||
assert result.ok is True
|
||||
assert terminated == [12345]
|
||||
assert not runtime.paths.state_path.exists()
|
||||
|
||||
|
||||
def test_stop_keeps_state_when_process_survives_timeout(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
runtime.paths.run_dir.mkdir(parents=True)
|
||||
runtime.paths.state_path.write_text('{"pid": 12345, "identity": 12345}', encoding="utf-8")
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
|
||||
monkeypatch.setattr(runtime, "_terminate", lambda _pid, timeout_s: False)
|
||||
|
||||
result = runtime.stop(timeout_s=0)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.message == "gateway_stop_timeout"
|
||||
assert result.status.running is True
|
||||
assert result.status.reason == "stop_timeout"
|
||||
assert runtime.paths.state_path.exists()
|
||||
|
||||
@@ -174,6 +174,39 @@ class TestHandlePairingCommand:
|
||||
assert "Pending pairing requests:" in reply
|
||||
|
||||
|
||||
class TestNonStringSenderId:
|
||||
def test_numeric_sender_id_round_trip(self) -> None:
|
||||
code = store.generate_code("telegram", 12345)
|
||||
assert store.approve_code(code) == ("telegram", "12345")
|
||||
assert store.is_approved("telegram", 12345) is True
|
||||
assert store.is_approved("telegram", "12345") is True
|
||||
assert store.get_approved("telegram") == ["12345"]
|
||||
assert store.revoke("telegram", 12345) is True
|
||||
assert store.is_approved("telegram", "12345") is False
|
||||
|
||||
def test_hand_edited_numeric_pending_does_not_corrupt_approved_set(self) -> None:
|
||||
store._store_path().write_text(
|
||||
'{"approved": {"telegram": ["111"]}, '
|
||||
'"pending": {"ABCD-EFGH": {"channel": "telegram", "sender_id": 222, '
|
||||
'"created_at": 1000.0, "expires_at": 9999999999.0}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert store.approve_code("ABCD-EFGH") == ("telegram", "222")
|
||||
assert store.is_approved("telegram", 222) is True
|
||||
store.generate_code("telegram", 333)
|
||||
assert store.get_approved("telegram") == ["111", "222"]
|
||||
|
||||
def test_numeric_id_in_hand_edited_store(self) -> None:
|
||||
store._store_path().write_text(
|
||||
'{"approved": {"telegram": [12345]}, "pending": {}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert store.is_approved("telegram", "12345") is True
|
||||
assert store.is_approved("telegram", 12345) is True
|
||||
assert store.revoke("telegram", 12345) is True
|
||||
assert store.is_approved("telegram", "12345") is False
|
||||
|
||||
|
||||
class TestStoreDurability:
|
||||
def test_corruption_recovery(self, tmp_path, monkeypatch) -> None:
|
||||
path = tmp_path / "pairing.json"
|
||||
|
||||
@@ -10,6 +10,8 @@ Also tests that bare dicts without a "type" field are coerced to text
|
||||
blocks, fixing Anthropic "content.0.type: Field required" rejections (#3993).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.providers.anthropic_provider import AnthropicProvider
|
||||
|
||||
|
||||
@@ -68,7 +70,7 @@ def test_convert_user_content_coerces_typeless_dict():
|
||||
{"foo": "bar"},
|
||||
{"type": "text", "text": "ok"},
|
||||
])
|
||||
assert result[0] == {"type": "text", "text": str({"foo": "bar"})}
|
||||
assert result[0] == {"type": "text", "text": '{"foo": "bar"}'}
|
||||
assert result[1] == {"type": "text", "text": "ok"}
|
||||
|
||||
|
||||
@@ -79,7 +81,16 @@ def test_convert_user_content_coerces_mixed_typeless():
|
||||
{"key": "val"},
|
||||
])
|
||||
assert result[0] == {"type": "text", "text": "42"}
|
||||
assert result[1] == {"type": "text", "text": str({"key": "val"})}
|
||||
assert result[1] == {"type": "text", "text": '{"key": "val"}'}
|
||||
|
||||
|
||||
def test_assistant_blocks_coerce_typeless_dict_to_json_text():
|
||||
blocks = AnthropicProvider._assistant_blocks({
|
||||
"role": "assistant",
|
||||
"content": [{"answer": "ok", "count": 2}],
|
||||
})
|
||||
|
||||
assert blocks == [{"type": "text", "text": '{"answer": "ok", "count": 2}'}]
|
||||
|
||||
|
||||
def test_convert_assistant_message_repairs_history_tool_arguments():
|
||||
@@ -132,3 +143,79 @@ def test_anthropic_sanitized_tool_ids_avoid_simple_collisions():
|
||||
ids = [block["id"] for block in blocks if block["type"] == "tool_use"]
|
||||
assert len(ids) == len(set(ids)) == 2
|
||||
assert all(all(ch.isalnum() or ch in "_-" for ch in tool_id) for tool_id in ids)
|
||||
|
||||
|
||||
def test_anthropic_convert_messages_remaps_duplicate_history_tool_ids():
|
||||
provider = AnthropicProvider.__new__(AnthropicProvider)
|
||||
|
||||
_system, messages = provider._convert_messages([
|
||||
{"role": "user", "content": "check both files"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_same",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
|
||||
},
|
||||
{
|
||||
"id": "toolu_same",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "toolu_same", "name": "read_file", "content": "a"},
|
||||
{"role": "tool", "tool_call_id": "toolu_same", "name": "read_file", "content": "b"},
|
||||
])
|
||||
|
||||
tool_uses = [
|
||||
block
|
||||
for block in messages[1]["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use"
|
||||
]
|
||||
tool_results = [
|
||||
block
|
||||
for block in messages[2]["content"]
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
]
|
||||
tool_use_ids = [block["id"] for block in tool_uses]
|
||||
tool_result_ids = [block["tool_use_id"] for block in tool_results]
|
||||
|
||||
assert len(tool_use_ids) == 2
|
||||
assert tool_use_ids[0] == "toolu_same"
|
||||
assert tool_use_ids[1] == "toolu_same__dedupe_2"
|
||||
assert tool_result_ids == tool_use_ids
|
||||
assert tool_uses[0]["input"] == {"path": "a.txt"}
|
||||
assert tool_uses[1]["input"] == {"path": "b.txt"}
|
||||
|
||||
|
||||
def test_anthropic_parse_response_remaps_duplicate_tool_use_ids():
|
||||
response = SimpleNamespace(
|
||||
content=[
|
||||
SimpleNamespace(
|
||||
type="tool_use",
|
||||
id="toolu_same",
|
||||
name="read_file",
|
||||
input={"path": "a.txt"},
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="tool_use",
|
||||
id="toolu_same",
|
||||
name="read_file",
|
||||
input={"path": "b.txt"},
|
||||
),
|
||||
],
|
||||
stop_reason="tool_use",
|
||||
usage=None,
|
||||
)
|
||||
|
||||
result = AnthropicProvider._parse_response(response)
|
||||
|
||||
assert len(result.tool_calls) == 2
|
||||
assert result.tool_calls[0].id == "toolu_same"
|
||||
assert result.tool_calls[0].arguments == {"path": "a.txt"}
|
||||
assert result.tool_calls[1].id != "toolu_same"
|
||||
assert result.tool_calls[1].id.startswith("toolu_")
|
||||
assert result.tool_calls[1].arguments == {"path": "b.txt"}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for custom provider thinking_style config passthrough."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nanobot.config.schema import ProviderConfig, ProvidersConfig
|
||||
from nanobot.providers.registry import create_dynamic_spec
|
||||
|
||||
|
||||
class TestCustomProviderThinkingStyle:
|
||||
"""Verify that thinking_style flows from config to ProviderSpec."""
|
||||
|
||||
def test_default_thinking_style_is_empty(self) -> None:
|
||||
cfg = ProviderConfig()
|
||||
assert cfg.thinking_style is None
|
||||
|
||||
def test_create_dynamic_spec_default(self) -> None:
|
||||
spec = create_dynamic_spec("custom")
|
||||
assert spec.thinking_style == ""
|
||||
|
||||
def test_create_dynamic_spec_with_thinking_type(self) -> None:
|
||||
spec = create_dynamic_spec("custom", thinking_style="thinking_type")
|
||||
assert spec.thinking_style == "thinking_type"
|
||||
|
||||
def test_create_dynamic_spec_with_enable_thinking(self) -> None:
|
||||
spec = create_dynamic_spec("custom", thinking_style="enable_thinking")
|
||||
assert spec.thinking_style == "enable_thinking"
|
||||
|
||||
def test_create_dynamic_spec_with_reasoning_split(self) -> None:
|
||||
spec = create_dynamic_spec("custom", thinking_style="reasoning_split")
|
||||
assert spec.thinking_style == "reasoning_split"
|
||||
|
||||
def test_provider_config_accepts_camel_case(self) -> None:
|
||||
"""Config JSON uses camelCase: thinkingStyle."""
|
||||
cfg = ProviderConfig.model_validate({"thinkingStyle": "thinking_type"})
|
||||
assert cfg.thinking_style == "thinking_type"
|
||||
|
||||
def test_providers_config_custom_has_thinking_style(self) -> None:
|
||||
"""Full providers config round-trip."""
|
||||
data = {
|
||||
"custom": {
|
||||
"apiKey": "sk-test",
|
||||
"apiBase": "https://example.com/v1",
|
||||
"thinkingStyle": "enable_thinking",
|
||||
}
|
||||
}
|
||||
pc = ProvidersConfig.model_validate(data)
|
||||
assert pc.custom.thinking_style == "enable_thinking"
|
||||
|
||||
def test_invalid_thinking_style_raises_with_clear_message(self) -> None:
|
||||
"""An invalid thinking_style must raise a ValidationError whose message
|
||||
lists the valid options (not just Pydantic's generic Literal error)."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProviderConfig.model_validate({"thinkingStyle": "thinking_typ"})
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "Invalid thinking_style" in message
|
||||
assert "thinking_type" in message
|
||||
assert "enable_thinking" in message
|
||||
assert "reasoning_split" in message
|
||||
@@ -1228,7 +1228,7 @@ def test_openai_compat_defaults_missing_tool_arguments_to_empty_object() -> None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) -> None:
|
||||
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0")
|
||||
monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0.01")
|
||||
mock_create = AsyncMock(return_value=_StalledStream())
|
||||
spec = find_by_name("openai")
|
||||
|
||||
|
||||
@@ -303,6 +303,37 @@ async def test_codex_http_error_preserves_status_and_retry_after(monkeypatch) ->
|
||||
assert response.error_should_retry is True
|
||||
|
||||
|
||||
def test_codex_response_failed_server_error_is_retryable() -> None:
|
||||
response = _codex_error_response(
|
||||
RuntimeError(
|
||||
"Response failed: {'type': 'server_error', 'code': 'server_error', "
|
||||
"'message': 'The server had an error while processing your request.'}"
|
||||
)
|
||||
)
|
||||
|
||||
assert response.finish_reason == "error"
|
||||
assert response.error_kind == "provider"
|
||||
assert response.error_type == "server_error"
|
||||
assert response.error_code == "server_error"
|
||||
assert response.error_should_retry is True
|
||||
assert provider_base.LLMProvider._is_transient_response(response) is True
|
||||
|
||||
|
||||
def test_codex_response_failed_cyber_policy_is_not_retryable() -> None:
|
||||
response = _codex_error_response(
|
||||
RuntimeError(
|
||||
"Response failed: {'type': 'invalid_request_error', 'code': 'cyber_policy', "
|
||||
"'message': 'Request denied.'}"
|
||||
)
|
||||
)
|
||||
|
||||
assert response.error_kind == "provider"
|
||||
assert response.error_type == "invalid_request_error"
|
||||
assert response.error_code == "cyber_policy"
|
||||
assert response.error_should_retry is False
|
||||
assert provider_base.LLMProvider._is_transient_response(response) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_http_diagnostic_log_omits_raw_body(monkeypatch) -> None:
|
||||
log_capture = _capture_codex_warnings(monkeypatch)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for the OpenCode Zen and OpenCode Go provider registrations."""
|
||||
|
||||
from nanobot.config.schema import Config, ProvidersConfig
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.registry import PROVIDERS, find_by_name
|
||||
|
||||
|
||||
def test_opencode_config_fields_exist() -> None:
|
||||
config = ProvidersConfig()
|
||||
|
||||
assert hasattr(config, "opencode_zen")
|
||||
assert hasattr(config, "opencode_go")
|
||||
|
||||
|
||||
def test_opencode_specs_use_openai_compatible_gateways() -> None:
|
||||
specs = {spec.name: spec for spec in PROVIDERS}
|
||||
|
||||
zen = specs["opencode_zen"]
|
||||
assert zen.backend == "openai_compat"
|
||||
assert zen.env_key == "OPENCODE_API_KEY"
|
||||
assert zen.display_name == "OpenCode Zen"
|
||||
assert zen.is_gateway is True
|
||||
assert zen.detect_by_base_keyword == "opencode.ai/zen"
|
||||
assert zen.default_api_base == "https://opencode.ai/zen/v1"
|
||||
assert "opencode" in zen.strip_model_prefixes
|
||||
|
||||
go = specs["opencode_go"]
|
||||
assert go.backend == "openai_compat"
|
||||
assert go.env_key == "OPENCODE_API_KEY"
|
||||
assert go.display_name == "OpenCode Go"
|
||||
assert go.is_gateway is True
|
||||
assert go.detect_by_base_keyword == "opencode.ai/zen/go"
|
||||
assert go.default_api_base == "https://opencode.ai/zen/go/v1"
|
||||
assert "opencode-go" in go.strip_model_prefixes
|
||||
|
||||
|
||||
def test_find_by_name_opencode_providers() -> None:
|
||||
zen = find_by_name("opencode_zen")
|
||||
assert zen is not None
|
||||
assert zen.name == "opencode_zen"
|
||||
|
||||
go = find_by_name("opencode-go")
|
||||
assert go is not None
|
||||
assert go.name == "opencode_go"
|
||||
|
||||
|
||||
def test_opencode_forced_providers_use_default_api_base() -> None:
|
||||
zen_config = Config.model_validate(
|
||||
{
|
||||
"providers": {"opencodeZen": {"apiKey": "opencode-key"}},
|
||||
"agents": {"defaults": {"provider": "opencode_zen", "model": "opencode/o3"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert zen_config.get_provider_name() == "opencode_zen"
|
||||
assert zen_config.get_api_key() == "opencode-key"
|
||||
assert zen_config.get_api_base() == "https://opencode.ai/zen/v1"
|
||||
|
||||
go_config = Config.model_validate(
|
||||
{
|
||||
"providers": {"opencodeGo": {"apiKey": "opencode-key"}},
|
||||
"agents": {"defaults": {"provider": "opencode_go", "model": "opencode-go/o3"}},
|
||||
}
|
||||
)
|
||||
|
||||
assert go_config.get_provider_name() == "opencode_go"
|
||||
assert go_config.get_api_key() == "opencode-key"
|
||||
assert go_config.get_api_base() == "https://opencode.ai/zen/go/v1"
|
||||
|
||||
|
||||
def test_opencode_prefixes_are_stripped_before_request() -> None:
|
||||
zen_provider = OpenAICompatProvider(
|
||||
api_key=None,
|
||||
default_model="opencode/o3",
|
||||
spec=find_by_name("opencode_zen"),
|
||||
)
|
||||
zen_kwargs = zen_provider._build_kwargs(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
model="opencode/o3",
|
||||
max_tokens=1024,
|
||||
temperature=0.7,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
assert zen_kwargs["model"] == "o3"
|
||||
|
||||
go_provider = OpenAICompatProvider(
|
||||
api_key=None,
|
||||
default_model="opencode-go/o3",
|
||||
spec=find_by_name("opencode_go"),
|
||||
)
|
||||
go_kwargs = go_provider._build_kwargs(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
model="opencode-go/o3",
|
||||
max_tokens=1024,
|
||||
temperature=0.7,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
assert go_kwargs["model"] == "o3"
|
||||
@@ -0,0 +1,50 @@
|
||||
from nanobot.config.schema import Config, ProviderConfig
|
||||
from nanobot.providers.factory import _provider_extra_headers, provider_signature
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
|
||||
def test_kimi_coding_uses_default_user_agent_header() -> None:
|
||||
spec = find_by_name("kimi_coding")
|
||||
|
||||
assert spec is not None
|
||||
assert _provider_extra_headers(spec, ProviderConfig()) == {
|
||||
"User-Agent": "claude-code/0.1.0",
|
||||
}
|
||||
|
||||
|
||||
def test_provider_config_extra_headers_override_defaults() -> None:
|
||||
spec = find_by_name("kimi_coding")
|
||||
provider = ProviderConfig.model_validate({
|
||||
"extraHeaders": {
|
||||
"User-Agent": "custom-client/1.0",
|
||||
"X-Test": "1",
|
||||
},
|
||||
})
|
||||
|
||||
assert _provider_extra_headers(spec, provider) == {
|
||||
"User-Agent": "custom-client/1.0",
|
||||
"X-Test": "1",
|
||||
}
|
||||
|
||||
|
||||
def test_provider_signature_tracks_default_extra_headers() -> None:
|
||||
config = Config.model_validate({
|
||||
"providers": {
|
||||
"kimiCoding": {
|
||||
"apiKey": "sk-kimi-test",
|
||||
},
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"provider": "kimi_coding",
|
||||
"model": "kimi-for-coding",
|
||||
},
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert {"User-Agent": "claude-code/0.1.0"} in provider_signature(config)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user